// Package persist renders the files that make a tunnel survive a reboot: a // systemd unit, a pair of systemd-networkd files, or nothing at all for a // runtime-only tunnel (§9.4). // // The unit deliberately corrects every defect of the one the legacy script // wrote. It orders after network-online.target rather than network.target, // carries no Restart= on a oneshot unit where the directive is inert, cleans up // any leftover device before creating one so a restart cannot fail on it, // tolerates its stop steps so stopping an already-stopped tunnel succeeds, and // identifies itself as panel-owned so the panel never deletes a file it did not // write. // // Note the asymmetry with the rest of the panel: the running kernel is // configured through netlink, while the unit file uses `ip`, so it stays // readable, auditable and functional with the panel uninstalled. Both paths are // built from the same argv builder in internal/link, so they cannot diverge. package persist import ( "fmt" "strings" "github.com/drs/gre-panel/internal/link" ) // OwnershipMarker identifies a file this panel wrote. Its presence is what // makes it safe to overwrite or delete a unit; its absence is what makes it // forbidden without an explicit takeover (§17.3). const OwnershipMarker = "# gre-panel:managed=1" // UnitSuffix is the systemd unit file extension. const UnitSuffix = ".service" // KeepalivePrefix is the unit name prefix of the optional keepalive unit (§9.5). const KeepalivePrefix = "gre-panel-keepalive-" // Default binary paths, used only when nothing better was resolved at startup. // They are fields rather than constants everywhere else precisely because the // legacy script hardcoding /sbin/ip is one of the defects being corrected. const ( DefaultIPBin = "/sbin/ip" DefaultModprobeBin = "/sbin/modprobe" DefaultPingBin = "/bin/ping" ) // Renderer turns a tunnel into the files that persist it. type Renderer struct { IPBin string ModprobeBin string PingBin string } // NewRenderer returns a renderer using the given binaries, falling back to the // conventional locations for any that were not resolved. func NewRenderer(ipBin, modprobeBin, pingBin string) *Renderer { return &Renderer{ IPBin: orDefault(ipBin, DefaultIPBin), ModprobeBin: orDefault(modprobeBin, DefaultModprobeBin), PingBin: orDefault(pingBin, DefaultPingBin), } } func orDefault(value, fallback string) string { if strings.TrimSpace(value) == "" { return fallback } return value } // UnitName is the systemd unit file name for a tunnel. It is the interface // name, which is what the legacy script used too, so a tunnel it created is // taken over by rewriting the same path rather than by leaving two units // fighting over one interface (§12). func UnitName(interfaceName string) string { return interfaceName + UnitSuffix } // KeepaliveUnitName is the unit file name of the optional keepalive unit. func KeepaliveUnitName(interfaceName string) string { return KeepalivePrefix + interfaceName + UnitSuffix } // ModuleFor returns the kernel module a tunnel kind needs. It autoloads on // first use, so the modprobe step is a tolerated hint rather than a // requirement. func ModuleFor(kind string) string { if link.IsIPv6Kind(kind) { return "ip6_gre" } return "ip_gre" } // Unit renders the systemd unit for a tunnel (§9.4). func (r *Renderer) Unit(spec link.TunnelSpec, addresses []link.Address) string { name := spec.Name var b strings.Builder b.WriteString(OwnershipMarker + " interface=" + name + "\n") b.WriteString("# This file is generated by gre-panel. Edit the tunnel in the panel; changes made\n") b.WriteString("# here are overwritten the next time it is applied.\n") b.WriteString("\n[Unit]\n") fmt.Fprintf(&b, "Description=GRE Tunnel %s (managed by gre-panel)\n", name) // network-online.target, not network.target: the local endpoint address has // to exist before `ip link add local ` can succeed. b.WriteString("After=network-online.target\n") b.WriteString("Wants=network-online.target\n") b.WriteString("\n[Service]\n") b.WriteString("Type=oneshot\n") b.WriteString("RemainAfterExit=yes\n") // Both pre-steps are tolerated with a leading dash: the module normally // autoloads, and deleting a device that is not there is the desired state // already. Without the delete, a restart after an unclean stop would fail on // the leftover interface, which is exactly the failure mode the legacy unit // had no answer for. fmt.Fprintf(&b, "ExecStartPre=-%s %s\n", r.ModprobeBin, ModuleFor(spec.Kind)) fmt.Fprintf(&b, "ExecStartPre=-%s\n", join(link.DeleteArgs(r.IPBin, name))) fmt.Fprintf(&b, "ExecStart=%s\n", join(link.CreateArgs(r.IPBin, spec))) for _, addr := range addresses { fmt.Fprintf(&b, "ExecStart=%s\n", join(link.AddAddressArgs(r.IPBin, name, addr))) } if spec.Mtu > 0 { fmt.Fprintf(&b, "ExecStart=%s\n", join(link.SetMTUArgs(r.IPBin, name, spec.Mtu))) } if spec.TxQueueLength != nil { fmt.Fprintf(&b, "ExecStart=%s\n", join(link.SetTxQueueLenArgs(r.IPBin, name, *spec.TxQueueLength))) } fmt.Fprintf(&b, "ExecStart=%s\n", join(link.SetUpArgs(r.IPBin, name))) // Stop steps are tolerated too, so stopping a tunnel that is already gone // succeeds instead of leaving the unit in a failed state. fmt.Fprintf(&b, "ExecStop=-%s\n", join(link.SetDownArgs(r.IPBin, name))) fmt.Fprintf(&b, "ExecStop=-%s\n", join(link.DeleteArgs(r.IPBin, name))) // Deliberately no Restart=: this is a Type=oneshot unit, where the directive // does nothing. The legacy unit carried Restart=on-failure and RestartSec=3, // which read as resilience and provided none. b.WriteString("\n[Install]\n") b.WriteString("WantedBy=multi-user.target\n") return b.String() } // KeepaliveOptions are the parameters of the optional keepalive unit (§9.5). type KeepaliveOptions struct { // Source is the tunnel's own address, which the probe is sent from so it // egresses through the tunnel. Source string // Target is the peer's tunnel address. Target string // IntervalSeconds is the gap between packets. IntervalSeconds float64 // PacketSize is the ICMP payload size in bytes. PacketSize int } // KeepaliveUnit renders the standalone keepalive unit (§9.5). // // This is offered only for operators who want keepalive to survive the panel // being stopped. The default is monitor_only, because the panel's own prober // already sends continuous ICMP from the tunnel source address and therefore is // a keepalive, without one process per tunnel. func (r *Renderer) KeepaliveUnit(interfaceName string, opts KeepaliveOptions) string { var b strings.Builder b.WriteString(OwnershipMarker + " interface=" + interfaceName + " role=keepalive\n") b.WriteString("# This file is generated by gre-panel. Edit the tunnel in the panel; changes made\n") b.WriteString("# here are overwritten the next time it is applied.\n") b.WriteString("\n[Unit]\n") fmt.Fprintf(&b, "Description=GRE Tunnel keepalive for %s (managed by gre-panel)\n", interfaceName) fmt.Fprintf(&b, "After=%s\n", UnitName(interfaceName)) fmt.Fprintf(&b, "BindsTo=%s\n", UnitName(interfaceName)) b.WriteString("\n[Service]\n") b.WriteString("Type=simple\n") fmt.Fprintf(&b, "ExecStart=%s\n", join(KeepaliveArgs(r.PingBin, opts))) // Restart= belongs here and nowhere else in this package: this is a // long-running process, so restarting it is meaningful. b.WriteString("Restart=always\n") b.WriteString("RestartSec=5\n") b.WriteString("\n[Install]\n") b.WriteString("WantedBy=multi-user.target\n") return b.String() } // KeepaliveArgs builds the keepalive command. -O reports outstanding packets so // the journal records loss rather than going silent, and -n skips reverse DNS, // which a tunnel address will not answer anyway. func KeepaliveArgs(pingBin string, opts KeepaliveOptions) []string { interval := opts.IntervalSeconds if interval <= 0 { interval = 1 } size := opts.PacketSize if size <= 0 { size = 56 } return []string{ pingBin, "-I", opts.Source, "-O", "-i", trimFloat(interval), "-s", fmt.Sprintf("%d", size), "-n", opts.Target, } } // trimFloat renders an interval without trailing zeros, so 1.0 is "1". func trimFloat(v float64) string { s := fmt.Sprintf("%.3f", v) s = strings.TrimRight(s, "0") return strings.TrimSuffix(s, ".") } // join renders an argv slice for a unit file line. It is display formatting for // a file systemd itself will split; nothing in this package ever hands a joined // string to a shell (§17.6). func join(argv []string) string { return strings.Join(argv, " ") }