platform: modularize api/gui, add docs-tests-web foundation, and refresh root config
This commit is contained in:
412
selective-vpn-api/app/transportcfg/systemd_helpers.go
Normal file
412
selective-vpn-api/app/transportcfg/systemd_helpers.go
Normal file
@@ -0,0 +1,412 @@
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user