Files

413 lines
13 KiB
Go

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")
}