summaryrefslogtreecommitdiff
path: root/internal/control/lifecycle.go
diff options
context:
space:
mode:
authorsergei <sergei@em-sysadmin.xyz>2026-04-14 06:23:55 +0400
committersergei <sergei@em-sysadmin.xyz>2026-04-14 06:23:55 +0400
commit3d51aa455006903345f554a2dd90034993796114 (patch)
tree62a7be2faf047f5eb7886feebc3b815556f03d7f /internal/control/lifecycle.go
downloadvpnem-main.tar.gz
vpnem-main.tar.bz2
vpnem-main.zip
vpnem: VPN infrastructure with load-balanced multi-protocol nodesHEADmain
- 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/lifecycle.go')
-rw-r--r--internal/control/lifecycle.go205
1 files changed, 205 insertions, 0 deletions
diff --git a/internal/control/lifecycle.go b/internal/control/lifecycle.go
new file mode 100644
index 0000000..a45339f
--- /dev/null
+++ b/internal/control/lifecycle.go
@@ -0,0 +1,205 @@
+package control
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "strings"
+)
+
+func SetNodeEnabled(node Node, enabled bool) Node {
+ node.Enabled = enabled
+ return node
+}
+
+func RotateNodeSecrets(node Node) (Node, error) {
+ for idx := range node.Protocols {
+ protocol := &node.Protocols[idx]
+ switch protocol.Type {
+ case "vless", "vmess":
+ if protocol.Auth == nil {
+ protocol.Auth = &AuthProfile{}
+ }
+ uuid, err := randomUUID()
+ if err != nil {
+ return node, err
+ }
+ protocol.Auth.UUID = uuid
+ case "shadowsocks":
+ if protocol.Auth == nil {
+ protocol.Auth = &AuthProfile{}
+ }
+ password, err := randomHex(16)
+ if err != nil {
+ return node, err
+ }
+ protocol.Auth.Password = password
+ case "hysteria2":
+ if err := ensureHysteria2Profile(protocol); err != nil {
+ return node, err
+ }
+ password, err := randomBase64(16)
+ if err != nil {
+ return node, err
+ }
+ protocol.Auth.Password = password
+ protocol.Hysteria2.UserPassword = password
+ obfsPassword, err := randomHex(32)
+ if err != nil {
+ return node, err
+ }
+ protocol.Hysteria2.ObfsPassword = obfsPassword
+ if protocol.Extra == nil {
+ protocol.Extra = map[string]any{}
+ }
+ protocol.Extra["obfs_password"] = obfsPassword
+ }
+ }
+ return node, nil
+}
+
+func AddSocks5Protocol(node Node, port int) (Node, error) {
+ if port <= 0 {
+ port = 54101
+ }
+ for _, protocol := range node.Protocols {
+ if protocol.Type == "socks5" || protocol.Type == "socks" {
+ return node, fmt.Errorf("node %s already has SOCKS5 enabled", node.ID)
+ }
+ }
+ node.Protocols = append(node.Protocols, ProtocolProfile{
+ Type: "socks5",
+ Enabled: true,
+ Port: port,
+ })
+ return node, nil
+}
+
+func DestroyNode(ctx context.Context, runner SSHExecutor, dnsClient DNSProvider, zone string, node Node, inventoryDir, stateDir string) []string {
+ var warnings []string
+
+ if dnsClient != nil && strings.TrimSpace(node.Domain) != "" && strings.HasSuffix(node.Domain, "."+zone) {
+ name := strings.TrimSuffix(node.Domain, "."+zone)
+ name = strings.TrimSuffix(name, ".")
+ if err := dnsClient.DeleteARecord(ctx, zone, name); err != nil {
+ warnings = append(warnings, "dns cleanup failed: "+err.Error())
+ }
+ }
+
+ if strings.TrimSpace(node.Host) != "" {
+ if _, err := runner.Run(ctx, node, RenderDestroyScript()); err != nil {
+ warnings = append(warnings, "remote cleanup failed: "+err.Error())
+ }
+ }
+
+ if err := DeleteNodeState(stateDir, node.ID); err != nil {
+ warnings = append(warnings, "state cleanup failed: "+err.Error())
+ }
+ if err := DeleteNodeFile(inventoryDir, node.ID); err != nil {
+ warnings = append(warnings, "inventory cleanup failed: "+err.Error())
+ }
+
+ return warnings
+}
+
+func UpgradeNode(ctx context.Context, runner SSHExecutor, node Node, stateDir string) (*NodeState, error) {
+ if _, err := BootstrapNode(ctx, runner, node, BootstrapOptions{
+ StateDir: stateDir,
+ DryRun: false,
+ }); err != nil {
+ return nil, err
+ }
+
+ state, err := CheckNode(ctx, runner, node, stateDir)
+ if state != nil {
+ if state.Metadata == nil {
+ state.Metadata = map[string]any{}
+ }
+ state.Metadata["lifecycle_action"] = "upgrade"
+ _ = SaveNodeState(stateDir, *state)
+ }
+ return state, err
+}
+
+func RepairReinstallNode(ctx context.Context, runner SSHExecutor, node Node, stateDir string) (*NodeState, error) {
+ return reinstallNode(ctx, runner, node, stateDir, "repair_reinstall")
+}
+
+func CleanReinstallNode(ctx context.Context, runner SSHExecutor, node Node, stateDir string) (*NodeState, error) {
+ return reinstallNode(ctx, runner, node, stateDir, "clean_reinstall")
+}
+
+func reinstallNode(ctx context.Context, runner SSHExecutor, node Node, stateDir, action string) (*NodeState, error) {
+ cleanupWarning := ""
+ if strings.TrimSpace(node.Host) != "" {
+ if _, err := runner.Run(ctx, node, RenderDestroyScript()); err != nil {
+ cleanupWarning = err.Error()
+ }
+ }
+
+ if _, err := BootstrapNode(ctx, runner, node, BootstrapOptions{
+ StateDir: stateDir,
+ DryRun: false,
+ }); err != nil {
+ return nil, err
+ }
+
+ state, err := CheckNode(ctx, runner, node, stateDir)
+ if state != nil {
+ if state.Metadata == nil {
+ state.Metadata = map[string]any{}
+ }
+ state.Metadata["lifecycle_action"] = action
+ if cleanupWarning != "" {
+ state.Metadata["cleanup_warning"] = cleanupWarning
+ }
+ _ = SaveNodeState(stateDir, *state)
+ }
+ return state, err
+}
+
+func RenderDestroyScript() string {
+ return `set -eu
+if [ -f /opt/vpnem-node/current/docker-compose.yml ]; then
+ if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
+ docker compose -f /opt/vpnem-node/current/docker-compose.yml down -v || true
+ elif command -v docker-compose >/dev/null 2>&1; then
+ docker-compose -f /opt/vpnem-node/current/docker-compose.yml down -v || true
+ fi
+fi
+rm -rf /opt/vpnem-node
+printf 'vpnem-node removed\n'
+`
+}
+
+func randomHex(size int) (string, error) {
+ buf := make([]byte, size)
+ if _, err := rand.Read(buf); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(buf), nil
+}
+
+func randomUUID() (string, error) {
+ buf := make([]byte, 16)
+ if _, err := rand.Read(buf); err != nil {
+ return "", err
+ }
+ buf[6] = (buf[6] & 0x0f) | 0x40
+ buf[8] = (buf[8] & 0x3f) | 0x80
+ hexID := hex.EncodeToString(buf)
+ return fmt.Sprintf("%s-%s-%s-%s-%s",
+ hexID[0:8],
+ hexID[8:12],
+ hexID[12:16],
+ hexID[16:20],
+ hexID[20:32],
+ ), nil
+}
+
+func RandomHexForAPI(size int) (string, error) { return randomHex(size) }
+
+func RandomBase64ForAPI(size int) (string, error) { return randomBase64(size) }
+
+func RandomUUIDForAPI() (string, error) { return randomUUID() }