package transportcfg import ( "fmt" "os" "path/filepath" "strconv" "strings" ) type SystemdServiceTuning struct { RestartPolicy string RestartSec int StartLimitIntervalSec int StartLimitBurst int TimeoutStartSec int TimeoutStopSec int WatchdogSec int } type SystemdHardening struct { Enabled bool NoNewPrivileges bool PrivateTmp bool ProtectSystem string ProtectHome string ProtectControlGroups bool ProtectKernelModules bool ProtectKernelTunables bool RestrictSUIDSGID bool LockPersonality bool PrivateDevices bool UMask string } func ValidSystemdUnitName(unit string) bool { u := strings.TrimSpace(unit) if u == "" || !strings.HasSuffix(u, ".service") { return false } if strings.Contains(u, "/") || strings.Contains(u, "\\") || strings.Contains(u, "..") { return false } for _, ch := range u { if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') { continue } if ch == '-' || ch == '_' || ch == '.' || ch == '@' { continue } return false } return true } func SystemdUnitPath(unitsDir, unit string) string { return filepath.Join(unitsDir, unit) } func SystemdUnitOwnedByClient(path, clientID string) (bool, error) { data, err := os.ReadFile(path) if err != nil { return false, err } marker := "Environment=SVPN_TRANSPORT_ID=" + strings.TrimSpace(clientID) return strings.Contains(string(data), marker), nil } func WriteSystemdUnitFile(unitsDir, unit, content string) error { path := SystemdUnitPath(unitsDir, unit) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } tmp := path + ".tmp" if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil { return err } return os.Rename(tmp, path) } func RenderSystemdUnit( client Client, stateDir string, unit string, execStart string, requiresSSH bool, sshUnit string, tuning SystemdServiceTuning, hardening SystemdHardening, shellQuoteArg func(string) string, ) string { desc := fmt.Sprintf("Selective VPN transport %s (%s)", client.ID, client.Kind) b := strings.Builder{} b.WriteString("[Unit]\n") b.WriteString("Description=" + desc + "\n") b.WriteString("After=network-online.target\n") b.WriteString("Wants=network-online.target\n") b.WriteString("StartLimitIntervalSec=" + strconv.Itoa(tuning.StartLimitIntervalSec) + "\n") b.WriteString("StartLimitBurst=" + strconv.Itoa(tuning.StartLimitBurst) + "\n") if requiresSSH && strings.TrimSpace(sshUnit) != "" { b.WriteString("Requires=" + sshUnit + "\n") b.WriteString("After=" + sshUnit + "\n") } b.WriteString("\n[Service]\n") b.WriteString("Type=simple\n") b.WriteString("WorkingDirectory=" + stateDir + "\n") b.WriteString("Restart=" + tuning.RestartPolicy + "\n") b.WriteString("RestartSec=" + strconv.Itoa(tuning.RestartSec) + "\n") b.WriteString("Environment=SVPN_TRANSPORT_ID=" + client.ID + "\n") b.WriteString("Environment=SVPN_TRANSPORT_KIND=" + client.Kind + "\n") b.WriteString("ExecStart=" + SystemdShellExec(execStart, shellQuoteArg) + "\n") b.WriteString("ExecStop=/bin/kill -TERM $MAINPID\n") b.WriteString("TimeoutStartSec=" + strconv.Itoa(tuning.TimeoutStartSec) + "\n") b.WriteString("TimeoutStopSec=" + strconv.Itoa(tuning.TimeoutStopSec) + "\n") if tuning.WatchdogSec > 0 { b.WriteString("WatchdogSec=" + strconv.Itoa(tuning.WatchdogSec) + "\n") b.WriteString("NotifyAccess=main\n") } renderSystemdHardening(&b, hardening) b.WriteString("\n[Install]\n") b.WriteString("WantedBy=multi-user.target\n") return b.String() } func RenderSSHOverlayUnit( client Client, stateDir string, unit string, execStart string, tuning SystemdServiceTuning, hardening SystemdHardening, shellQuoteArg func(string) string, ) string { desc := fmt.Sprintf("Selective VPN DNSTT SSH overlay (%s)", client.ID) b := strings.Builder{} b.WriteString("[Unit]\n") b.WriteString("Description=" + desc + "\n") b.WriteString("After=network-online.target\n") b.WriteString("Wants=network-online.target\n") b.WriteString("StartLimitIntervalSec=" + strconv.Itoa(tuning.StartLimitIntervalSec) + "\n") b.WriteString("StartLimitBurst=" + strconv.Itoa(tuning.StartLimitBurst) + "\n") b.WriteString("\n[Service]\n") b.WriteString("Type=simple\n") b.WriteString("WorkingDirectory=" + stateDir + "\n") b.WriteString("Restart=" + tuning.RestartPolicy + "\n") b.WriteString("RestartSec=" + strconv.Itoa(tuning.RestartSec) + "\n") b.WriteString("Environment=SVPN_TRANSPORT_ID=" + client.ID + "\n") b.WriteString("Environment=SVPN_TRANSPORT_KIND=" + client.Kind + "\n") b.WriteString("ExecStart=" + SystemdShellExec(execStart, shellQuoteArg) + "\n") b.WriteString("ExecStop=/bin/kill -TERM $MAINPID\n") b.WriteString("TimeoutStartSec=" + strconv.Itoa(tuning.TimeoutStartSec) + "\n") b.WriteString("TimeoutStopSec=" + strconv.Itoa(tuning.TimeoutStopSec) + "\n") if tuning.WatchdogSec > 0 { b.WriteString("WatchdogSec=" + strconv.Itoa(tuning.WatchdogSec) + "\n") b.WriteString("NotifyAccess=main\n") } renderSystemdHardening(&b, hardening) b.WriteString("\n[Install]\n") b.WriteString("WantedBy=multi-user.target\n") return b.String() } func SystemdServiceTuningFromConfig(cfg map[string]any, prefix string, configInt func(map[string]any, string, int) int) SystemdServiceTuning { intGetter := configInt if intGetter == nil { intGetter = defaultConfigInt } t := SystemdServiceTuning{ RestartPolicy: "always", RestartSec: 2, StartLimitIntervalSec: 300, StartLimitBurst: 30, TimeoutStartSec: 90, TimeoutStopSec: 20, WatchdogSec: 0, } if policy := configStringWithPrefixFallback(cfg, prefix, "restart_policy"); policy != "" { t.RestartPolicy = normalizeSystemdRestartPolicy(policy) } t.RestartSec = clampInt(configIntWithPrefixFallback(cfg, prefix, "restart_sec", t.RestartSec, intGetter), 0, 3600) t.StartLimitIntervalSec = clampInt(configIntWithPrefixFallback(cfg, prefix, "start_limit_interval_sec", t.StartLimitIntervalSec, intGetter), 0, 86400) t.StartLimitBurst = clampInt(configIntWithPrefixFallback(cfg, prefix, "start_limit_burst", t.StartLimitBurst, intGetter), 1, 1000) t.TimeoutStartSec = clampInt(configIntWithPrefixFallback(cfg, prefix, "timeout_start_sec", t.TimeoutStartSec, intGetter), 1, 3600) t.TimeoutStopSec = clampInt(configIntWithPrefixFallback(cfg, prefix, "timeout_stop_sec", t.TimeoutStopSec, intGetter), 1, 3600) t.WatchdogSec = clampInt(configIntWithPrefixFallback(cfg, prefix, "watchdog_sec", t.WatchdogSec, intGetter), 0, 3600) return t } func SystemdHardeningFromConfig(cfg map[string]any, prefix string) SystemdHardening { h := SystemdHardening{ Enabled: true, NoNewPrivileges: true, PrivateTmp: true, ProtectSystem: "full", ProtectHome: "read-only", ProtectControlGroups: true, ProtectKernelModules: true, ProtectKernelTunables: true, RestrictSUIDSGID: true, LockPersonality: true, PrivateDevices: false, UMask: "0077", } profile := strings.ToLower(strings.TrimSpace(configStringWithPrefixFallback(cfg, prefix, "hardening_profile"))) switch profile { case "off", "none", "disabled", "false", "0": h.Enabled = false case "strict": h.ProtectSystem = "strict" h.PrivateDevices = true case "", "baseline", "default": // baseline defaults default: // unknown profile -> keep baseline defaults } if ConfigHasKey(cfg, "hardening_enabled") { h.Enabled = ConfigBool(cfg, "hardening_enabled") } if prefix != "" && ConfigHasKey(cfg, prefix+"hardening_enabled") { h.Enabled = ConfigBool(cfg, prefix+"hardening_enabled") } if !h.Enabled { return h } h.NoNewPrivileges = configBoolWithPrefixFallback(cfg, prefix, "no_new_privileges", h.NoNewPrivileges) h.PrivateTmp = configBoolWithPrefixFallback(cfg, prefix, "private_tmp", h.PrivateTmp) h.ProtectControlGroups = configBoolWithPrefixFallback(cfg, prefix, "protect_control_groups", h.ProtectControlGroups) h.ProtectKernelModules = configBoolWithPrefixFallback(cfg, prefix, "protect_kernel_modules", h.ProtectKernelModules) h.ProtectKernelTunables = configBoolWithPrefixFallback(cfg, prefix, "protect_kernel_tunables", h.ProtectKernelTunables) h.RestrictSUIDSGID = configBoolWithPrefixFallback(cfg, prefix, "restrict_suid_sgid", h.RestrictSUIDSGID) h.LockPersonality = configBoolWithPrefixFallback(cfg, prefix, "lock_personality", h.LockPersonality) h.PrivateDevices = configBoolWithPrefixFallback(cfg, prefix, "private_devices", h.PrivateDevices) h.ProtectSystem = normalizeSystemdProtectSystem(configStringWithPrefixFallback(cfg, prefix, "protect_system"), h.ProtectSystem) h.ProtectHome = normalizeSystemdProtectHome(configStringWithPrefixFallback(cfg, prefix, "protect_home"), h.ProtectHome) h.UMask = normalizeSystemdUMask(configStringWithPrefixFallback(cfg, prefix, "umask"), h.UMask) return h } func SystemdShellExec(command string, shellQuoteArg func(string) string) string { cmd := strings.TrimSpace(command) if cmd == "" { return "/bin/true" } quote := shellQuoteArg if quote == nil { quote = defaultShellQuoteArg } return "/bin/sh -lc " + quote(cmd) } func configStringWithPrefixFallback(cfg map[string]any, prefix, key string) string { if prefix != "" { if v := strings.TrimSpace(ConfigString(cfg, prefix+key)); v != "" { return v } } return strings.TrimSpace(ConfigString(cfg, key)) } func configBoolWithPrefixFallback(cfg map[string]any, prefix, key string, defaultVal bool) bool { base := defaultVal if ConfigHasKey(cfg, key) { base = ConfigBool(cfg, key) } if prefix == "" { return base } if !ConfigHasKey(cfg, prefix+key) { return base } return ConfigBool(cfg, prefix+key) } func configIntWithPrefixFallback(cfg map[string]any, prefix, key string, defaultVal int, configInt func(map[string]any, string, int) int) int { base := configInt(cfg, key, defaultVal) if prefix == "" { return base } if !ConfigHasKey(cfg, prefix+key) { return base } return configInt(cfg, prefix+key, base) } func systemdBool(v bool) string { if v { return "yes" } return "no" } func normalizeSystemdProtectSystem(raw, defaultVal string) string { v := strings.ToLower(strings.TrimSpace(raw)) switch v { case "": return defaultVal case "yes", "true", "1": return "yes" case "no", "false", "0": return "no" case "full", "strict": return v default: return defaultVal } } func normalizeSystemdProtectHome(raw, defaultVal string) string { v := strings.ToLower(strings.TrimSpace(raw)) switch v { case "": return defaultVal case "yes", "true", "1": return "yes" case "no", "false", "0": return "no" case "read-only", "tmpfs": return v default: return defaultVal } } func normalizeSystemdUMask(raw, defaultVal string) string { v := strings.TrimSpace(raw) if v == "" { return defaultVal } if len(v) == 3 { v = "0" + v } if len(v) != 4 { return defaultVal } for _, ch := range v { if ch < '0' || ch > '7' { return defaultVal } } return v } func normalizeSystemdRestartPolicy(v string) string { s := strings.ToLower(strings.TrimSpace(v)) switch s { case "no", "on-success", "on-failure", "on-abnormal", "on-watchdog", "on-abort", "always": return s default: return "always" } } func clampInt(v, minV, maxV int) int { if v < minV { return minV } if v > maxV { return maxV } return v } func defaultConfigInt(cfg map[string]any, key string, defaultVal int) int { if cfg == nil { return defaultVal } raw, ok := cfg[key] if !ok || raw == nil { return defaultVal } switch v := raw.(type) { case int: return v case int64: return int(v) case float64: return int(v) case string: n, err := strconv.Atoi(strings.TrimSpace(v)) if err == nil { return n } } return defaultVal } func defaultShellQuoteArg(in string) string { s := strings.ReplaceAll(in, "'", "'\"'\"'") return "'" + s + "'" } func renderSystemdHardening(b *strings.Builder, h SystemdHardening) { if !h.Enabled { return } b.WriteString("NoNewPrivileges=" + systemdBool(h.NoNewPrivileges) + "\n") b.WriteString("PrivateTmp=" + systemdBool(h.PrivateTmp) + "\n") b.WriteString("ProtectSystem=" + h.ProtectSystem + "\n") b.WriteString("ProtectHome=" + h.ProtectHome + "\n") b.WriteString("ProtectControlGroups=" + systemdBool(h.ProtectControlGroups) + "\n") b.WriteString("ProtectKernelModules=" + systemdBool(h.ProtectKernelModules) + "\n") b.WriteString("ProtectKernelTunables=" + systemdBool(h.ProtectKernelTunables) + "\n") b.WriteString("RestrictSUIDSGID=" + systemdBool(h.RestrictSUIDSGID) + "\n") b.WriteString("LockPersonality=" + systemdBool(h.LockPersonality) + "\n") if h.PrivateDevices { b.WriteString("PrivateDevices=yes\n") } b.WriteString("UMask=" + h.UMask + "\n") }