A server brought up via the UI stayed active but not enabled, so a reboot silently dropped it (and its PostUp cross-tunnel routes) with no error to point at. Start now runs enable --now, Stop runs disable --now, so "running now" and "survives a reboot" are the same action. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATVUwTa4Pqwq26orW5BcDW
124 lines
4.3 KiB
Go
124 lines
4.3 KiB
Go
package wireguard
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
|
|
"github.com/ngoduykhanh/wireguard-ui/util"
|
|
)
|
|
|
|
// UnitName returns the systemd unit name managing the given WireGuard
|
|
// interface via wg-quick, e.g. "wg-quick@wg-home.service".
|
|
func UnitName(iface string) string {
|
|
return "wg-quick@" + iface + ".service"
|
|
}
|
|
|
|
// Start brings up the given WireGuard interface via
|
|
// `systemctl enable --now wg-quick@<iface>.service`. Enabling (not just
|
|
// starting) is deliberate: a server that's up now but not enabled silently
|
|
// vanishes on the next reboot, taking its PostUp-installed cross-tunnel
|
|
// routes with it, with no error anywhere to point at - this makes "started
|
|
// from the UI" and "survives a reboot" the same action instead of two.
|
|
func Start(ctx context.Context, iface string) error {
|
|
EnsureIPForwarding()
|
|
return runSystemctlArgs(ctx, iface, "enable", "--now")
|
|
}
|
|
|
|
// Stop brings down the given WireGuard interface via
|
|
// `systemctl disable --now wg-quick@<iface>.service`. Disabling mirrors
|
|
// Start's enable: an admin-initiated stop should stay stopped after a
|
|
// reboot too, not silently come back.
|
|
func Stop(ctx context.Context, iface string) error {
|
|
return runSystemctlArgs(ctx, iface, "disable", "--now")
|
|
}
|
|
|
|
// Restart restarts the given WireGuard interface via
|
|
// `systemctl restart wg-quick@<iface>.service`.
|
|
func Restart(ctx context.Context, iface string) error {
|
|
EnsureIPForwarding()
|
|
return runSystemctl(ctx, "restart", iface)
|
|
}
|
|
|
|
// EnsureIPForwarding turns on IPv4/IPv6 forwarding for the running kernel
|
|
// (equivalent to `sysctl -w net.ipv4.ip_forward=1`), best-effort. Without
|
|
// this, any server relying on FORWARD rules or NAT egress silently drops
|
|
// all forwarded traffic. Persistence across reboots (e.g.
|
|
// /etc/sysctl.d/*.conf) is left to install-time setup, not this runtime
|
|
// call - this only guarantees the currently running kernel is correct
|
|
// whenever a server is (re)started.
|
|
func EnsureIPForwarding() {
|
|
setSysctl("/proc/sys/net/ipv4/ip_forward")
|
|
setSysctl("/proc/sys/net/ipv6/conf/all/forwarding")
|
|
}
|
|
|
|
func setSysctl(path string) {
|
|
data, err := os.ReadFile(path)
|
|
if err == nil && strings.TrimSpace(string(data)) == "1" {
|
|
return
|
|
}
|
|
_ = os.WriteFile(path, []byte("1"), 0644)
|
|
}
|
|
|
|
func runSystemctl(ctx context.Context, action, iface string) error {
|
|
return runSystemctlArgs(ctx, iface, action)
|
|
}
|
|
|
|
func runSystemctlArgs(ctx context.Context, iface string, action ...string) error {
|
|
if !util.ValidateInterfaceName(iface) {
|
|
return fmt.Errorf("invalid interface name: %q", iface)
|
|
}
|
|
args := append(append([]string{}, action...), UnitName(iface))
|
|
cmd := exec.CommandContext(ctx, "systemctl", args...)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("systemctl %s %s failed: %w: %s", strings.Join(action, " "), UnitName(iface), err, string(out))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Status reports whether the given WireGuard interface's wg-quick unit is
|
|
// currently active and whether it is enabled to start on boot. Only real
|
|
// exec failures (e.g. systemctl binary missing) are returned as err - a
|
|
// unit being inactive/disabled/failed is a normal, non-error result
|
|
// reflected in the returned booleans.
|
|
func Status(ctx context.Context, iface string) (active bool, enabled bool, err error) {
|
|
if !util.ValidateInterfaceName(iface) {
|
|
return false, false, fmt.Errorf("invalid interface name: %q", iface)
|
|
}
|
|
|
|
unit := UnitName(iface)
|
|
|
|
active, err = systemctlBoolCheck(ctx, "is-active", unit)
|
|
if err != nil {
|
|
return false, false, err
|
|
}
|
|
|
|
enabled, err = systemctlBoolCheck(ctx, "is-enabled", unit)
|
|
if err != nil {
|
|
return active, false, err
|
|
}
|
|
|
|
return active, enabled, nil
|
|
}
|
|
|
|
// systemctlBoolCheck runs `systemctl <verb> <unit>` and interprets a
|
|
// non-zero exit code as a normal "false" result (inactive/disabled/failed),
|
|
// not an error. Only a failure to execute systemctl at all (binary missing,
|
|
// context cancelled, ...) is surfaced as err.
|
|
func systemctlBoolCheck(ctx context.Context, verb, unit string) (bool, error) {
|
|
cmd := exec.CommandContext(ctx, "systemctl", verb, unit)
|
|
err := cmd.Run()
|
|
if err == nil {
|
|
return true, nil
|
|
}
|
|
if _, ok := err.(*exec.ExitError); ok {
|
|
// systemctl ran fine and just reported a non-active/non-enabled
|
|
// state via its exit code - not an execution error.
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("systemctl %s %s failed to execute: %w", verb, unit, err)
|
|
}
|