diff options
| author | sergei <sergei@em-sysadmin.xyz> | 2026-04-14 06:23:55 +0400 |
|---|---|---|
| committer | sergei <sergei@em-sysadmin.xyz> | 2026-04-14 06:23:55 +0400 |
| commit | 3d51aa455006903345f554a2dd90034993796114 (patch) | |
| tree | 62a7be2faf047f5eb7886feebc3b815556f03d7f /internal/control/runtime.go | |
| download | vpnem-main.tar.gz vpnem-main.tar.bz2 vpnem-main.zip | |
- Multi-protocol VPS nodes (VLESS-REALITY + Hysteria2 + SOCKS5)
- Smart load balancing via recommendation API
- Windows/Linux client (Go + Wails + sing-box)
- Server API with RealIP detection and connection tracking
- Auto-deployment via vpnui control plane
- Silent Windows installer with UAC elevation
- Load-based server recommendation (no sticky sessions)
- Best Server one-click connection workflow
Diffstat (limited to 'internal/control/runtime.go')
| -rw-r--r-- | internal/control/runtime.go | 586 |
1 files changed, 586 insertions, 0 deletions
diff --git a/internal/control/runtime.go b/internal/control/runtime.go new file mode 100644 index 0000000..93138b6 --- /dev/null +++ b/internal/control/runtime.go @@ -0,0 +1,586 @@ +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 + } +} |
