413 lines
13 KiB
Go
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")
|
|
}
|