summaryrefslogtreecommitdiff
path: root/internal/control/runtime.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/control/runtime.go')
-rw-r--r--internal/control/runtime.go586
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
+ }
+}