package control import ( "archive/tar" "compress/gzip" "encoding/json" "fmt" "os" "path/filepath" "strings" "time" "vpnem/internal/config" ) type RuntimeBundleMeta struct { ReleaseID string `json:"release_id"` CreatedAt string `json:"created_at"` NodeID string `json:"node_id"` } func RenderRuntimeBundle(dir string, node Node, releaseID string) error { if err := os.MkdirAll(dir, 0o755); err != nil { return err } for idx := range node.Protocols { if err := ensureRealityProfile(&node.Protocols[idx]); err != nil { return err } if err := ensureHysteria2Profile(&node.Protocols[idx]); err != nil { return err } } meta := RuntimeBundleMeta{ ReleaseID: releaseID, CreatedAt: time.Now().UTC().Format(time.RFC3339), NodeID: node.ID, } files := map[string][]byte{} nodeJSON, err := json.MarshalIndent(node, "", " ") if err != nil { return err } files["node.json"] = append(nodeJSON, '\n') metaJSON, err := json.MarshalIndent(meta, "", " ") if err != nil { return err } files["bundle-meta.json"] = append(metaJSON, '\n') files["node.env"] = []byte(renderNodeEnv(node)) files["docker-compose.yml"] = []byte(renderRuntimeCompose(node)) files["README.md"] = []byte(renderRuntimeReadme(node)) if hasHysteria2(node) { certHost := hysteria2CertificateHost(node) certPEM, keyPEM, err := generateSelfSignedCertForHost(certHost) if err != nil { return err } files["cert.pem"] = certPEM files["key.pem"] = keyPEM } if config, ok, err := renderSingBoxServerConfig(node); err != nil { return err } else if ok { files["sing-box.server.json"] = []byte(config) if needsEdgeProxy(node) { files["Caddyfile"] = []byte(renderCaddyfile(node)) } } for name, data := range files { path := filepath.Join(dir, name) if err := os.WriteFile(path, data, 0o644); err != nil { return err } } return nil } func CreateTarGzFromDir(srcDir, outPath string) error { outFile, err := os.Create(outPath) if err != nil { return err } defer outFile.Close() gzw := gzip.NewWriter(outFile) defer gzw.Close() tw := tar.NewWriter(gzw) defer tw.Close() return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { return nil } relPath, err := filepath.Rel(srcDir, path) if err != nil { return err } header, err := tar.FileInfoHeader(info, "") if err != nil { return err } header.Name = filepath.ToSlash(relPath) if err := tw.WriteHeader(header); err != nil { return err } data, err := os.ReadFile(path) if err != nil { return err } _, err = tw.Write(data) return err }) } func renderNodeEnv(node Node) string { var b strings.Builder writeEnv := func(key, value string) { b.WriteString(key) b.WriteString("=") b.WriteString(sanitizeEnv(value)) b.WriteString("\n") } writeEnv("NODE_ID", node.ID) writeEnv("NODE_NAME", node.Name) writeEnv("NODE_PROVIDER", node.Provider) writeEnv("NODE_REGION", node.Region) writeEnv("NODE_HOST", node.Host) writeEnv("NODE_DOMAIN", node.Domain) writeEnv("NODE_ACME_EMAIL", node.ACMEEmail) return b.String() } func renderRuntimeCompose(node Node) string { if needsSingBoxRuntime(node) { return renderSingBoxCompose(node) } var b strings.Builder b.WriteString("services:\n") b.WriteString(" node-info:\n") b.WriteString(" image: nginx:alpine\n") b.WriteString(" restart: unless-stopped\n") b.WriteString(" ports:\n") b.WriteString(" - \"127.0.0.1:18080:80\"\n") b.WriteString(" volumes:\n") b.WriteString(" - ./node.json:/usr/share/nginx/html/index.json:ro\n") b.WriteString(" - ./README.md:/usr/share/nginx/html/README.md:ro\n") return b.String() } func renderRuntimeReadme(node Node) string { if hasHysteria2(node) { profile := firstHysteria2Profile(node) return fmt.Sprintf( "# vpnem node bundle\n\nThis bundle was generated for node `%s` in region `%s`.\n\nIncluded runtime:\n- sing-box server with a Hysteria2 inbound on UDP `%d`\n- embedded self-signed TLS certificate\n- Salamander obfuscation enabled\n- local mixed health inbound on `127.0.0.1:1080`\n", node.ID, node.Region, defaultInt(profile.Port, defaultHysteria2Port), ) } if usesVLESSReality(node) { reality := firstRealityProfile(node) return fmt.Sprintf( "# vpnem node bundle\n\nThis bundle was generated for node `%s` in region `%s`.\n\nIncluded runtime:\n- sing-box server with a VLESS REALITY inbound on `%d`\n- no ACME or Caddy layer is required\n- REALITY handshake destination `%s:%d`\n", node.ID, node.Region, realityPort(node), reality.ServerName, reality.ServerPort, ) } if usesVLESSTLS(node) { return fmt.Sprintf( "# vpnem node bundle\n\nThis bundle was generated for node `%s` in region `%s`.\n\nIncluded runtime:\n- sing-box server with a VLESS inbound on loopback\n- Caddy terminating HTTPS with ACME certificates for `%s`\n\nRequirements:\n- the domain must resolve to this VPS\n- ports 80 and 443 must be reachable from the internet\n- acme_email should be set for certificate issuance\n", node.ID, node.Region, node.Domain, ) } return fmt.Sprintf( "# vpnem node bundle\n\nThis bundle was generated for node `%s` in region `%s`.\n\nIt contains inventory metadata and a minimal runtime placeholder. Replace or extend the runtime services as protocol-specific deployers are added.\n", node.ID, node.Region, ) } func sanitizeEnv(value string) string { value = strings.ReplaceAll(value, "\n", "") return value } func usesVLESSTLS(node Node) bool { for _, protocol := range node.Protocols { if protocol.Type == "vless" && protocol.Enabled && protocol.TLS != nil && protocol.TLS.Enabled && strings.TrimSpace(node.Domain) != "" { return true } } return false } func usesVLESSReality(node Node) bool { for _, protocol := range node.Protocols { if protocol.Type == "vless-reality" && protocol.Enabled { return true } } return false } func usesVMessTLS(node Node) bool { for _, protocol := range node.Protocols { if protocol.Type == "vmess" && protocol.Enabled && protocol.TLS != nil && protocol.TLS.Enabled && strings.TrimSpace(node.Domain) != "" { return true } } return false } func needsEdgeProxy(node Node) bool { return usesVLESSTLS(node) || usesVMessTLS(node) } func needsSingBoxRuntime(node Node) bool { for _, protocol := range node.Protocols { if protocol.Enabled { return true } } return false } func renderSingBoxCompose(node Node) string { var b strings.Builder b.WriteString("services:\n") b.WriteString(" sing-box:\n") b.WriteString(" image: ghcr.io/sagernet/sing-box:v1.12.20\n") b.WriteString(" restart: unless-stopped\n") b.WriteString(" command: [\"run\", \"-c\", \"/etc/sing-box/config.json\"]\n") if isHysteria2Only(node) { hy2Port := defaultInt(firstHysteria2Profile(node).Port, defaultHysteria2Port) b.WriteString(" ports:\n") b.WriteString(fmt.Sprintf(" - \"%d:%d/udp\"\n", hy2Port, hy2Port)) b.WriteString(" - \"127.0.0.1:1080:1080/tcp\"\n") } else { b.WriteString(" network_mode: host\n") } b.WriteString(" volumes:\n") b.WriteString(" - ./sing-box.server.json:/etc/sing-box/config.json:ro\n") if hasHysteria2(node) { b.WriteString(" - ./cert.pem:/etc/sing-box/cert.pem:ro\n") b.WriteString(" - ./key.pem:/etc/sing-box/key.pem:ro\n") } b.WriteString("\n") if needsEdgeProxy(node) { b.WriteString(" caddy:\n") b.WriteString(" image: caddy:2\n") b.WriteString(" restart: unless-stopped\n") b.WriteString(" network_mode: host\n") b.WriteString(" depends_on:\n") b.WriteString(" - sing-box\n") b.WriteString(" environment:\n") if strings.TrimSpace(node.ACMEEmail) != "" { b.WriteString(" ACME_EMAIL: " + node.ACMEEmail + "\n") } b.WriteString(" volumes:\n") b.WriteString(" - ./Caddyfile:/etc/caddy/Caddyfile:ro\n") b.WriteString(" - caddy_data:/data\n") b.WriteString(" - caddy_config:/config\n") b.WriteString("\n") b.WriteString("volumes:\n") b.WriteString(" caddy_data:\n") b.WriteString(" caddy_config:\n") } return b.String() } func renderSingBoxServerConfig(node Node) (string, bool, error) { inbounds := make([]map[string]any, 0) if !needsSingBoxRuntime(node) { return "", false, nil } if vless, ok := findProtocol(node, "vless"); ok && vless.Enabled { if vless.Auth == nil || strings.TrimSpace(vless.Auth.UUID) == "" { return "", false, fmt.Errorf("vless runtime requires auth.uuid") } inbound := map[string]any{ "type": "vless", "tag": "vless-in", "users": []map[string]any{ {"uuid": vless.Auth.UUID}, }, } path := stringFromExtra(vless.Extra, "path") if path == "" { path = "/ws" } if vless.TLS != nil && vless.TLS.Enabled && strings.TrimSpace(node.Domain) != "" { inbound["listen"] = "127.0.0.1" inbound["listen_port"] = 10443 inbound["transport"] = map[string]any{ "type": "ws", "path": path, } } else { inbound["listen"] = "0.0.0.0" inbound["listen_port"] = vless.Port } inbounds = append(inbounds, inbound) } if reality, ok := findProtocol(node, "vless-reality"); ok && reality.Enabled { if reality.Auth == nil || strings.TrimSpace(reality.Auth.UUID) == "" { return "", false, fmt.Errorf("vless-reality runtime requires auth.uuid") } if err := ensureRealityProfile(&reality); err != nil { return "", false, err } inbound := map[string]any{ "type": "vless", "tag": "vless-reality-in", "listen": "::", "listen_port": reality.Port, "users": []map[string]any{ {"uuid": reality.Auth.UUID}, }, "tls": map[string]any{ "enabled": true, "server_name": reality.Reality.ServerName, "reality": map[string]any{ "enabled": true, "handshake": map[string]any{ "server": reality.Reality.ServerName, "server_port": defaultInt(reality.Reality.ServerPort, 443), }, "private_key": reality.Reality.PrivateKey, "short_id": []string{reality.Reality.ShortID}, }, }, } inbounds = append(inbounds, inbound) } if ss, ok := findProtocol(node, "shadowsocks"); ok && ss.Enabled { if ss.Auth == nil || strings.TrimSpace(ss.Auth.Method) == "" || strings.TrimSpace(ss.Auth.Password) == "" { return "", false, fmt.Errorf("shadowsocks runtime requires auth.method and auth.password") } inbounds = append(inbounds, map[string]any{ "type": "shadowsocks", "tag": "ss-in", "listen": "0.0.0.0", "listen_port": ss.Port, "method": ss.Auth.Method, "password": ss.Auth.Password, }) } if socks, ok := findProtocol(node, "socks"); ok && socks.Enabled { inbounds = append(inbounds, map[string]any{ "type": "socks", "tag": "socks-in", "listen": "0.0.0.0", "listen_port": socks.Port, }) } if socks, ok := findProtocol(node, "socks5"); ok && socks.Enabled { inbounds = append(inbounds, map[string]any{ "type": "socks", "tag": "socks5-in", "listen": "0.0.0.0", "listen_port": socks.Port, }) } if vmess, ok := findProtocol(node, "vmess"); ok && vmess.Enabled { if vmess.Auth == nil || strings.TrimSpace(vmess.Auth.UUID) == "" { return "", false, fmt.Errorf("vmess runtime requires auth.uuid") } inbound := map[string]any{ "type": "vmess", "tag": "vmess-in", "users": []map[string]any{ {"uuid": vmess.Auth.UUID, "alterId": 0}, }, } path := stringFromExtra(vmess.Extra, "path") if path == "" { path = "/vmess" } if vmess.TLS != nil && vmess.TLS.Enabled && strings.TrimSpace(node.Domain) != "" { inbound["listen"] = "127.0.0.1" inbound["listen_port"] = 10444 inbound["transport"] = map[string]any{ "type": "ws", "path": path, } } else { inbound["listen"] = "0.0.0.0" inbound["listen_port"] = vmess.Port } inbounds = append(inbounds, inbound) } if hy2, ok := findProtocol(node, "hysteria2"); ok && hy2.Enabled { profile := hy2.Hysteria2 if profile == nil { return "", false, fmt.Errorf("hysteria2 runtime requires hysteria2 settings") } inboundConfig, err := config.BuildHysteria2Inbound(node, hy2.Port, profile.UserPassword, profile.ObfsPassword, profile.UpMbps, profile.DownMbps, profile.CertPath, profile.KeyPath) if err != nil { return "", false, err } inbound := map[string]any(*inboundConfig) inbound["users"] = []map[string]any{ {"name": node.ID, "password": profile.UserPassword}, } inbounds = append(inbounds, inbound) if needsHysteria2HealthInbound(node) { inbounds = append(inbounds, map[string]any{ "type": "mixed", "tag": "hy2-health-in", "listen": "127.0.0.1", "listen_port": 1080, }) } } config := map[string]any{ "log": map[string]any{"level": "info"}, "inbounds": inbounds, "outbounds": []map[string]any{ {"type": "direct", "tag": "direct"}, }, } data, err := json.MarshalIndent(config, "", " ") if err != nil { return "", false, err } return string(data) + "\n", true, nil } func renderCaddyfile(node Node) string { var b strings.Builder b.WriteString("{\n") if strings.TrimSpace(node.ACMEEmail) != "" { b.WriteString(" email ") b.WriteString(node.ACMEEmail) b.WriteString("\n") } b.WriteString("}\n\n") b.WriteString(node.Domain) b.WriteString(" {\n") b.WriteString(" encode zstd gzip\n") if vless, ok := findProtocol(node, "vless"); ok && vless.Enabled && vless.TLS != nil && vless.TLS.Enabled { path := stringFromExtra(vless.Extra, "path") if path == "" { path = "/ws" } b.WriteString(" @vless path ") b.WriteString(path) b.WriteString("\n") b.WriteString(" reverse_proxy @vless 127.0.0.1:10443\n") } if vmess, ok := findProtocol(node, "vmess"); ok && vmess.Enabled && vmess.TLS != nil && vmess.TLS.Enabled { path := stringFromExtra(vmess.Extra, "path") if path == "" { path = "/vmess" } b.WriteString(" @vmess path ") b.WriteString(path) b.WriteString("\n") b.WriteString(" reverse_proxy @vmess 127.0.0.1:10444\n") } b.WriteString(" respond /healthz 200\n") b.WriteString("}\n") return b.String() } func firstRealityProfile(node Node) VLESSRealityProfile { for _, protocol := range node.Protocols { if protocol.Type == "vless-reality" && protocol.Enabled && protocol.Reality != nil { return *protocol.Reality } } return VLESSRealityProfile{} } func firstHysteria2Profile(node Node) Hysteria2Profile { for _, protocol := range node.Protocols { if protocol.Type == "hysteria2" && protocol.Enabled && protocol.Hysteria2 != nil { return *protocol.Hysteria2 } } return Hysteria2Profile{} } func realityPort(node Node) int { for _, protocol := range node.Protocols { if protocol.Type == "vless-reality" && protocol.Enabled { return protocol.Port } } return 443 } func defaultInt(value, fallback int) int { if value > 0 { return value } return fallback } func findProtocol(node Node, kind string) (ProtocolProfile, bool) { for _, protocol := range node.Protocols { if protocol.Type == kind { return protocol, true } } return ProtocolProfile{}, false } func hasHysteria2(node Node) bool { hy2, ok := findProtocol(node, "hysteria2") return ok && hy2.Enabled } func isHysteria2Only(node Node) bool { enabled := 0 hy2Enabled := false for _, protocol := range node.Protocols { if !protocol.Enabled { continue } enabled++ if protocol.Type == "hysteria2" { hy2Enabled = true } } return enabled == 1 && hy2Enabled } func needsHysteria2HealthInbound(node Node) bool { return hasHysteria2(node) } func hysteria2CertificateHost(node Node) string { if tls, ok := findProtocol(node, "hysteria2"); ok && tls.TLS != nil && strings.TrimSpace(tls.TLS.ServerName) != "" { return strings.TrimSpace(tls.TLS.ServerName) } suffix := strings.ReplaceAll(strings.ToLower(node.ID), "_", "-") suffix = strings.ReplaceAll(suffix, " ", "-") return "node-" + suffix + ".local" } func intFromExtra(extra map[string]any, key string, fallback int) int { if extra == nil { return fallback } switch value := extra[key].(type) { case int: return value case float64: return int(value) default: return fallback } }