From 062cf9c4f52a15f3fd6e3909f4db392325a9ff9e Mon Sep 17 00:00:00 2001 From: bluebluesoda <300733005+bluebluesoda@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:45:24 +0800 Subject: [PATCH 1/6] feat(ipv6): in-tree NDP responder for global-address NA source Some upstream IPv6 providers reject NDP replies whose source is a link-local address (what ndppd and Linux proxy_ndp emit) and require the advertised global address. Replace the distro ndppd prefix-mode data plane with an in-tree raw AF_PACKET responder whose NA source is the target's global address, re-reading a panel-writable rules file while it runs so vps add/del never restarts it. - add internal/ndp: minimal NDP responder (NS->NA, DAD handling, correct ICMPv6 checksum/hop-limit/flags); only answers for target blocks inside the operator's routed prefix (confines the root responder's trust surface) - main: ipv6-proxy unit (prefix mode) + ipv6-proxy command + ethernetMAC pre-check at install so a broken ext_if never hot-loops under Restart=always - ipv6.go: write ndppd.conf atomically (temp+rename), drop all ndppd daemon management and its sudoers grants - mgr/routed_ipv6.go: configure guest v6 before publishing the NDP rule; pin fe80::1 gateway neighbor, UseGateway=false, local /112 route + vpsmgr-ipv6.service helper in the guest - 50-image.sh/80-debian-dev-image.sh: bake UseGateway=false into [IPv6AcceptRA] --- install.sh | 12 -- scripts/50-image.sh | 2 +- scripts/80-debian-dev-image.sh | 2 +- src/internal/mgr/ipv6.go | 99 ++++------- src/internal/mgr/mgr.go | 23 ++- src/internal/mgr/routed_ipv6.go | 116 ++++++++++++- src/internal/ndp/proxy.go | 297 ++++++++++++++++++++++++++++++++ src/internal/ndp/proxy_test.go | 100 +++++++++++ src/main.go | 121 +++++++++++-- 9 files changed, 666 insertions(+), 106 deletions(-) create mode 100644 src/internal/ndp/proxy.go create mode 100644 src/internal/ndp/proxy_test.go diff --git a/install.sh b/install.sh index e52131c..f0369df 100755 --- a/install.sh +++ b/install.sh @@ -199,18 +199,6 @@ export VPSMGR_IPV4_SUBNET="${VPSMGR_IPV4_SUBNET:-}" export VPSMGR_IPV6_MODE="${VPSMGR_IPV6_MODE:-}" export VPSMGR_IPV6_POOL="${VPSMGR_IPV6_POOL:-}" -# NDP proxy for IPv6 pass-through. Prefix mode: each container owns a /112 -# block and ndppd relays neighbor discovery on the upstream interface for it. -# Pool mode: kernel proxy_ndp handles each /128 (no ndppd needed). Only -# needed in prefix mode; small, no data-plane involvement. -if [[ "${VPSMGR_IPV6_MODE:-}" == "prefix" && -n "${VPSMGR_IPV6_SUBNET:-}" ]]; then - echo - echo "===== installing ndppd (IPv6 NDP proxy) =====" - apt-get update -qq 2>/dev/null || true - DEBIAN_FRONTEND=noninteractive apt-get install -y -qq ndppd - systemctl enable ndppd.service >/dev/null 2>&1 || true -fi - for step in 00-check 10-incus 20-network 30-traefik 40-panel 50-image; do echo echo "===== $step =====" diff --git a/scripts/50-image.sh b/scripts/50-image.sh index 9455b70..ed2b0ac 100755 --- a/scripts/50-image.sh +++ b/scripts/50-image.sh @@ -75,7 +75,7 @@ systemctl enable ssh # deterministic block would hand a fresh container that useless address. if [ -f /etc/systemd/network/eth0.network ]; then sed -i "s/^DHCP=true\$/DHCP=ipv4/" /etc/systemd/network/eth0.network - printf "\n[IPv6AcceptRA]\nUseOnLinkPrefix=false\nUseRoutePrefix=false\nUseAutonomousPrefix=false\nDHCPv6Client=no\n" >> /etc/systemd/network/eth0.network + printf "\n[IPv6AcceptRA]\nUseGateway=false\nUseOnLinkPrefix=false\nUseRoutePrefix=false\nUseAutonomousPrefix=false\nDHCPv6Client=no\n" >> /etc/systemd/network/eth0.network fi # vpsmgr TCP tuning: BBR congestion control, larger autotuned buffers, window # scaling on, and no slow-start reset after idle (better for long-lived SSH and diff --git a/scripts/80-debian-dev-image.sh b/scripts/80-debian-dev-image.sh index 4d03cbf..90d0de8 100755 --- a/scripts/80-debian-dev-image.sh +++ b/scripts/80-debian-dev-image.sh @@ -80,7 +80,7 @@ systemctl enable ssh # host, so the parent prefix must not be treated as on-link if [ -f /etc/systemd/network/eth0.network ]; then sed -i "s/^DHCP=true$/DHCP=ipv4/" /etc/systemd/network/eth0.network - printf "\n[IPv6AcceptRA]\nUseOnLinkPrefix=false\nUseRoutePrefix=false\nUseAutonomousPrefix=false\nDHCPv6Client=no\n" >> /etc/systemd/network/eth0.network + printf "\n[IPv6AcceptRA]\nUseGateway=false\nUseOnLinkPrefix=false\nUseRoutePrefix=false\nUseAutonomousPrefix=false\nDHCPv6Client=no\n" >> /etc/systemd/network/eth0.network fi # --- Go 1.26.7 (manual install, arch-aware, checksum-verified) --- GO_VERSION=1.26.7 diff --git a/src/internal/mgr/ipv6.go b/src/internal/mgr/ipv6.go index 711351a..5e9db3b 100644 --- a/src/internal/mgr/ipv6.go +++ b/src/internal/mgr/ipv6.go @@ -7,6 +7,7 @@ import ( "net" "os" "os/exec" + "path/filepath" "sort" "strconv" "strings" @@ -298,26 +299,13 @@ func (m *Manager) enableProxyNDP() error { const ndppdConfPath = "/etc/vpsmgr/ndppd.conf" const ndppdConfLink = "/etc/ndppd.conf" -// linkNDPPDConf points the daemon's default config path at vpsmgr's rendered -// file. Pinned sudo command (no wildcards), idempotent. -func (m *Manager) linkNDPPDConf() error { - _, err := su.Run("/bin/ln", "-sf", ndppdConfPath, ndppdConfLink) - return err -} - -// unlinkNDPPDConf removes the root-owned symlink (pinned sudo rm). A missing -// file is not an error. -func (m *Manager) unlinkNDPPDConf() error { - _, err := su.Run("/bin/rm", "-f", ndppdConfLink) - return err -} - // ndppdConf renders /etc/ndppd.conf: one `rule ::/112` per container -// under a `proxy ` section, so upstream neighbor solicitations for any -// address in a container's block are relayed to the Incus bridge (the container -// answers for the addresses it binds). `add` / `drop` let a single user be -// added or removed without racing the DB transaction in Add/Del. Empty when -// IPv6 is disabled or no container has a block. +// under a `proxy ` section, so the in-tree NDP responder knows which +// /112 blocks to answer for on the external link. `add` / `drop` let a single +// user be added or removed without racing the DB transaction in Add/Del. +// Empty when IPv6 is disabled or no container has a block. The format is kept +// ndppd-compatible (each rule line is a bare `rule {`), even though the +// daemon is no longer used in prefix mode. func (m *Manager) ndppdConf(add, drop string) (string, error) { if !m.cfg.IPv6Enabled() { return "", nil @@ -362,62 +350,51 @@ func (m *Manager) ndppdConf(add, drop string) (string, error) { return b.String(), nil } -// restartNDPPD (re)starts the ndppd daemon after a config write. ndppd 0.2.x -// has no live reload (SIGHUP terminates it), so a restart is required and its -// liveness is verified afterwards. The distro init script can wedge in a stale -// "active (exited)" state, so a failed start falls back to launching the -// daemon directly. All calls go through the sudoers whitelist. -func (m *Manager) restartNDPPD() error { - if _, err := exec.LookPath("ndppd"); err != nil { - return fmt.Errorf("ndppd is not installed (install.sh installs it when IPv6 is enabled)") - } - _, _ = su.Run("/usr/sbin/service", "ndppd", "restart") - if m.ndppdAlive() { - return nil - } - _, _ = su.Run("/usr/sbin/service", "ndppd", "start") - if m.ndppdAlive() { - return nil - } - // Last resort: start the daemon directly, bypassing the init script. - _, _ = su.Run("/usr/bin/pkill", "-x", "ndppd") - _ = os.Remove("/var/run/ndppd.pid") - if _, err := su.Run("/usr/sbin/ndppd", "-d", "-p", "/var/run/ndppd.pid"); err != nil { - return err - } - return nil -} - -// ndppdAlive reports whether an ndppd process is running. -func (m *Manager) ndppdAlive() bool { - out, err := exec.Command("pgrep", "-x", "ndppd").Output() - return err == nil && len(out) > 0 -} - // writeNDPPD renders the config for the current container set (plus/minus one -// container) and restarts the daemon. When no container has IPv6 routing the -// daemon is stopped, so a stale config can never misroute. +// container) and atomically rewrites the file. The in-tree NDP responder +// re-reads the file while it runs (vps add/del never restarts it), so the +// rewrite must be atomic: a unique temp file in the (vps-writable) config dir +// is renamed over the target, so the responder never reads a half-written rule +// set and the unprivileged panel never depends on overwriting a possibly +// root-owned old file (rename checks the parent dir, not the target). When no +// container has IPv6 routing the file is removed, so a stale rule can never +// misroute. func (m *Manager) writeNDPPD(add, drop string) error { conf, err := m.ndppdConf(add, drop) if err != nil { return err } if conf == "" { - _, _ = su.Run("/usr/sbin/service", "ndppd", "stop") _ = os.Remove(ndppdConfPath) - _ = m.unlinkNDPPDConf() return nil } - if err := os.WriteFile(ndppdConfPath, []byte(conf), 0o644); err != nil { + tmp, err := os.CreateTemp(filepath.Dir(ndppdConfPath), ".ndppd.conf.*") + if err != nil { return err } - // Point the daemon's default config path at the rendered file BEFORE the - // restart, or ndppd exits with "Failed to load configuration file - // '/etc/ndppd.conf'" and every WireIPv6/UnwireIPv6 fails. - if err := m.linkNDPPDConf(); err != nil { + tmpPath := tmp.Name() + removeTemp := true + defer func() { + if removeTemp { + _ = os.Remove(tmpPath) + } + }() + if err := tmp.Chmod(0o644); err != nil { + _ = tmp.Close() return err } - return m.restartNDPPD() + if _, err := tmp.WriteString(conf); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpPath, ndppdConfPath); err != nil { + return err + } + removeTemp = false + return nil } // WireIPv6 registers a container's /112 with the NDP proxy so its addresses diff --git a/src/internal/mgr/mgr.go b/src/internal/mgr/mgr.go index 4243e4e..dd63e49 100644 --- a/src/internal/mgr/mgr.go +++ b/src/internal/mgr/mgr.go @@ -436,15 +436,18 @@ func (m *Manager) Add(name string, opt AddOptions) (*Result, error) { } } } else { - if err := m.WireIPv6(name); err != nil { - cleanup() - return nil, fmt.Errorf("wire ipv6: %w", err) - } - // Host-routed peer IPv6 (no L2 discovery / MITM between containers). + // Host-routed peer IPv6 (no L2 discovery / MITM between containers): + // bring the guest's /128 + local /112 up FIRST, then publish the NDP + // rule. Publishing last means an outside client's first SYN never + // races the guest still half-configured during `vps add`. if err := m.ConfigureContainerIPv6(name, ""); err != nil { cleanup() return nil, fmt.Errorf("config container ipv6: %w", err) } + if err := m.WireIPv6(name); err != nil { + cleanup() + return nil, fmt.Errorf("wire ipv6: %w", err) + } } u, err := m.db.CreateUserFull(name, hash, ip, idx, sshPort, startPort, opt.CPU, opt.MemMB, opt.DiskGB, opt.BandwidthGB, db.StatusCreating, poolAddr) if err != nil { @@ -1263,14 +1266,16 @@ func (m *Manager) Reinstall(name, image string) (string, error) { } } } else { - if err := m.WireIPv6(u.Name); err != nil { - rollback() - return "", fmt.Errorf("wire ipv6: %w", err) - } + // Configure the guest's IPv6 first, then publish the NDP rule, so the + // outside world only learns the /112 once the container is ready. if err := m.ConfigureContainerIPv6(u.Name, ""); err != nil { rollback() return "", fmt.Errorf("config container ipv6: %w", err) } + if err := m.WireIPv6(u.Name); err != nil { + rollback() + return "", fmt.Errorf("wire ipv6: %w", err) + } } // User-defined init script (if any): run it detached inside the container, // last of all, so it sees the full network. Best-effort — the container is diff --git a/src/internal/mgr/routed_ipv6.go b/src/internal/mgr/routed_ipv6.go index c392538..ea0001a 100644 --- a/src/internal/mgr/routed_ipv6.go +++ b/src/internal/mgr/routed_ipv6.go @@ -2,6 +2,9 @@ package mgr import ( "fmt" + "net" + "os" + "path/filepath" "strings" "vpsmgr/internal/cfg" @@ -69,9 +72,14 @@ func (m *Manager) ipv6ContainerScriptFor(ipv6, prefix string) (string, error) { if ipv6 == "" { return "", nil } + bridgeMAC := m.bridgeMAC() if prefix == "" { return m.poolContainerScript(ipv6) } + localBlock, err := ipv6LocalBlock(ipv6) + if err != nil { + return "", err + } flush := fmt.Sprintf(`for r in $(ip -6 route show dev eth0 | awk '{print $1}'); do case "$r" in %s*) ip -6 route del "$r" dev eth0 2>/dev/null || true ;; @@ -79,6 +87,12 @@ func (m *Manager) ipv6ContainerScriptFor(ipv6, prefix string) (string, error) { done ip -6 route flush cache 2>/dev/null || true`, prefix) script := fmt.Sprintf(`set -e +# GATEWAY_MAC is the Incus bridge's MAC: the container's default route is +# fe80::1 (the bridge gateway), but security.ipv6_filtering drops the guest's +# NDP lookup for it, so we pin the neighbor. LOCAL_BLOCK is the container's +# /112; it must be a local route for services to bind any address in it. +GATEWAY_MAC=%q +LOCAL_BLOCK=%q # Wait for systemd to be ready before touching it: right after boot the # /run/systemd/private bus socket may not exist yet, and systemctl then fails # with "Failed to connect to system scope bus". Bounded, cheap. @@ -99,18 +113,40 @@ if command -v nmcli >/dev/null 2>&1 && ! systemctl is-active systemd-networkd >/ ipv6.addresses %s/128 \ ipv6.gateway fe80::1 2>/dev/null || true [ -n "$CONN" ] && nmcli con up "$CONN" >/dev/null 2>&1 || true + [ -n "$GATEWAY_MAC" ] && ip -6 neigh replace fe80::1 lladdr "$GATEWAY_MAC" nud permanent dev eth0 2>/dev/null || true else CFG=/etc/systemd/network/eth0.network + mkdir -p /etc/systemd/network + [ -f "$CFG" ] || { printf '\n[Match]\nName=eth0\n' > "$CFG"; } changed=0 # Rebuild the [IPv6AcceptRA] section from scratch — the only way to heal # both the mangled single-line residue buggy old versions wrote and sections # missing any option. Canonical contents: the parent prefix is never on-link # and never a route, no SLAAC address is generated, and the RA's Managed flag - # must not start a DHCPv6 client (a dynamic address would fall outside the - # routed block, which ipv6_filtering drops). - if ! grep -qs '^DHCPv6Client=no$' "$CFG" 2>/dev/null; then + # must not start a DHCPv6 client. UseGateway=false keeps an RA from installing + # a competing dynamic default route. + if ! grep -qs '^UseGateway=false$' "$CFG" 2>/dev/null || \ + ! grep -qs '^DHCPv6Client=no$' "$CFG" 2>/dev/null; then awk 'BEGIN{s=0} /^n\[IPv6AcceptRA\]/{next} /^\[IPv6AcceptRA\]$/{s=1;next} s&&/^\[/{s=0} !s' "$CFG" > "$CFG.new" && mv "$CFG.new" "$CFG" - printf '\n[IPv6AcceptRA]\nUseOnLinkPrefix=false\nUseRoutePrefix=false\nUseAutonomousPrefix=false\nDHCPv6Client=no\n' >> "$CFG" + cat >> "$CFG" </dev/null; then + awk 'BEGIN{s=0} /^\[Neighbor\]$/{s=1;next} s&&/^\[/{s=0} !s' "$CFG" > "$CFG.new" && mv "$CFG.new" "$CFG" + cat >> "$CFG" </dev/null; then - printf '\n[Address]\nAddress=%s/128\n' %q >> "$CFG" + cat >> "$CFG" </dev/null; then - printf '\n[Route]\nDestination=::/0\nGateway=fe80::1\n' >> "$CFG" + cat >> "$CFG" </dev/null; then + cat >> "$CFG" </dev/null || true fi -%s`, ipv6, ipv6, ipv6, ipv6, flush) +# Make the complete routed /112 local inside the guest for every image stack +# (including NetworkManager), and re-apply it after a network restart via a +# one-shot unit. +ip -6 route replace local "$LOCAL_BLOCK" dev eth0 +mkdir -p /etc/systemd/system +cat > /etc/systemd/system/vpsmgr-ipv6.service </dev/null 2>&1 || true +systemctl restart vpsmgr-ipv6.service >/dev/null 2>&1 || true +%s`, bridgeMAC, localBlock, ipv6, ipv6, ipv6, flush) return script, nil } +// ipv6LocalBlock returns the /112 the container's primary address belongs to. +func ipv6LocalBlock(ipv6 string) (string, error) { + ip := net.ParseIP(ipv6) + if ip == nil || ip.To16() == nil || ip.To4() != nil { + return "", fmt.Errorf("invalid IPv6 address %q", ipv6) + } + mask := net.CIDRMask(blockBits, 128) + return (&net.IPNet{IP: ip.To16().Mask(mask), Mask: mask}).String(), nil +} + +// bridgeMAC returns the Incus bridge's MAC (used to pin the guest's fe80::1 +// gateway neighbor), or "" when the bridge has no Ethernet address. +func (m *Manager) bridgeMAC() string { + path := filepath.Join("/sys/class/net", m.cfg.Incus.Bridge, "address") + b, err := os.ReadFile(path) + if err != nil { + return "" + } + mac, err := net.ParseMAC(strings.TrimSpace(string(b))) + if err != nil || len(mac) != 6 { + return "" + } + return mac.String() +} + // poolContainerScript renders the guest-side network config for a pool // container, on whichever stack the image ships: // diff --git a/src/internal/ndp/proxy.go b/src/internal/ndp/proxy.go new file mode 100644 index 0000000..3920a09 --- /dev/null +++ b/src/internal/ndp/proxy.go @@ -0,0 +1,297 @@ +// Package ndp implements the small IPv6 neighbour responder vpsmgr needs for +// routed prefixes. Linux's proxy_ndp and ndppd answer with a link-local +// source address. Some providers reject that response and only accept a +// neighbour advertisement whose source is the advertised global address, so +// vpsmgr emits the advertisement directly on the external Ethernet link. +package ndp + +import ( + "bufio" + "encoding/binary" + "errors" + "fmt" + "log" + "net" + "os" + "strings" + "time" + + "golang.org/x/sys/unix" +) + +const ( + ethernetHeaderLen = 14 + ipv6HeaderLen = 40 + icmpv6NS = 135 + icmpv6NA = 136 + etherTypeIPv6 = 0x86dd + + // Router, solicited and override. This matches the advertisement emitted + // by a directly-configured IPv6 address on Linux. + naFlags = uint32(0xe0000000) +) + +// Run listens for IPv6 Neighbor Solicitations on external and emits a +// Neighbor Advertisement for targets covered by the CIDR rules in configPath. +// allowed, when non-nil, is the operator's routed prefix: the responder only +// ever answers for a target inside it (and ignores any rule file entry outside +// it). This confines the root raw-socket listener to the operator's own address +// space, so a compromised writer of the rules file cannot turn the host into an +// NDP spoofer for arbitrary external addresses. +// +// The rules file is reread at most once per second while idle, and its mtime is +// checked when an NS arrives. This makes an add/del take effect for that very +// first solicitation without restarting this long-running process. +func Run(configPath, external string, allowed *net.IPNet) error { + iface, err := net.InterfaceByName(external) + if err != nil { + return fmt.Errorf("find external interface %s: %w", external, err) + } + if len(iface.HardwareAddr) != 6 { + return fmt.Errorf("external interface %s has no Ethernet MAC", external) + } + + fd, err := unix.Socket(unix.AF_PACKET, unix.SOCK_RAW, int(htons(etherTypeIPv6))) + if err != nil { + return fmt.Errorf("open IPv6 packet socket: %w", err) + } + defer unix.Close(fd) + bind := &unix.SockaddrLinklayer{Ifindex: iface.Index, Protocol: htons(etherTypeIPv6)} + if err := unix.Bind(fd, bind); err != nil { + return fmt.Errorf("bind IPv6 packet socket to %s: %w", external, err) + } + // A timeout lets us reload the rules and observe a clean shutdown signal + // without needing a second control socket. + tv := unix.NsecToTimeval(int64(time.Second)) + if err := unix.SetsockoptTimeval(fd, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &tv); err != nil { + return fmt.Errorf("set packet socket timeout: %w", err) + } + + mac := [6]byte{} + copy(mac[:], iface.HardwareAddr) + var rules []net.IPNet + var rulesAt time.Time + var rulesModTime time.Time + var lastRuleError time.Time + packet := make([]byte, 4096) + for { + if time.Since(rulesAt) >= time.Second { + next, loadErr := loadRules(configPath) + if loadErr != nil { + // Removing the file is the normal representation of an empty + // container set. Clear the old rules immediately, otherwise a + // deleted container would remain advertised until this process + // restarted. A malformed/read-failed file is different: keep the + // last known-good rules while the writer finishes an update. + if errors.Is(loadErr, errNoRules) || errors.Is(loadErr, os.ErrNotExist) { + rules = nil + rulesAt = time.Now() + rulesModTime = time.Time{} + continue + } + // A config write is atomic from the daemon's point of view in + // normal operation, but tolerate the brief empty/truncated + // window and keep the last known-good rules. + if time.Since(lastRuleError) >= 10*time.Second { + log.Printf("IPv6 NDP rule reload: %v", loadErr) + lastRuleError = time.Now() + } + } else { + rules = filterRules(next, allowed) + rulesAt = time.Now() + if info, statErr := os.Stat(configPath); statErr == nil { + rulesModTime = info.ModTime() + } + } + } + + n, _, recvErr := unix.Recvfrom(fd, packet, 0) + if recvErr != nil { + if recvErr == unix.EAGAIN || recvErr == unix.EWOULDBLOCK || recvErr == unix.EINTR { + continue + } + return fmt.Errorf("receive IPv6 packet: %w", recvErr) + } + if n < ethernetHeaderLen+ipv6HeaderLen+24 || len(rules) == 0 { + continue + } + frame := packet[:n] + if binary.BigEndian.Uint16(frame[12:14]) != etherTypeIPv6 || frame[20] != 58 { + continue + } + // ICMPv6 starts immediately after the fixed IPv6 header. Extension + // headers do not occur on Neighbor Solicitations. + icmp := frame[ethernetHeaderLen+ipv6HeaderLen:] + if len(icmp) < 24 || icmp[0] != icmpv6NS || icmp[1] != 0 { + continue + } + // A new rule can be written immediately before the first external NS, + // while the one-second periodic reload is still waiting. Notice an + // atomic rename here so that first NS is answered instead of being + // silently dropped and forcing the client to retry. + if info, statErr := os.Stat(configPath); statErr == nil { + if !info.ModTime().Equal(rulesModTime) { + if next, loadErr := loadRules(configPath); loadErr == nil { + rules = filterRules(next, allowed) + rulesAt = time.Now() + rulesModTime = info.ModTime() + } else if errors.Is(loadErr, errNoRules) || errors.Is(loadErr, os.ErrNotExist) { + rules = nil + rulesAt = time.Now() + rulesModTime = time.Time{} + } + } + } else if errors.Is(statErr, os.ErrNotExist) && !rulesModTime.IsZero() { + rules = nil + rulesAt = time.Now() + rulesModTime = time.Time{} + } + target := net.IP(append([]byte(nil), icmp[8:24]...)) + if !matches(target, rules) { + continue + } + if err := sendAdvertisement(fd, iface.Index, mac, frame, target); err != nil { + return fmt.Errorf("send NDP advertisement for %s: %w", target, err) + } + } +} + +func loadRules(path string) ([]net.IPNet, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + var rules []net.IPNet + s := bufio.NewScanner(f) + for s.Scan() { + fields := strings.Fields(s.Text()) + if len(fields) < 2 || fields[0] != "rule" { + continue + } + _, network, err := net.ParseCIDR(fields[1]) + if err != nil || network.IP.To4() != nil { + continue + } + rules = append(rules, *network) + } + if err := s.Err(); err != nil { + return nil, err + } + if len(rules) == 0 { + return nil, fmt.Errorf("%w: %s", errNoRules, path) + } + return rules, nil +} + +var errNoRules = errors.New("no IPv6 rules") + +// filterRules drops any rule not contained in allowed. When allowed is nil +// every rule is kept (tests / unconstrained mode). +func filterRules(rules []net.IPNet, allowed *net.IPNet) []net.IPNet { + if allowed == nil { + return rules + } + out := rules[:0] + for _, r := range rules { + if r.IP != nil && allowed.Contains(r.IP) { + out = append(out, r) + } + } + return out +} + +func matches(target net.IP, rules []net.IPNet) bool { + for i := range rules { + if rules[i].Contains(target) { + return true + } + } + return false +} + +func sendAdvertisement(fd, ifindex int, sourceMAC [6]byte, ns []byte, target net.IP) error { + frame, dstMAC, err := buildAdvertisement(sourceMAC, ns, target) + if err != nil { + return err + } + addr := &unix.SockaddrLinklayer{Ifindex: ifindex, Halen: 6, Protocol: htons(etherTypeIPv6)} + copy(addr.Addr[:], dstMAC[:]) + return unix.Sendto(fd, frame, 0, addr) +} + +func buildAdvertisement(sourceMAC [6]byte, ns []byte, target net.IP) ([]byte, [6]byte, error) { + var emptyMAC [6]byte + if len(ns) < ethernetHeaderLen+ipv6HeaderLen+24 { + return nil, emptyMAC, fmt.Errorf("short neighbor solicitation") + } + var dstMAC [6]byte + copy(dstMAC[:], ns[6:12]) + srcIP := net.IP(ns[22:38]) + dstIP := srcIP + if srcIP.Equal(net.IPv6zero) { + // DAD solicitations have no source address and must be multicast. + dstIP = net.ParseIP("ff02::1") + copy(dstMAC[:], []byte{0x33, 0x33, 0x00, 0x00, 0x00, 0x01}) + } + target16 := target.To16() + if target16 == nil { + return nil, emptyMAC, fmt.Errorf("invalid IPv6 neighbor target %q", target) + } + + icmp := make([]byte, 32) + icmp[0] = icmpv6NA + // icmp[1] is the code, which is zero. + flags := naFlags + if srcIP.Equal(net.IPv6zero) { + // The solicited flag must be clear for a DAD response. + flags = 0xc0000000 + } + binary.BigEndian.PutUint32(icmp[4:8], flags) + copy(icmp[8:24], target16) + icmp[24] = 2 // Target Link-Layer Address option. + icmp[25] = 1 // One 8-byte option unit. + copy(icmp[26:32], sourceMAC[:]) + + ipv6 := make([]byte, ipv6HeaderLen) + ipv6[0] = 0x60 + binary.BigEndian.PutUint16(ipv6[4:6], uint16(len(icmp))) + ipv6[6] = 58 + ipv6[7] = 255 + copy(ipv6[8:24], target16) + copy(ipv6[24:40], dstIP.To16()) + + pseudo := make([]byte, 40) + copy(pseudo[0:16], target16) + copy(pseudo[16:32], dstIP.To16()) + binary.BigEndian.PutUint32(pseudo[32:36], uint32(len(icmp))) + pseudo[39] = 58 + binary.BigEndian.PutUint16(icmp[2:4], checksum(append(pseudo, icmp...))) + + frame := make([]byte, 0, ethernetHeaderLen+len(ipv6)+len(icmp)) + frame = append(frame, dstMAC[:]...) + frame = append(frame, sourceMAC[:]...) + frame = append(frame, 0x86, 0xdd) + frame = append(frame, ipv6...) + frame = append(frame, icmp...) + + return frame, dstMAC, nil +} + +func checksum(data []byte) uint16 { + var sum uint32 + for len(data) >= 2 { + sum += uint32(binary.BigEndian.Uint16(data)) + data = data[2:] + } + if len(data) == 1 { + sum += uint32(data[0]) << 8 + } + for sum>>16 != 0 { + sum = (sum & 0xffff) + (sum >> 16) + } + return ^uint16(sum) +} + +func htons(v uint16) uint16 { return (v<<8)&0xff00 | v>>8 } diff --git a/src/internal/ndp/proxy_test.go b/src/internal/ndp/proxy_test.go new file mode 100644 index 0000000..5f0a52c --- /dev/null +++ b/src/internal/ndp/proxy_test.go @@ -0,0 +1,100 @@ +package ndp + +import ( + "encoding/binary" + "net" + "os" + "path/filepath" + "testing" +) + +func TestLoadRulesAndMatches(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "ndppd.conf") + contents := "proxy eth0 {\n rule 2001:db8:1::/112 {\n static\n }\n}\n" + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + rules, err := loadRules(path) + if err != nil { + t.Fatal(err) + } + if !matches(net.ParseIP("2001:db8:1::42"), rules) { + t.Fatal("address inside rule did not match") + } + if matches(net.ParseIP("2001:db8:2::42"), rules) { + t.Fatal("address outside rule matched") + } +} + +// The A-fix: the responder must only ever advertise into the operator's own +// routed prefix. A rule outside `allowed` is dropped even if the rules file (a +// panel-writable path) names it — so a compromised writer cannot turn the root +// raw-socket responder into an NDP spoofer for arbitrary external addresses. +func TestFilterRules(t *testing.T) { + // A rule inside the allowed prefix is kept. + inside := []net.IPNet{*mustCIDR(t, "2001:db8:aaaa::1/128")} + allowed := mustCIDR(t, "2001:db8:aaaa::/48") + if got := filterRules(inside, allowed); len(got) != 1 { + t.Fatalf("in-prefix rule dropped: %v", got) + } + // A rule outside (or straddling the boundary) is dropped. + outside := []net.IPNet{ + *mustCIDR(t, "2001:db8:bbbb::1/128"), + *mustCIDR(t, "2001:db8:aaa1::1/128"), + *mustCIDR(t, "2002:db8:aaaa::1/128"), + } + if got := filterRules(outside, allowed); len(got) != 0 { + t.Fatalf("out-of-prefix rule kept: %v", got) + } + // Mix: only the in-prefix one survives, in order. + mixed := append(append([]net.IPNet{}, inside...), outside[0]) + if got := filterRules(mixed, allowed); len(got) != 1 || !got[0].Contains(net.ParseIP("2001:db8:aaaa::1")) { + t.Fatalf("mixed filtering wrong: %v", got) + } + // nil allowed = constrained-less test/unconstrained mode: keep everything. + if got := filterRules(outside, nil); len(got) != len(outside) { + t.Fatalf("nil allowed should keep all rules, got %v", got) + } +} + +func mustCIDR(t *testing.T, s string) *net.IPNet { + t.Helper() + _, n, err := net.ParseCIDR(s) + if err != nil { + t.Fatal(err) + } + return n +} + +func TestSendAdvertisementUsesTargetAsSource(t *testing.T) { + target := net.ParseIP("2001:db8:1::42") + peer := net.ParseIP("fe80::2") + ns := make([]byte, 14+40+24) + copy(ns[6:12], []byte{0, 1, 2, 3, 4, 5}) + copy(ns[22:38], peer.To16()) + ns[12], ns[13] = 0x86, 0xdd + ns[20] = 58 + ns[14+40] = icmpv6NS + copy(ns[14+40+8:14+40+24], target.To16()) + + // Build through the same helper used by the raw socket sender; this locks + // down the wire-format invariants that matter to the provider router. + mac := [6]byte{0x10, 0x66, 0x6a, 0xf4, 0x1b, 0x4a} + frame, _, err := buildAdvertisement(mac, ns, target) + if err != nil { + t.Fatal(err) + } + ipv6 := frame[14:54] + icmp := frame[54:] + + if got := net.IP(ipv6[8:24]); !got.Equal(target) { + t.Fatalf("NA source = %s, want %s", got, target) + } + if !net.IP(ipv6[24:40]).Equal(peer) { + t.Fatalf("NA destination is not the NS peer") + } + if binary.BigEndian.Uint32(icmp[4:8]) != naFlags { + t.Fatal("NA flags lost router/solicited/override bits") + } +} diff --git a/src/main.go b/src/main.go index a1d4ce9..f2fff29 100644 --- a/src/main.go +++ b/src/main.go @@ -25,6 +25,7 @@ import ( "vpsmgr/internal/fw" "vpsmgr/internal/inter" "vpsmgr/internal/mgr" + "vpsmgr/internal/ndp" "vpsmgr/internal/panel" "vpsmgr/internal/pw" "vpsmgr/internal/su" @@ -91,6 +92,31 @@ RemainAfterExit=yes WantedBy=multi-user.target ` +// ipv6ProxyUnit is the PREFIX-mode boot unit: it rebuilds the host routes and +// the guest static IPv6 config once (ExecStartPre), then runs the long-lived +// in-process NDP responder whose NA source is the advertised global address +// (ndppd and Linux proxy_ndp use a link-local source that this class of +// provider rejects). Runs as root (raw AF_PACKET socket) and is restarted on +// failure; RestartSec=2 keeps a genuinely broken ext_if from hot-looping. The +// responder re-reads the NDP rule file while it runs, so vps add/del never +// restarts it. +const ipv6ProxyUnit = `# Managed by vpsmgr — installed file, do not edit by hand. +# Changes are overwritten on the next install. +[Unit] +Description=vps IPv6 pass-through routes and NDP responder +After=network-online.target incus.service vps-nft.service +Wants=network-online.target +Before=vps.service +[Service] +Type=simple +ExecStartPre=/usr/local/bin/vps ipv6-reapply +ExecStart=/usr/local/bin/vps ipv6-proxy +Restart=always +RestartSec=2 +[Install] +WantedBy=multi-user.target +` + func main() { log.SetFlags(0) if len(os.Args) < 2 { @@ -142,6 +168,11 @@ func main() { // Re-attach IPv6 routes/proxy_ndp for all existing containers. // Run by the vps-ipv6.service boot unit and `vps install`. err = cmdIPv6Reapply() + case "ipv6-proxy": + // Root-only systemd worker for prefix-mode NDP. It must emit the + // advertised global address as the NA source; ndppd and Linux's + // proxy_ndp use a link-local source, which this provider rejects. + err = cmdIPv6Proxy() case "ip6": // Root helper behind the sudoers whitelist: validates every argument // before running the pinned ip -6 operations (see main_ip6.go). @@ -175,7 +206,7 @@ usage: vps config list|set|help inspect/change config.yaml (per-field validated edits) vps version system: - vps install | serve | ipv6-reapply + vps install | serve | ipv6-reapply | ipv6-proxy cpu: whole cores >= 1 (e.g. --cpu 2), or a fraction of one core in 0.1..0.9 (e.g. --cpu 0.5 — the container is pinned to one core with a time slice) bandwidth: monthly quota in GiB (upload + download combined); 0 or empty = unlimited @@ -361,19 +392,43 @@ func cmdInstall() error { if err := writeUnit("vps-nft.service", nftUnit); err != nil { return err } - // IPv6 pass-through boot unit (re-applies routes/proxy after reboot). + // IPv6 pass-through boot unit. Prefix mode runs the long-lived in-tree NDP + // responder (ipv6ProxyUnit); pool mode re-applies routes/proxy at boot via + // the oneshot ipv6Unit. Stop the old unit before replacing it so an upgrade + // from the previous oneshot/ndppd implementation actually starts the new + // responder, and (for the prefix responder) so a bad ext_if fails the + // install clearly instead of the service hot-looping under Restart=always. + _ = exec.Command("systemctl", "disable", "--now", "vps-ipv6.service").Run() + proxyUsable := true if c.IPv6Enabled() { - if err := writeUnit("vps-ipv6.service", ipv6Unit); err != nil { - return err + unit := ipv6Unit + if c.IPv6ModeEffective() == cfg.IPv6ModePrefix { + unit = ipv6ProxyUnit + if _, err := ethernetMAC(c.Net.ExtIF); err != nil { + log.Printf("warning: prefix-mode IPv6 needs an Ethernet ext_if (%s): %v — NDP responder not enabled; fix net.ext_if and re-run `vps install`", c.Net.ExtIF, err) + proxyUsable = false + } } + if proxyUsable { + if err := writeUnit("vps-ipv6.service", unit); err != nil { + return err + } + } else { + _ = os.Remove("/etc/systemd/system/vps-ipv6.service") + } + } else { + _ = os.Remove("/etc/systemd/system/vps-ipv6.service") } + // Prefix mode uses the vpsmgr raw NDP responder; a distro ndppd listener + // would emit competing (link-local-source) NAs, so disable it explicitly. + _ = exec.Command("systemctl", "disable", "--now", "ndppd.service").Run() if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil { return fmt.Errorf("daemon-reload: %s", strings.TrimSpace(string(out))) } if out, err := exec.Command("systemctl", "enable", "--now", "vps-nft.service").CombinedOutput(); err != nil { return fmt.Errorf("enable vps-nft: %s", strings.TrimSpace(string(out))) } - if c.IPv6Enabled() { + if c.IPv6Enabled() && proxyUsable { if out, err := exec.Command("systemctl", "enable", "--now", "vps-ipv6.service").CombinedOutput(); err != nil { return fmt.Errorf("enable vps-ipv6: %s", strings.TrimSpace(string(out))) } @@ -460,6 +515,49 @@ func writeUnit(name, content string) error { return os.Rename(tmp, p) } +// ethernetMAC returns the 6-byte hardware address of iface by name, or an +// error when it is missing or has no Ethernet MAC (a tunnel, bare ifb, some +// cloud vtaps, bond without a slave, ...). Prefix-mode NDP needs a real L2 MAC +// to answer neighbor solicitations with, so this is checked both at install +// (so the responder is not enabled on an interface that can never work) and +// in cmdIPv6Proxy (fail clearly rather than looping). +func ethernetMAC(ifname string) (net.HardwareAddr, error) { + iface, err := net.InterfaceByName(ifname) + if err != nil { + return nil, fmt.Errorf("interface %s: %w", ifname, err) + } + if len(iface.HardwareAddr) != 6 { + return nil, fmt.Errorf("interface %s has no Ethernet MAC (%v)", ifname, iface.HardwareAddr) + } + return net.HardwareAddr(append([]byte(nil), iface.HardwareAddr...)), nil +} + +// cmdIPv6Proxy runs the prefix-mode NDP responder. Root-only (raw AF_PACKET +// socket); started by vps-ipv6.service's ExecStart. It only ever answers for +// addresses inside the operator's routed prefix (see ndp.Run's `allowed`), so a +// compromised rules-file writer cannot turn the host into an NDP spoofer for +// arbitrary external addresses. Returns nil when IPv6 is not prefix mode, so +// the unit is harmlessly idle outside prefix deployments. +func cmdIPv6Proxy() error { + c, err := cfg.Load() + if err != nil { + return fmt.Errorf("load config: %w", err) + } + if !c.IPv6Enabled() || c.IPv6ModeEffective() != cfg.IPv6ModePrefix { + return nil + } + if _, err := ethernetMAC(c.Net.ExtIF); err != nil { + return err + } + n, err := c.IPv6Network() + if err != nil { + return err + } + // n is the parent routed prefix; hand it to the responder as the only + // address space it may advertise into. + return ndp.Run("/etc/vpsmgr/ndppd.conf", c.Net.ExtIF, n) +} + // ensureVPSUser sets up the unprivileged 'vps' account the panel daemon runs // as. Idempotent, called by `vps install`: // - creates the system user if missing @@ -535,16 +633,9 @@ vps ALL=(root) NOPASSWD: /usr/bin/systemctl is-active --quiet vps.service # ip — the panel never gets bare ip -6 wildcard rights. vps ALL=(root) NOPASSWD: /usr/local/bin/vps ip6 * vps ALL=(root) NOPASSWD: /sbin/sysctl -w net.ipv6.conf.all.forwarding=1 -vps ALL=(root) NOPASSWD: /usr/sbin/service ndppd restart -vps ALL=(root) NOPASSWD: /usr/sbin/service ndppd start -vps ALL=(root) NOPASSWD: /usr/sbin/service ndppd stop -vps ALL=(root) NOPASSWD: /usr/bin/pkill -x ndppd -vps ALL=(root) NOPASSWD: /usr/sbin/ndppd -d -p /var/run/ndppd.pid -# ndppd reads /etc/ndppd.conf by default (no -c in the init script), but the -# panel renders the config under /etc/vpsmgr — these pinned commands keep the -# root-owned symlink /etc/ndppd.conf -> /etc/vpsmgr/ndppd.conf in sync. -vps ALL=(root) NOPASSWD: /bin/ln -sf /etc/vpsmgr/ndppd.conf /etc/ndppd.conf -vps ALL=(root) NOPASSWD: /bin/rm -f /etc/ndppd.conf +# Prefix-mode IPv6 NDP is served by the in-tree responder (root systemd unit, +# vps-ipv6.service) reading /etc/vpsmgr/ndppd.conf — the panel no longer +# starts/stops the distro ndppd daemon, so no ndppd/ln-sf grants are needed. ` if err := os.MkdirAll("/etc/sudoers.d", 0o750); err != nil { return fmt.Errorf("mkdir /etc/sudoers.d: %w", err) From 9696752d251f428852906e5ca68d09c65ee6e24a Mon Sep 17 00:00:00 2001 From: bluebluesoda <300733005+bluebluesoda@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:51:14 +0800 Subject: [PATCH 2/6] docs: explain the in-tree NDP responder design and scale-test checklist Add docs/ipv6-ndp-responder.md: why prefix mode replaced the distro ndppd daemon with a project-owned responder (some providers drop link-local-source NAs), the data-plane correctness guarantees, the A/B design hardening, what changed on dev-ndp, and an 8-point checklist for wide-scale testing. Link it from docs/README.md. --- docs/README.md | 1 + docs/ipv6-ndp-responder.md | 173 +++++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 docs/ipv6-ndp-responder.md diff --git a/docs/README.md b/docs/README.md index 3e62c59..529e01c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,7 @@ Technical documentation for vpsmgr. All docs are in English. | [architecture.md](architecture.md) | System design: components, storage, network, security, traffic, image, install/uninstall lifecycle | | [configuration.md](configuration.md) | Full reference for `/etc/vpsmgr/config.yaml` | | [ipv6.md](ipv6.md) | IPv6 pass-through design: deterministic addresses, prefix support, routing, installer flow | +| [ipv6-ndp-responder.md](ipv6-ndp-responder.md) | Why prefix mode replaced ndppd with an in-tree NDP responder, design hardening, and what to test at scale | | [development.md](development.md) | Building, testing, releasing, conventions | See also [`AGENTS.md`](../AGENTS.md) at the repo root — short version aimed at AI coding agents. diff --git a/docs/ipv6-ndp-responder.md b/docs/ipv6-ndp-responder.md new file mode 100644 index 0000000..e87c26b --- /dev/null +++ b/docs/ipv6-ndp-responder.md @@ -0,0 +1,173 @@ +# In-tree NDP responder for prefix mode + +This document explains **why** vpsmgr replaced the distro `ndppd` daemon with +its own small NDP responder in **prefix mode**, and what to verify during +wide-scale testing. It is the design rationale for the change on the +`dev-ndp` branch. + +## The problem + +In prefix mode each container owns a global /112 block described in +[ipv6.md](ipv6.md). For the outside world to reach a container, the provider's +upstream router sends a Neighbor Solicitation (NS) for the container address +on the host's external link; the host must answer with a Neighbor +Advertisement (NA) so the router learns which MAC to forward packets to. + +Two mechanisms answered those NSes before: + +- The kernel's `proxy_ndp` — but it only answers **single /128 addresses** and + ignores prefix-cover queries, so it cannot serve a whole routed /112. +- **ndppd** — proxied ND for a whole prefix. This was vpsmgr's choice. + +### The silent failure + +A class of upstream IPv6 providers **drops NAs whose source address is +link-local**. Both `proxy_ndp` and ndppd emit their NA with the host's +link-local source address (`fe80::…`). The router therefore discards the reply, +the container appears unreachable from outside, and — because this happens on +the data plane with no error surfaced anywhere — it fails **silently**: +containers reach *out* fine, but inbound connectivity to the container's +public address returns nothing. + +This was confirmed on a real provider with a controlled experiment: binding +the same address directly on `eth0` (so the kernel answers with the *global* +address as the NA source) made the container reachable from outside +immediately. + +## The chosen fix + +vpsmgr now ships its own minimal NDP responder in `internal/ndp` that answers +Neighbor Solicitations on the external interface with an NA whose **source is +the advertised global address** — exactly what these providers expect. It runs +as a root systemd unit (`vps-ipv6.service`, prefix mode only). + +### Wire-format correctness + +The responder is small (< 300 lines) but the invariants that matter to a +router are all handled and locked down by unit tests: + +- Correct ICMPv6 checksum over the IPv6 pseudo-header. +- Hop limit = 255 (a hard NDP requirement). +- NA flags `0xe0000000` (Router+Solicited+Override) for a normal reply; + `0xc0000000` (Solicited cleared) for a DAD answer. +- Target Link-Layer Address option carrying the host `eth0` MAC. +- DAD solicitations (source `::`) answered as a multicast `ff02::1` NA. + +### Why not just patch ndppd? + +The ideas of "one project-owned daemon" vs "reuse a mature daemon" were weighed +(the latter is the original report's weaker point). The decision to keep an +in-tree responder was deliberate: ndppd **cannot be configured** to source its +NA from a global address, so no config-level workaround exists; patching it +would mean maintaining a private fork of a C daemon and re-integrating upstream +forever. A ~300-line, unit-tested Go responder that we own and can reason about +was judged the better long-term trade-off. The cost is maintenance ownership. + +## Reading the rules without restarting + +`ndppd` needed a daemon restart on every config change. The in-tree responder +re-reads `/etc/vpsmgr/ndppd.conf` while it runs: + +- At most once per second while idle. +- On-time on the arrival of an NS (by checking the file's mtime), so the very + first solicitation after an `add`/`del` is answered instead of dropped. + +As a result `vps add` / `vps del` never restart `vps-ipv6.service`. + +## Design hardening (beyond the original report) + +Two issues found while reviewing became part of the accepted design. + +### A. Confining the root responder's trust surface + +`vps-ipv6.service` runs as **root** (it needs a raw `AF_PACKET` socket). Its +input, `/etc/vpsmgr/ndppd.conf`, lives in a directory the unprivileged panel +user writes (the panel updates it on every add/del). Without additional +checks, a compromised panel process could write an arbitrary IPv6 rule and +turn the root responder into an NDP spoofer for any external address. + +The responder therefore takes the operator's **routed prefix** as an `allowed` +set and silently drops any rule not contained in it (checked at both reload +points). A compromised rules-file writer can no longer make the host answer for +addresses outside vpsmgr's own prefix. + +### B. No hot-looping on a broken interface + +`Restart=always` + a responder that exits on error would retry every second +forever. The common trigger is an `ext_if` with no 6-byte Ethernet MAC (a +tunnel, a bare tun, some cloud virtual NICs, a bond without a slave). We added +an `ethernetMAC` pre-check at **install time**: if prefix mode is selected but +the interface cannot answer NDP, the responder is not enabled and `vps install` +warns clearly, instead of letting the service spin in a log loop. The command +itself also exits cleanly (no-op) when IPv6 is disabled or the mode is not prefix. + +## What changed on the branch + +| File | Change | +|---|---| +| `internal/ndp/proxy.go` | the responder (raw socket, NS→NA, `allowed` confinement) | +| `internal/ndp/proxy_test.go` | wire-format + `filterRules` tests | +| `main.go` | `vps ipv6-proxy` command, prefix `vps-ipv6.service` unit, `ethernetMAC` install check; removed ndppd daemon sudoers grants | +| `internal/mgr/ipv6.go` | `writeNDPPD` rewritten **atomically** (unique temp file + `os.Rename`); all ndppd daemon management (restart/stop/link/pid/alive) removed | +| `internal/mgr/mgr.go` | `Add`/`Reinstall`: configure the guest's IPv6 **before** publishing the NDP rule, so the first SYN never races a half-configured guest | +| `internal/mgr/routed_ipv6.go` | guest script: pin the `fe80::1` gateway neighbor, RA `UseGateway=false`, a **local** /112 route, and a `vpsmgr-ipv6.service` in-container helper that re-applies the local route after a network restart | +| `install.sh` | removed the `ndppd` install/enable block (no longer used in prefix mode) | +| `50-image.sh`, `80-debian-dev-image.sh` | bake `UseGateway=false` into the image's `[IPv6AcceptRA]` | + +The real **data-plane** change is confined to prefix mode and to the NDP +handling around it. Login, snapshots, quotas, pool mode, IPv4 and the panel are +untouched. + +## Notes on a few deliberate decisions + +- **File name kept as `ndppd.conf`.** The format (bare `rule {}`) is + ndppd-compatible and unchanged; the name is retained so the panel's Writable + directory layout and the config it renders stay stable. The documentation + makes clear the daemon itself is gone. +- **Atomic write / privilege.** The old buggy author was a *permission* one: + the panel failed to write `ndppd.conf` when an older `vps install` (running + as root) had left it root-owned. The atomic-rename rewrite only depends on the + writable parent directory, never on overwriting a possibly root-owned target. +- **`UseGateway=false`.** Guards against an RA installing a competing dynamic + default route in the guest. It is baked into the images *and* restored + idempotently by the runtime script. +- **Gateway neighbor pinned.** `security.ipv6_filtering` drops the guest's + link-local NDP lookup for the bridge's `fe80::1`; pinning the neighbor to the + bridge MAC restores the guest's ability to send without weakening isolation. +- **Local /112 route.** A normal /112 route only says where packets *go*; + a `local` route makes every address in the block bindable, so a service on + `block::4` is reachable without adding each address separately. + +## What to verify in wide-scale testing + +The unit tests (build, vet, and `go test ./...`) all pass. Prefix-mode IPv6 +data flow still needs real-router validation because a test box without an +upstream IPv6 prefix cannot exercise the NDP path end-to-end. On hosts with a +routed prefix: + +1. **Inbound reachability is global-source.** From an external probe (e.g. + Globalping, same as the existing `check-ipv6-support.sh`), verify the + container's public address and at least one non-primary address in its /112 + (e.g. `block::4`) reply. This is the exact thing that broke before. +2. **Outbound still works** — `curl -6` from inside the container. +3. **First-connection race.** Immediately after `vps add`, ping the new + container from outside; the first NS must be answered (the on-time mtime + reload), i.e. no "first ping lost". +4. **add/del churn without a service restart** — `journalctl -u vps-ipv6.service` + shows no restarts across several `vps add`/`del`. +5. **Self-healing on boot** — reboot the host; `vps-ipv6.service` starts, + re-runs `ipv6-reapply`, and inbound still works. +6. **Broken `ext_if` fails loudly** — on a host whose ext_if has no MAC, + `vps install` (prefix mode) must *warn and not enable* the responder, + never spin in a start loop. +7. **No competing daemon** — `ndppd.service` is inactive (it is disabled at + install); a competing link-local NA would break the whole fix. +8. **Guest gateway neighbor + local route** — inside a container, + `ip neigh` shows `fe80::1` pinned to the bridge MAC and + `ip -6 route show table local` contains the /112. + +## References + +- Base behaviour and prefix/pool modes: [ipv6.md](ipv6.md). +- This document's rationale: the original provider bug report plus the + `dev-ndp` branch review (issues A and B). \ No newline at end of file From a3a81d943f6896765fc4ad6e77436724adefcc7a Mon Sep 17 00:00:00 2001 From: bluebluesoda <300733005+bluebluesoda@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:39:21 +0800 Subject: [PATCH 3/6] docs: note how the NDP responder coexists with the isolation layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clarify that security.port_isolation + ipv4/ipv6_filtering gate east-west traffic on the bridge while the responder handles north-south NA replies on the upstream NIC, and document the gateway-neighbour pinning that plans around the isolation filter's deliberate side effect on fe80::1 NDP. Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- docs/ipv6-ndp-responder.md | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/docs/ipv6-ndp-responder.md b/docs/ipv6-ndp-responder.md index e87c26b..275eb6c 100644 --- a/docs/ipv6-ndp-responder.md +++ b/docs/ipv6-ndp-responder.md @@ -118,6 +118,40 @@ The real **data-plane** change is confined to prefix mode and to the NDP handling around it. Login, snapshots, quotas, pool mode, IPv4 and the panel are untouched. +## Relationship to the existing isolation layer + +The responder is not the first NDP-related mechanism in vpsmgr; the container +isolation layer predates it by far (introduced with the Incus port, +`security.port_isolation` + `security.ipv4/ipv6_filtering`, in +`src/internal/lx/lx.go`). These two address **different directions** and do not +conflict: + +| Layer | Direction | Defence target | Mechanism | +|---|---|---|---| +| Isolation Layer | **east-west** on the bridge | isolate containers, stop ARP/NDP spoofing, protect the host's own neighbour cache | Incus bridge input rules (`security.ipv4/v6_filtering`, `port_isolation`) | +| This responder | **north-south** on the upstream NIC (`eth0`) | let strict upstreams resolve container /112 addresses | host-side in-tree raw-socket NA replies for inbound NS | + +The responder listens on the host's upstream interface for NS aimed at the +routed /112 block; the isolation rules filter *guest*-originated neighbour +packets inside the bridge. Different directions, so they do not overlap in +normal prefix-mode topologies. + +### The interaction point: gateway-neighbour pinning + +`security.ipv6_filtering` has a deliberate side effect: it drops the **guest's +own NDP lookup** for the bridge gateway `fe80::1` (that reply claims an address +the guest does not own, so the rule discards it). Without a dance-around, a +guest cannot resolve its default route's neighbour — the earlier fix's +filtering breaks the new requirement of egress through the host. + +The responder work treats this explicitly, not as a conflict but as a +constraint to plan around: the guest pins `fe80::1` to the bridge MAC (`ip -6 +neigh replace \u2026 nud permanent` in the runtime script, and `[Neighbor]` in +networkd), with `UseGateway=false` guarding against a competing RA default +route. So the new code **relies on** the isolation layer's behaviour and +coexists with it — one closes the neighbour-spoofing hole, the other makes +strict upstreams able to reach the block through the existing design. + ## Notes on a few deliberate decisions - **File name kept as `ndppd.conf`.** The format (bare `rule {}`) is @@ -170,4 +204,6 @@ routed prefix: - Base behaviour and prefix/pool modes: [ipv6.md](ipv6.md). - This document's rationale: the original provider bug report plus the - `dev-ndp` branch review (issues A and B). \ No newline at end of file + `dev-ndp` branch review (issues A and B). +- The isolation layer this responder coexists with: `src/internal/lx/lx.go` + (`nicIsolation`) and `security.ipv6_filtering`. \ No newline at end of file From da3f8b21050af569907274f8df1e2c24dfc3edfc Mon Sep 17 00:00:00 2001 From: bluebluesoda <300733005+bluebluesoda@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:04:31 +0800 Subject: [PATCH 4/6] fix(install): stop vps-ipv6.service too before replacing the binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the dev-ndp branch the prefix-mode boot unit vps-ipv6.service runs a long-lived `vps ipv6-proxy` process that maps /usr/local/bin/vps, so stopping only vps.service left the binary held and 40-panel.sh failed to overwrite it with ETXTBSY ("Text file busy"). Stop every vpsmgr unit that keeps the binary mapped (vps.service and vps-ipv6.service) before copying, in both the local-build and prebuilt paths. Verified live: cp fails while the panel runs, succeeds once it is stopped. Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- scripts/40-panel.sh | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/scripts/40-panel.sh b/scripts/40-panel.sh index 43aabcb..56b4e46 100755 --- a/scripts/40-panel.sh +++ b/scripts/40-panel.sh @@ -104,17 +104,26 @@ cleanup_go(){ log "go toolchain removed; host left clean" } +# stop_holders stops every vpsmgr unit that keeps /usr/local/bin/vps mapped +# (running), so the binary can be replaced without ETXTBSY ("Text file busy"). +# vps.service is the panel; on the dev-ndp branch vps-ipv6.service is a +# long-lived `vps ipv6-proxy` process that ALSO maps the binary — stopping only +# the panel leaves it holding the file and cp then fails. Unconditional: +# stopping a unit that is not present is a harmless no-op. +stop_holders(){ + systemctl stop vps.service >/dev/null 2>&1 || true + systemctl stop vps-ipv6.service >/dev/null 2>&1 || true + log "stopped services holding the vps binary" +} + build_local(){ ensure_go || die "go toolchain install failed" log "building vpsmgr from source..." bash "$ROOT/build.sh" || die "build failed" # Replacing a running binary fails with ETXTBSY ("Text file busy") on an - # adopted install where vps.service is already active. Stop the service - # first, then copy, then let the installer re-enable it below. - if systemctl is-active --quiet vps.service 2>/dev/null; then - systemctl stop vps.service >/dev/null 2>&1 || true - log "stopped vps.service to replace the running binary" - fi + # adopted install where a vpsmgr unit is already active. Stop the services + # that map the binary first, then copy, then let the installer re-enable. + stop_holders if ! cp "$ROOT/bin/vps" /usr/local/bin/vps; then die "could not copy built binary to /usr/local/bin/vps" fi @@ -151,13 +160,11 @@ install_prebuilt(){ else log "warn: could not fetch checksums, skipping verification" fi - # Replace a running binary (ETXTBSY): stop the service first, then copy, - # then let the installer re-enable it below. This runs only after the - # download verified, so a failed download never takes the panel down. - if systemctl is-active --quiet vps.service 2>/dev/null; then - systemctl stop vps.service >/dev/null 2>&1 || true - log "stopped vps.service to replace the running binary" - fi + # Replace a running binary (ETXTBSY): stop the units that map it first, + # then copy, then let the installer re-enable them below. This runs only + # after the download verified, so a failed download never takes the panel + # down. + stop_holders cp "$dir/vps-$arch" /usr/local/bin/vps chmod 755 /usr/local/bin/vps log "installed /usr/local/bin/vps from release ($(/usr/local/bin/vps version))" From 430d6f77c43aa2dd7023f9ed8fb0daf29cd38b90 Mon Sep 17 00:00:00 2001 From: bluebluesoda <300733005+bluebluesoda@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:51:04 +0800 Subject: [PATCH 5/6] feat(install): warn on a release install from a non-main branch A plain ./install.sh downloads the prebuilt RELEASE binary, which always comes from main regardless of the checked-out branch. If this is a real git checkout on a non-main branch, prompt for confirmation before continuing. Tarball / one-click pipe installs have no .git directory and skip the check. The branch is read directly from .git/HEAD so git(1) is not required; a detached HEAD is treated as non-main. --- install.sh | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/install.sh b/install.sh index f0369df..8a9f874 100755 --- a/install.sh +++ b/install.sh @@ -85,6 +85,36 @@ if [[ "$DISABLE_V4FORWARD" == "1" ]]; then esac fi +# Defensive guard: a plain (no-flag) ./install.sh downloads the prebuilt +# RELEASE binary, which always comes from the `main` branch — regardless of +# which branch is checked out. If this is a real git checkout sitting on a +# non-`main` branch, that is almost certainly not what the operator intends +# (they usually expect the dev branch's code to be installed). Prompt for +# confirmation. Tarball / one-click pipe installs have no .git directory and +# therefore skip this check entirely. The branch is read directly from +# .git/HEAD so no git(1) binary is required. +if [[ "$BUILD_MODE" == "release" && -d "$ROOT/.git" ]]; then + RELEASE_BRANCH="$(sed -n 's|^ref: refs/heads/||p' "$ROOT/.git/HEAD" 2>/dev/null)" + if [[ "$RELEASE_BRANCH" != "main" ]]; then + if [[ -z "$RELEASE_BRANCH" ]]; then + RELEASE_BRANCH="(detached HEAD)" + fi + echo + echo "!! You are on branch '$RELEASE_BRANCH', but a plain ./install.sh installs the !!" + echo " prebuilt RELEASE binary from the main branch — NOT this branch's code." + echo " (use ./install.sh --local-build to compile this checkout's branch instead)" + if [[ ! -t 0 ]]; then + echo "[install] non-interactive — proceeding with the release (prebuild) install" + else + read -r -p "Continue anyway? (y/N) " RELEASE_CONFIRM + case "$RELEASE_CONFIRM" in + y|Y|yes|YES) ;; + *) echo "[install] aborted by user — release install declined (branch '$RELEASE_BRANCH'); use --local-build to install this branch"; exit 1 ;; + esac + fi + fi +fi + # Local build: make it obvious WHICH branch will be compiled, and give the user # a chance to abort — some people want a dev build but end up building stable. if [[ "$BUILD_MODE" == "local" ]]; then From fd6448bd274f0cf4120d14baf3e13f9295ad5e3d Mon Sep 17 00:00:00 2001 From: bluebluesoda <300733005+bluebluesoda@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:04:21 +0800 Subject: [PATCH 6/6] Update handlers.go --- src/internal/admin/handlers.go | 1576 ++++++++++++++++---------------- 1 file changed, 794 insertions(+), 782 deletions(-) diff --git a/src/internal/admin/handlers.go b/src/internal/admin/handlers.go index ef60453..d3bccda 100644 --- a/src/internal/admin/handlers.go +++ b/src/internal/admin/handlers.go @@ -1,17 +1,17 @@ package admin import ( - "encoding/json" - "net/http" - "strconv" - "strings" - "time" + "encoding/json" + "net/http" + "strconv" + "strings" + "time" - "vpsmgr/internal/cfg" - "vpsmgr/internal/db" - "vpsmgr/internal/lx" - "vpsmgr/internal/mgr" - "vpsmgr/internal/pw" + "vpsmgr/internal/cfg" + "vpsmgr/internal/db" + "vpsmgr/internal/lx" + "vpsmgr/internal/mgr" + "vpsmgr/internal/pw" ) type ctxKey int @@ -24,224 +24,224 @@ var loginDummyHash = func() string { h, _ := pw.Hash("vpsmgr-admin-timing-pad"); // pageData is the data handed to the admin templates. type pageData struct { - Title string - Prefix string - Msg string - Err string - Host hostView - Reboot bool - Users []userView - UserCount int - MaxUsers int - CapacityPct int - V4Forward bool - Lang string - // Colors is the fixed palette of accent colors an admin may assign to a - // user (see userColorPalette). Empty when never rendered. - Colors []string - // IPv6 pool mode: the free addresses offered in the create-user dropdown - // (empty = not pool mode / pool exhausted). PoolUsed/PoolTotal are the - // pool fill for display. - IPv6PoolMode bool - PoolFree []string - PoolUsed int - PoolTotal int - // AdminKeys is the operator's own SSH-key store (management panel only). - AdminKeys []sshKeyRow + Title string + Prefix string + Msg string + Err string + Host hostView + Reboot bool + Users []userView + UserCount int + MaxUsers int + CapacityPct int + V4Forward bool + Lang string + // Colors is the fixed palette of accent colors an admin may assign to a + // user (see userColorPalette). Empty when never rendered. + Colors []string + // IPv6 pool mode: the free addresses offered in the create-user dropdown + // (empty = not pool mode / pool exhausted). PoolUsed/PoolTotal are the + // pool fill for display. + IPv6PoolMode bool + PoolFree []string + PoolUsed int + PoolTotal int + // AdminKeys is the operator's own SSH-key store (management panel only). + AdminKeys []sshKeyRow } // sshKeyRow is one public key shown in the admin SSH-key management panel. type sshKeyRow struct { - ID int64 `json:"id"` - Name string `json:"name"` - Key string `json:"key"` // clean "type base64" (comment stripped) - Active bool `json:"active"` + ID int64 `json:"id"` + Name string `json:"name"` + Key string `json:"key"` // clean "type base64" (comment stripped) + Active bool `json:"active"` } // hostView carries host memory/swap/pool/uptime numbers for the overview cards. type hostView struct { - MemTotal string - MemUsed string - MemPct string - SwapTotal string - SwapUsed string - SwapPct string - PoolTotal string - PoolUsed string - PoolAvail string - PoolPct string - Uptime string + MemTotal string + MemUsed string + MemPct string + SwapTotal string + SwapUsed string + SwapPct string + PoolTotal string + PoolUsed string + PoolAvail string + PoolPct string + Uptime string } // userView is one row of the admin user table. type userView struct { - Name string - // Color is the operator-assigned accent color ("" = default). It colors - // the bold username and the "login panel" button for quick user spotting. - Color string - State string - // Status is the persistent lifecycle state (ready/creating/reinstalling/ - // failed). Non-ready states are shown to the operator so a crashed - // Add/Reinstall is visible instead of looking like a healthy user. - Status string - Ports string // full user-port block, e.g. 10700-10799 (tooltip) - PortsShort string // compact form, e.g. 107xx - SSHPort string - QuotaCPU string - QuotaMem string - QuotaDisk string - BandwidthGB int // monthly bandwidth quota GiB, 0 = unlimited - CPUUse string - MemUse string - DiskUsed string - UpGB string - DownGB string - BWTotal string // up+down this month, GB — used for table sorting - IPv6 string - Procs int64 // live process count (0 when stopped) - ProcsLimit string // per-container pids.max cap, e.g. "4096" + Name string + // Color is the operator-assigned accent color ("" = default). It colors + // the bold username and the "login panel" button for quick user spotting. + Color string + State string + // Status is the persistent lifecycle state (ready/creating/reinstalling/ + // failed). Non-ready states are shown to the operator so a crashed + // Add/Reinstall is visible instead of looking like a healthy user. + Status string + Ports string // full user-port block, e.g. 10700-10799 (tooltip) + PortsShort string // compact form, e.g. 107xx + SSHPort string + QuotaCPU string + QuotaMem string + QuotaDisk string + BandwidthGB int // monthly bandwidth quota GiB, 0 = unlimited + CPUUse string + MemUse string + DiskUsed string + UpGB string + DownGB string + BWTotal string // up+down this month, GB — used for table sorting + IPv6 string + Procs int64 // live process count (0 when stopped) + ProcsLimit string // per-container pids.max cap, e.g. "4096" } func (s *Server) buildPageData(msg, errMsg string) pageData { - d := pageData{ - Title: "VPS Manager Admin", - Prefix: s.prefix(), - Msg: msg, - Err: errMsg, - V4Forward: s.mgr.V4ForwardLive(), - Colors: append([]string{}, userColorPalette...), - } - hs := s.mgr.HostStats() - d.Reboot = hs.RebootNeeded - d.Host = hostView{ - MemTotal: humanBytes(int64(hs.Mem.MemTotal)), - MemUsed: humanBytes(int64(hs.Mem.MemUsed)), - SwapTotal: humanBytes(int64(hs.Mem.SwapTotal)), - SwapUsed: humanBytes(int64(hs.Mem.SwapUsed)), - PoolTotal: humanBytes(hs.PoolTotal), - PoolUsed: humanBytes(hs.PoolUsed), - PoolAvail: humanBytes(hs.PoolAvail), - Uptime: formatUptime(hs.Uptime), - } - if hs.Mem.MemTotal > 0 { - d.Host.MemPct = strconv.Itoa(int(hs.Mem.MemUsed*100/hs.Mem.MemTotal)) + "%" - } - if hs.Mem.SwapTotal > 0 { - d.Host.SwapPct = strconv.Itoa(int(hs.Mem.SwapUsed*100/hs.Mem.SwapTotal)) + "%" - } - if hs.PoolTotal > 0 { - d.Host.PoolPct = strconv.Itoa(int(hs.PoolUsed*100/hs.PoolTotal)) + "%" - } - // IPv6 pool mode: offer the free addresses in the create dropdown. - if s.mgr.IPv6Mode() == cfg.IPv6ModePool { - d.IPv6PoolMode = true - d.PoolFree = s.mgr.FreePoolIPv6List() - if total, used, err := s.mgr.IPv6PoolUsage(); err == nil { - d.PoolUsed, d.PoolTotal = used, total - } - } - if keys, err := s.mgr.ListAdminKeys(); err == nil { - for _, k := range keys { - d.AdminKeys = append(d.AdminKeys, sshKeyRow{ID: k.ID, Name: k.Name, Key: k.Key, Active: k.Active}) - } - } - return d + d := pageData{ + Title: "VPS Manager Admin", + Prefix: s.prefix(), + Msg: msg, + Err: errMsg, + V4Forward: s.mgr.V4ForwardLive(), + Colors: append([]string{}, userColorPalette...), + } + hs := s.mgr.HostStats() + d.Reboot = hs.RebootNeeded + d.Host = hostView{ + MemTotal: humanBytes(int64(hs.Mem.MemTotal)), + MemUsed: humanBytes(int64(hs.Mem.MemUsed)), + SwapTotal: humanBytes(int64(hs.Mem.SwapTotal)), + SwapUsed: humanBytes(int64(hs.Mem.SwapUsed)), + PoolTotal: humanBytes(hs.PoolTotal), + PoolUsed: humanBytes(hs.PoolUsed), + PoolAvail: humanBytes(hs.PoolAvail), + Uptime: formatUptime(hs.Uptime), + } + if hs.Mem.MemTotal > 0 { + d.Host.MemPct = strconv.Itoa(int(hs.Mem.MemUsed*100/hs.Mem.MemTotal)) + "%" + } + if hs.Mem.SwapTotal > 0 { + d.Host.SwapPct = strconv.Itoa(int(hs.Mem.SwapUsed*100/hs.Mem.SwapTotal)) + "%" + } + if hs.PoolTotal > 0 { + d.Host.PoolPct = strconv.Itoa(int(hs.PoolUsed*100/hs.PoolTotal)) + "%" + } + // IPv6 pool mode: offer the free addresses in the create dropdown. + if s.mgr.IPv6Mode() == cfg.IPv6ModePool { + d.IPv6PoolMode = true + d.PoolFree = s.mgr.FreePoolIPv6List() + if total, used, err := s.mgr.IPv6PoolUsage(); err == nil { + d.PoolUsed, d.PoolTotal = used, total + } + } + if keys, err := s.mgr.ListAdminKeys(); err == nil { + for _, k := range keys { + d.AdminKeys = append(d.AdminKeys, sshKeyRow{ID: k.ID, Name: k.Name, Key: k.Key, Active: k.Active}) + } + } + return d } func (s *Server) loadUsers(d *pageData) { - statuses, err := s.mgr.BatchUsers() - if err != nil { - d.Err = d.Err + " " + err.Error() - return - } - vs := make([]userView, 0, len(statuses)) - for _, st := range statuses { - u := st.User - vs = append(vs, userView{ - Name: u.Name, - Color: u.Color, - State: st.State, - Status: u.Status, - Ports: mgr.UserPorts(u.StartPort, cfg.PortsPerUser), - PortsShort: mgr.UserPortsShort(u.StartPort), - SSHPort: strconv.Itoa(u.SSHPort), - QuotaCPU: mgr.FormatCPU(u.CPU), - QuotaMem: strconv.Itoa(u.MemMB) + " MiB", - QuotaDisk: strconv.Itoa(u.DiskGB) + " GiB", - BandwidthGB: u.BandwidthQuotaGB, - CPUUse: st.CPUUse, - MemUse: st.MemUse, - DiskUsed: st.DiskUsed, - UpGB: st.UpGB, - DownGB: st.DownGB, - BWTotal: st.BWTotal, - IPv6: st.IPv6, - Procs: st.Procs, - ProcsLimit: lx.DefaultProcessesLimit, - }) - } - d.Users = vs - d.UserCount = len(vs) - // Capacity follows the configured slot range, not a fixed 200: a shrunken - // net.slot_range lowers the ceiling, and existing users that fall outside - // it (from a wider earlier setting) can push the count past 100% — shown - // honestly as over-capacity. - d.MaxUsers = s.cfg.SlotCount() - if d.MaxUsers > 0 { - d.CapacityPct = d.UserCount * 100 / d.MaxUsers - // With only a handful of containers the true percentage (e.g. 1/200) - // is an invisible sliver; floor it at 2% so the bar is visibly present - // once there is at least one container. 0 users stays empty. - if d.UserCount > 0 && d.CapacityPct < 2 { - d.CapacityPct = 2 - } - } + statuses, err := s.mgr.BatchUsers() + if err != nil { + d.Err = d.Err + " " + err.Error() + return + } + vs := make([]userView, 0, len(statuses)) + for _, st := range statuses { + u := st.User + vs = append(vs, userView{ + Name: u.Name, + Color: u.Color, + State: st.State, + Status: u.Status, + Ports: mgr.UserPorts(u.StartPort, cfg.PortsPerUser), + PortsShort: mgr.UserPortsShort(u.StartPort), + SSHPort: strconv.Itoa(u.SSHPort), + QuotaCPU: mgr.FormatCPU(u.CPU), + QuotaMem: strconv.Itoa(u.MemMB) + " MiB", + QuotaDisk: strconv.Itoa(u.DiskGB) + " GiB", + BandwidthGB: u.BandwidthQuotaGB, + CPUUse: st.CPUUse, + MemUse: st.MemUse, + DiskUsed: st.DiskUsed, + UpGB: st.UpGB, + DownGB: st.DownGB, + BWTotal: st.BWTotal, + IPv6: st.IPv6, + Procs: st.Procs, + ProcsLimit: lx.DefaultProcessesLimit, + }) + } + d.Users = vs + d.UserCount = len(vs) + // Capacity follows the configured slot range, not a fixed 200: a shrunken + // net.slot_range lowers the ceiling, and existing users that fall outside + // it (from a wider earlier setting) can push the count past 100% — shown + // honestly as over-capacity. + d.MaxUsers = s.cfg.SlotCount() + if d.MaxUsers > 0 { + d.CapacityPct = d.UserCount * 100 / d.MaxUsers + // With only a handful of containers the true percentage (e.g. 1/200) + // is an invisible sliver; floor it at 2% so the bar is visibly present + // once there is at least one container. 0 users stays empty. + if d.UserCount > 0 && d.CapacityPct < 2 { + d.CapacityPct = 2 + } + } } // formatUptime renders a duration as a static non-ticking string like // "5d 3h 12m" so the admin panel shows the uptime captured at page load. func formatUptime(d time.Duration) string { - if d <= 0 { - return "-" - } - d = d.Round(time.Minute) - days := d / (24 * time.Hour) - d -= days * 24 * time.Hour - hours := d / time.Hour - d -= hours * time.Hour - mins := d / time.Minute - if days > 0 { - return strconv.FormatInt(int64(days), 10) + "d " + strconv.FormatInt(int64(hours), 10) + "h " + strconv.FormatInt(int64(mins), 10) + "m" - } - return strconv.FormatInt(int64(hours), 10) + "h " + strconv.FormatInt(int64(mins), 10) + "m" + if d <= 0 { + return "-" + } + d = d.Round(time.Minute) + days := d / (24 * time.Hour) + d -= days * 24 * time.Hour + hours := d / time.Hour + d -= hours * time.Hour + mins := d / time.Minute + if days > 0 { + return strconv.FormatInt(int64(days), 10) + "d " + strconv.FormatInt(int64(hours), 10) + "h " + strconv.FormatInt(int64(mins), 10) + "m" + } + return strconv.FormatInt(int64(hours), 10) + "h " + strconv.FormatInt(int64(mins), 10) + "m" } // humanBytes renders a byte count as a short human string (e.g. "184 MiB"). func humanBytes(b int64) string { - if b < 0 { - b = 0 - } - const unit = 1024 - if b < unit { - return strconv.FormatInt(b, 10) + " B" - } - div, exp := int64(unit), 0 - for n := b / unit; n >= unit; n /= unit { - div *= unit - exp++ - } - return strconv.FormatFloat(float64(b)/float64(div), 'f', 1, 64) + " " + "KMGTPE"[exp:exp+1] + "iB" + if b < 0 { + b = 0 + } + const unit = 1024 + if b < unit { + return strconv.FormatInt(b, 10) + " B" + } + div, exp := int64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return strconv.FormatFloat(float64(b)/float64(div), 'f', 1, 64) + " " + "KMGTPE"[exp:exp+1] + "iB" } // storeFlash persists a one-shot banner for the request's admin session. func (s *Server) storeFlash(r *http.Request, msg, kind string) { - if c, err := r.Cookie("vpsmgr_admin_session"); err == nil { - if msg == "" { - s.flash.Clear(c.Value) - return - } - s.flash.Set(c.Value, msg, kind) - } + if c, err := r.Cookie("vpsmgr_admin_session"); err == nil { + if msg == "" { + s.flash.Clear(c.Value) + return + } + s.flash.Set(c.Value, msg, kind) + } } // currentAdminHash reads the admin password hash fresh from the DB on every @@ -250,84 +250,84 @@ func (s *Server) storeFlash(r *http.Request, msg, kind string) { // restarting the panel service. Login is low-frequency, so the extra read is // negligible. func (s *Server) currentAdminHash() string { - v, _, err := s.db.GetSetting(db.SettingAdminPassHash) - if err != nil { - return "" - } - return v + v, _, err := s.db.GetSetting(db.SettingAdminPassHash) + if err != nil { + return "" + } + return v } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost { - ip := clientIP(r) - s.limiter.prune() - if !s.limiter.allowed(ip) { - s.renderStatus(w, r, http.StatusTooManyRequests, "admin_login.html", - pageData{Title: "Admin Login", Prefix: s.prefix(), Err: s.t(r, "err_too_many")}) - return - } - r.Body = http.MaxBytesReader(w, r.Body, 1<<20) - if err := r.ParseForm(); err != nil { - http.Error(w, err.Error(), 400) - return - } - pass := r.FormValue("password") - // Password-only login: no username. Compare against the configured - // bcrypt hash; when unset (fresh install before admin-passwd) burn the - // same bcrypt time as a real compare. - hash := s.currentAdminHash() - if hash == "" { - pw.Verify(loginDummyHash, pass) - s.render(w, r, "admin_login.html", pageData{Title: "Admin Login", Prefix: s.prefix(), Err: s.t(r, "err_not_configured")}) - return - } - if !pw.Verify(hash, pass) { - s.render(w, r, "admin_login.html", pageData{Title: "Admin Login", Prefix: s.prefix(), Err: s.t(r, "err_bad_login")}) - return - } - token, err := s.sessions.create(s.cfg.Panel.SessionDays) - if err != nil { - http.Error(w, err.Error(), 500) - return - } - s.setSessionCookie(w, token) - _ = s.db.AddAuditLog("000", "session.login") - s.redirect(w, r, s.p(""), "") - return - } - s.render(w, r, "admin_login.html", pageData{Title: "Admin Login", Prefix: s.prefix()}) + if r.Method == http.MethodPost { + ip := clientIP(r) + s.limiter.prune() + if !s.limiter.allowed(ip) { + s.renderStatus(w, r, http.StatusTooManyRequests, "admin_login.html", + pageData{Title: "Admin Login", Prefix: s.prefix(), Err: s.t(r, "err_too_many")}) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), 400) + return + } + pass := r.FormValue("password") + // Password-only login: no username. Compare against the configured + // bcrypt hash; when unset (fresh install before admin-passwd) burn the + // same bcrypt time as a real compare. + hash := s.currentAdminHash() + if hash == "" { + pw.Verify(loginDummyHash, pass) + s.render(w, r, "admin_login.html", pageData{Title: "Admin Login", Prefix: s.prefix(), Err: s.t(r, "err_not_configured")}) + return + } + if !pw.Verify(hash, pass) { + s.render(w, r, "admin_login.html", pageData{Title: "Admin Login", Prefix: s.prefix(), Err: s.t(r, "err_bad_login")}) + return + } + token, err := s.sessions.create(s.cfg.Panel.SessionDays) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + s.setSessionCookie(w, token) + _ = s.db.AddAuditLog("000", "session.login") + s.redirect(w, r, s.p(""), "") + return + } + s.render(w, r, "admin_login.html", pageData{Title: "Admin Login", Prefix: s.prefix()}) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { - if c, err := r.Cookie("vpsmgr_admin_session"); err == nil { - s.sessions.delete(c.Value) - } - s.clearSessionCookie(w) - s.redirect(w, r, s.p("/login"), "") + if c, err := r.Cookie("vpsmgr_admin_session"); err == nil { + s.sessions.delete(c.Value) + } + s.clearSessionCookie(w) + s.redirect(w, r, s.p("/login"), "") } // handleOverview renders the admin dashboard. It performs one full batch // refresh (a handful of incus calls) on every manual page load; there is no // automatic polling. func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) { - d := s.buildPageData("", "") - s.loadUsers(&d) - s.render(w, r, "admin_overview.html", d) + d := s.buildPageData("", "") + s.loadUsers(&d) + s.render(w, r, "admin_overview.html", d) } func (s *Server) handleFlash(w http.ResponseWriter, r *http.Request) { - c, err := r.Cookie("vpsmgr_admin_session") - if err != nil { - http.Error(w, "", http.StatusUnauthorized) - return - } - msg, kind, data, _ := s.flash.Pop(c.Value) - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(struct { - Msg string `json:"msg"` - Kind string `json:"kind"` - Data string `json:"data"` - }{msg, kind, data}) + c, err := r.Cookie("vpsmgr_admin_session") + if err != nil { + http.Error(w, "", http.StatusUnauthorized) + return + } + msg, kind, data, _ := s.flash.Pop(c.Value) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(struct { + Msg string `json:"msg"` + Kind string `json:"kind"` + Data string `json:"data"` + }{msg, kind, data}) } // handleUserAdd creates a user with the CLI's Add logic and shows the full @@ -338,142 +338,144 @@ func (s *Server) handleFlash(w http.ResponseWriter, r *http.Request) { // backgrounds in both light and dark mode. The stored value is the hex string // itself, and this list is the allowlist — anything else is rejected. var userColorPalette = []string{ - "#e11d48", // red - "#ea580c", // orange - "#d97706", // amber - "#16a34a", // green - "#0d9488", // teal - "#0891b2", // cyan - "#3b82f6", // blue - "#7c3aed", // violet - "#c026d3", // fuchsia - "#db2777", // pink + "#e11d48", // red + "#ea580c", // orange + "#d97706", // amber + "#16a34a", // green + "#0d9488", // teal + "#0891b2", // cyan + "#3b82f6", // blue + "#7c3aed", // violet + "#c026d3", // fuchsia + "#db2777", // pink } // validUserColor reports whether color is a member of the fixed palette // ("" is reserved for "reset to default" and handled by the caller). func validUserColor(color string) bool { - for _, c := range userColorPalette { - if c == color { - return true - } - } - return false + for _, c := range userColorPalette { + if c == color { + return true + } + } + return false } func (s *Server) handleUserAdd(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - http.Error(w, err.Error(), 400) - return - } - name := r.FormValue("name") - cpu, err := mgr.ParseCPU(r.FormValue("cpu")) - if err != nil { - s.redirect(w, r, s.p(""), "error: "+err.Error()) - return - } - memMB, err := parseMem(r.FormValue("mem")) - if err != nil { - s.redirect(w, r, s.p(""), "error: "+err.Error()) - return - } - diskGB, err := strconv.Atoi(r.FormValue("disk")) - if err != nil { - s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_invalid_disk")) - return - } - bandwidthGB, err := mgr.ParseBandwidthGB(r.FormValue("bandwidth")) - if err != nil { - s.redirect(w, r, s.p(""), "error: "+err.Error()) - return - } - // IPv6 dropdown (pool mode only): "auto" (or empty) = first free pool - // address, "none" = V4-only container, otherwise the picked address. - ipv6 := strings.TrimSpace(r.FormValue("ipv6")) - if ipv6 == "auto" { - ipv6 = "" - } - res, err := s.mgr.Add(name, mgr.AddOptions{CPU: cpu, MemMB: memMB, DiskGB: diskGB, BandwidthGB: bandwidthGB, IPv6Addr: ipv6}) - if err != nil { - s.redirect(w, r, s.p(""), "error: "+err.Error()) - return - } - _ = s.db.AddAuditLog("000+"+name, "user.create") - // The password is always auto-generated and shown once. - cred := "user: " + res.User.Name + - "\npassword: " + res.Password + - "\npanel: " + s.cfg.PanelURL("/"+s.cfg.Panel.URLPath) - // Carry the username as flash data so the modal can offer "log in as". - s.redirectModalData(w, r, s.p(""), cred, res.User.Name) + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), 400) + return + } + name := r.FormValue("name") + cpu, err := mgr.ParseCPU(r.FormValue("cpu")) + if err != nil { + s.redirect(w, r, s.p(""), "error: "+err.Error()) + return + } + memMB, err := parseMem(r.FormValue("mem")) + if err != nil { + s.redirect(w, r, s.p(""), "error: "+err.Error()) + return + } + diskGB, err := strconv.Atoi(r.FormValue("disk")) + if err != nil { + s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_invalid_disk")) + return + } + bandwidthGB, err := mgr.ParseBandwidthGB(r.FormValue("bandwidth")) + if err != nil { + s.redirect(w, r, s.p(""), "error: "+err.Error()) + return + } + // IPv6 dropdown (pool mode only): "auto" (or empty) = first free pool + // address, "none" = V4-only container, otherwise the picked address. + ipv6 := strings.TrimSpace(r.FormValue("ipv6")) + if ipv6 == "auto" { + ipv6 = "" + } + res, err := s.mgr.Add(name, mgr.AddOptions{CPU: cpu, MemMB: memMB, DiskGB: diskGB, BandwidthGB: bandwidthGB, IPv6Addr: ipv6}) + if err != nil { + s.redirect(w, r, s.p(""), "error: "+err.Error()) + return + } + _ = s.db.AddAuditLog("000+"+name, "user.create") + // The password is always auto-generated and shown once. The panel address + // is taken from the request's own Host (see panelURL), so it matches + // whatever origin the operator actually used to reach the admin panel. + cred := "user: " + res.User.Name + + "\npassword: " + res.Password + + "\npanel: " + s.panelURL(r, "/"+s.cfg.Panel.URLPath) + // Carry the username as flash data so the modal can offer "log in as". + s.redirectModalData(w, r, s.p(""), cred, res.User.Name) } func (s *Server) handleUserDel(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - http.Error(w, err.Error(), 400) - return - } - name := r.FormValue("name") - if r.FormValue("confirm") != "1" { - s.redirect(w, r, s.p(""), "error: please confirm deletion") - return - } - if err := s.mgr.Del(name); err != nil { - s.redirect(w, r, s.p(""), "error: "+err.Error()) - return - } - _ = s.db.AddAuditLog("000+"+name, "user.delete") - s.redirect(w, r, s.p(""), s.t(r, "user_deleted", name)) + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), 400) + return + } + name := r.FormValue("name") + if r.FormValue("confirm") != "1" { + s.redirect(w, r, s.p(""), "error: please confirm deletion") + return + } + if err := s.mgr.Del(name); err != nil { + s.redirect(w, r, s.p(""), "error: "+err.Error()) + return + } + _ = s.db.AddAuditLog("000+"+name, "user.delete") + s.redirect(w, r, s.p(""), s.t(r, "user_deleted", name)) } func (s *Server) handleUserQuota(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - http.Error(w, err.Error(), 400) - return - } - name := r.FormValue("name") - cpu, err := mgr.ParseCPU(r.FormValue("cpu")) - if err != nil { - s.redirect(w, r, s.p(""), "error: "+err.Error()) - return - } - memMB, err := parseMem(r.FormValue("mem")) - if err != nil { - s.redirect(w, r, s.p(""), "error: "+err.Error()) - return - } - diskGB, err := strconv.Atoi(r.FormValue("disk")) - if err != nil { - s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_invalid_disk")) - return - } - bandwidthGB, err := mgr.ParseBandwidthGB(r.FormValue("bandwidth")) - if err != nil { - s.redirect(w, r, s.p(""), "error: "+err.Error()) - return - } - if _, err := s.mgr.UpdateQuotasAndBandwidth(name, cpu, memMB, diskGB, bandwidthGB); err != nil { - s.redirect(w, r, s.p(""), "error: "+err.Error()) - return - } - _ = s.db.AddAuditLog("000+"+name, "quota.update") - s.redirect(w, r, s.p(""), s.t(r, "quota_updated", name)) + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), 400) + return + } + name := r.FormValue("name") + cpu, err := mgr.ParseCPU(r.FormValue("cpu")) + if err != nil { + s.redirect(w, r, s.p(""), "error: "+err.Error()) + return + } + memMB, err := parseMem(r.FormValue("mem")) + if err != nil { + s.redirect(w, r, s.p(""), "error: "+err.Error()) + return + } + diskGB, err := strconv.Atoi(r.FormValue("disk")) + if err != nil { + s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_invalid_disk")) + return + } + bandwidthGB, err := mgr.ParseBandwidthGB(r.FormValue("bandwidth")) + if err != nil { + s.redirect(w, r, s.p(""), "error: "+err.Error()) + return + } + if _, err := s.mgr.UpdateQuotasAndBandwidth(name, cpu, memMB, diskGB, bandwidthGB); err != nil { + s.redirect(w, r, s.p(""), "error: "+err.Error()) + return + } + _ = s.db.AddAuditLog("000+"+name, "quota.update") + s.redirect(w, r, s.p(""), s.t(r, "quota_updated", name)) } // handleUserBandwidthReset zeroes a user's monthly traffic counters without // touching the resource quotas. If the user was over quota (and thus // throttled), the reset also lifts the NIC rate limit immediately. func (s *Server) handleUserBandwidthReset(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - http.Error(w, err.Error(), 400) - return - } - name := r.FormValue("name") - if err := s.mgr.ResetBandwidth(name); err != nil { - s.redirect(w, r, s.p(""), "error: "+err.Error()) - return - } - _ = s.db.AddAuditLog("000+"+name, "bandwidth.reset") - s.redirect(w, r, s.p(""), s.t(r, "bandwidth_reset", name)) + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), 400) + return + } + name := r.FormValue("name") + if err := s.mgr.ResetBandwidth(name); err != nil { + s.redirect(w, r, s.p(""), "error: "+err.Error()) + return + } + _ = s.db.AddAuditLog("000+"+name, "bandwidth.reset") + s.redirect(w, r, s.p(""), s.t(r, "bandwidth_reset", name)) } // handleUserColor sets or clears a user's accent color. Empty color = reset @@ -482,98 +484,98 @@ func (s *Server) handleUserBandwidthReset(w http.ResponseWriter, r *http.Request // table — a deliberately low-key entry point, documented rather than surfaced // as its own button. func (s *Server) handleUserColor(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - http.Error(w, err.Error(), 400) - return - } - name := r.FormValue("name") - color := strings.TrimSpace(r.FormValue("color")) - if color != "" && !validUserColor(color) { - s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_invalid_color")) - return - } - u, err := s.db.GetUserByName(name) - if err != nil { - s.redirect(w, r, s.p(""), "error: user not found") - return - } - if err := s.db.UpdateUserColor(u.ID, color); err != nil { - s.redirect(w, r, s.p(""), "error: "+err.Error()) - return - } - _ = s.db.AddAuditLog("000+"+name, "color.update") - if color == "" { - s.redirect(w, r, s.p(""), s.t(r, "color_reset", name)) - return - } - s.redirect(w, r, s.p(""), s.t(r, "color_updated", name)) + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), 400) + return + } + name := r.FormValue("name") + color := strings.TrimSpace(r.FormValue("color")) + if color != "" && !validUserColor(color) { + s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_invalid_color")) + return + } + u, err := s.db.GetUserByName(name) + if err != nil { + s.redirect(w, r, s.p(""), "error: user not found") + return + } + if err := s.db.UpdateUserColor(u.ID, color); err != nil { + s.redirect(w, r, s.p(""), "error: "+err.Error()) + return + } + _ = s.db.AddAuditLog("000+"+name, "color.update") + if color == "" { + s.redirect(w, r, s.p(""), s.t(r, "color_reset", name)) + return + } + s.redirect(w, r, s.p(""), s.t(r, "color_updated", name)) } func (s *Server) handlePower(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - http.Error(w, err.Error(), 400) - return - } - action := r.FormValue("action") - name := r.FormValue("name") - if err := s.mgr.Power(name, action); err != nil { - s.redirect(w, r, s.p(""), "error: "+err.Error()) - return - } - _ = s.db.AddAuditLog("000+"+name, "power."+action) - s.redirect(w, r, s.p(""), s.t(r, "power_ok", name, action)) + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), 400) + return + } + action := r.FormValue("action") + name := r.FormValue("name") + if err := s.mgr.Power(name, action); err != nil { + s.redirect(w, r, s.p(""), "error: "+err.Error()) + return + } + _ = s.db.AddAuditLog("000+"+name, "power."+action) + s.redirect(w, r, s.p(""), s.t(r, "power_ok", name, action)) } // handleResetPanelPass resets a user's panel login password and shows it once. func (s *Server) handleResetPanelPass(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - http.Error(w, err.Error(), 400) - return - } - name := r.FormValue("name") - pass, err := s.mgr.ResetPanelPassword(name) - if err != nil { - s.redirect(w, r, s.p(""), "error: "+err.Error()) - return - } - panel := s.cfg.PanelURL("/" + s.cfg.Panel.URLPath) - _ = s.db.AddAuditLog("000+"+name, "passwd.reset") - s.redirectModal(w, r, s.p(""), s.t(r, "new_panel_password", name, pass, panel)) + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), 400) + return + } + name := r.FormValue("name") + pass, err := s.mgr.ResetPanelPassword(name) + if err != nil { + s.redirect(w, r, s.p(""), "error: "+err.Error()) + return + } + panel := s.panelURL(r, "/"+s.cfg.Panel.URLPath) + _ = s.db.AddAuditLog("000+"+name, "passwd.reset") + s.redirectModal(w, r, s.p(""), s.t(r, "new_panel_password", name, pass, panel)) } // handleAdminPass changes the admin panel password (no username). The current // session is preserved. func (s *Server) handleAdminPass(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - http.Error(w, err.Error(), 400) - return - } - pass := r.FormValue("new_password") - confirm := r.FormValue("confirm_password") - if pass != confirm { - s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_pass_mismatch")) - return - } - if len(pass) < 14 { - s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_pass_short")) - return - } - hash, err := pw.Hash(pass) - if err != nil { - s.redirect(w, r, s.p(""), "error: "+err.Error()) - return - } - if err := s.db.SetSetting(db.SettingAdminPassHash, hash); err != nil { - s.redirect(w, r, s.p(""), "error: "+err.Error()) - return - } - // Every other admin session is now stale: keep only the one that changed - // the password, so a stolen/long-open session cannot outlive the rotation. - if c, err := r.Cookie("vpsmgr_admin_session"); err == nil { - s.sessions.clearExcept(c.Value) - } - _ = s.db.AddAuditLog("000", "admin.passwd") - s.redirect(w, r, s.p(""), s.t(r, "admin_pass_changed")) + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), 400) + return + } + pass := r.FormValue("new_password") + confirm := r.FormValue("confirm_password") + if pass != confirm { + s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_pass_mismatch")) + return + } + if len(pass) < 14 { + s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_pass_short")) + return + } + hash, err := pw.Hash(pass) + if err != nil { + s.redirect(w, r, s.p(""), "error: "+err.Error()) + return + } + if err := s.db.SetSetting(db.SettingAdminPassHash, hash); err != nil { + s.redirect(w, r, s.p(""), "error: "+err.Error()) + return + } + // Every other admin session is now stale: keep only the one that changed + // the password, so a stolen/long-open session cannot outlive the rotation. + if c, err := r.Cookie("vpsmgr_admin_session"); err == nil { + s.sessions.clearExcept(c.Value) + } + _ = s.db.AddAuditLog("000", "admin.passwd") + s.redirect(w, r, s.p(""), s.t(r, "admin_pass_changed")) } // handleLoginAs ("log in as user" / impersonation) creates a user-panel @@ -584,190 +586,200 @@ func (s *Server) handleAdminPass(w http.ResponseWriter, r *http.Request) { // the user-panel cookie. The session is flagged impersonated, so the user // panel attributes its audit events "000+" and shows a banner. func (s *Server) handleLoginAs(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - http.Error(w, err.Error(), 400) - return - } - // Impersonation hands the browser the user-panel cookie. Refuse when the - // user panel is disabled: a bare "/" cookie path would otherwise be sent to - // every request on the host, including the admin panel. (With no URLPath - // there is nothing to impersonate into anyway.) - if s.cfg.Panel.URLPath == "" { - s.redirect(w, r, s.p(""), "error: user panel is disabled") - return - } - name := strings.TrimSpace(r.FormValue("name")) - u, err := s.db.GetUserByName(name) - if err != nil { - s.redirect(w, r, s.p(""), "error: user not found") - return - } - sess, err := s.db.CreateImpersonatedSession(u.ID, s.cfg.Panel.SessionDays) - if err != nil { - s.redirect(w, r, s.p(""), "error: "+err.Error()) - return - } - // Set the user panel's own session cookie (same name, path and flags as a - // real login) so the browser becomes that user. Only the user-panel cookie - // is written here; the admin cookie is left alone. - userPrefix := "/" + s.cfg.Panel.URLPath - http.SetCookie(w, &http.Cookie{ - Name: "vpsmgr_session", - Value: sess.Token, - Path: userPrefix, - MaxAge: s.cfg.Panel.SessionDays * 86400, - HttpOnly: true, - Secure: true, - SameSite: http.SameSiteLaxMode, - }) - _ = s.db.AddAuditLog("000+"+u.Name, "session.login") - http.Redirect(w, r, userPrefix+"/", http.StatusFound) + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), 400) + return + } + // Impersonation hands the browser the user-panel cookie. Refuse when the + // user panel is disabled: a bare "/" cookie path would otherwise be sent to + // every request on the host, including the admin panel. (With no URLPath + // there is nothing to impersonate into anyway.) + if s.cfg.Panel.URLPath == "" { + s.redirect(w, r, s.p(""), "error: user panel is disabled") + return + } + name := strings.TrimSpace(r.FormValue("name")) + u, err := s.db.GetUserByName(name) + if err != nil { + s.redirect(w, r, s.p(""), "error: user not found") + return + } + sess, err := s.db.CreateImpersonatedSession(u.ID, s.cfg.Panel.SessionDays) + if err != nil { + s.redirect(w, r, s.p(""), "error: "+err.Error()) + return + } + // Set the user panel's own session cookie (same name, path and flags as a + // real login) so the browser becomes that user. Only the user-panel cookie + // is written here; the admin cookie is left alone. + userPrefix := "/" + s.cfg.Panel.URLPath + http.SetCookie(w, &http.Cookie{ + Name: "vpsmgr_session", + Value: sess.Token, + Path: userPrefix, + MaxAge: s.cfg.Panel.SessionDays * 86400, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) + _ = s.db.AddAuditLog("000+"+u.Name, "session.login") + http.Redirect(w, r, userPrefix+"/", http.StatusFound) +} + +// panelURL renders the user-panel address as the browser sees it: https plus +// the request's own Host (not the configured listen port). This keeps the +// address shown in admin modals correct when the panel is reached through a +// reverse proxy on a custom domain or a different port. The scheme is always +// https — the panel only serves TLS and its session cookie is Secure, so it +// cannot function over plain http regardless of the transport in between. +func (s *Server) panelURL(r *http.Request, prefix string) string { + return "https://" + r.Host + prefix } // parseMem parses a memory string ("512" or "1G") into MiB, mirroring the CLI. func parseMem(s string) (int, error) { - s = strings.TrimSpace(s) - if s == "" { - return 0, strconv.ErrSyntax - } - mult := 1 - last := s[len(s)-1] - switch { - case last >= '0' && last <= '9': - case last == 'M' || last == 'm': - s = s[:len(s)-1] - case last == 'G' || last == 'g': - mult = 1024 - s = s[:len(s)-1] - default: - return 0, strconv.ErrSyntax - } - n, err := strconv.Atoi(s) - if err != nil { - return 0, err - } - n *= mult - if n < 64 { - return 0, strconv.ErrSyntax - } - return n, nil + s = strings.TrimSpace(s) + if s == "" { + return 0, strconv.ErrSyntax + } + mult := 1 + last := s[len(s)-1] + switch { + case last >= '0' && last <= '9': + case last == 'M' || last == 'm': + s = s[:len(s)-1] + case last == 'G' || last == 'g': + mult = 1024 + s = s[:len(s)-1] + default: + return 0, strconv.ErrSyntax + } + n, err := strconv.Atoi(s) + if err != nil { + return 0, err + } + n *= mult + if n < 64 { + return 0, strconv.ErrSyntax + } + return n, nil } // ---- domain management ---- // domainView is one row of the admin domain panel. type domainView struct { - Domain string - Username string - UpdatedAt string // UTC RFC3339; rendered in the browser's timezone - ProxyProtocol bool + Domain string + Username string + UpdatedAt string // UTC RFC3339; rendered in the browser's timezone + ProxyProtocol bool } type domainsPageData struct { - Title string - Prefix string - Msg string - Err string - Domains []domainView - Blocked string // blocked-domains list, one domain per line (textarea) - Lang string + Title string + Prefix string + Msg string + Err string + Domains []domainView + Blocked string // blocked-domains list, one domain per line (textarea) + Lang string } func (s *Server) renderDomains(w http.ResponseWriter, r *http.Request, d domainsPageData) { - t, err := s.templates() - if err != nil { - http.Error(w, "template error: "+err.Error(), 500) - return - } - if d.Lang == "" { - d.Lang = langEn - if l, ok := r.Context().Value(langCtxKey).(string); ok && l != "" { - d.Lang = l - } - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(http.StatusOK) - if err := t.ExecuteTemplate(w, "admin_domains.html", d); err != nil { - http.Error(w, err.Error(), 500) - } + t, err := s.templates() + if err != nil { + http.Error(w, "template error: "+err.Error(), 500) + return + } + if d.Lang == "" { + d.Lang = langEn + if l, ok := r.Context().Value(langCtxKey).(string); ok && l != "" { + d.Lang = l + } + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + if err := t.ExecuteTemplate(w, "admin_domains.html", d); err != nil { + http.Error(w, err.Error(), 500) + } } // handleDomains renders the admin domain panel: every domain with its owner // and last-modified time, newest first, plus the blocked-domains list. func (s *Server) handleDomains(w http.ResponseWriter, r *http.Request) { - d := domainsPageData{Title: "VPS Manager Admin — Domains", Prefix: s.prefix()} - all, err := s.mgr.AllDomains() - if err != nil { - d.Err = err.Error() - } else { - for _, x := range all { - d.Domains = append(d.Domains, domainView{Domain: x.Domain, Username: x.Username, UpdatedAt: x.UpdatedAt, ProxyProtocol: x.ProxyProtocol}) - } - } - if blocked, err := s.db.GetBlockedDomains(); err != nil { - if d.Err != "" { - d.Err += " " - } - d.Err += err.Error() - } else { - d.Blocked = strings.Join(blocked, "\n") - } - s.renderDomains(w, r, d) + d := domainsPageData{Title: "VPS Manager Admin — Domains", Prefix: s.prefix()} + all, err := s.mgr.AllDomains() + if err != nil { + d.Err = err.Error() + } else { + for _, x := range all { + d.Domains = append(d.Domains, domainView{Domain: x.Domain, Username: x.Username, UpdatedAt: x.UpdatedAt, ProxyProtocol: x.ProxyProtocol}) + } + } + if blocked, err := s.db.GetBlockedDomains(); err != nil { + if d.Err != "" { + d.Err += " " + } + d.Err += err.Error() + } else { + d.Blocked = strings.Join(blocked, "\n") + } + s.renderDomains(w, r, d) } // handleDomainDel deletes a domain (admin path). It finds the owning user and // removes the domain + its traefik file atomically. func (s *Server) handleDomainDel(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - http.Error(w, err.Error(), 400) - return - } - domain := r.FormValue("domain") - if err := s.mgr.AdminDelDomain(domain); err != nil { - s.redirect(w, r, s.p("/domains"), "error: "+err.Error()) - return - } - if dmn, err := s.db.GetDomainByDomain(domain); err == nil { - if owner, err := s.db.GetUserByID(dmn.UserID); err == nil { - _ = s.db.AddAuditLog("000+"+owner.Name, "domain_update") - } - } - s.redirect(w, r, s.p("/domains"), s.t(r, "domain_deleted", domain)) + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), 400) + return + } + domain := r.FormValue("domain") + if err := s.mgr.AdminDelDomain(domain); err != nil { + s.redirect(w, r, s.p("/domains"), "error: "+err.Error()) + return + } + if dmn, err := s.db.GetDomainByDomain(domain); err == nil { + if owner, err := s.db.GetUserByID(dmn.UserID); err == nil { + _ = s.db.AddAuditLog("000+"+owner.Name, "domain_update") + } + } + s.redirect(w, r, s.p("/domains"), s.t(r, "domain_deleted", domain)) } // handleDomainUpdate applies the admin batch PROXY protocol toggle, same // semantics as the user panel. func (s *Server) handleDomainUpdate(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - http.Error(w, err.Error(), 400) - return - } - checked := map[string]bool{} - for _, d := range r.Form["proto"] { - checked[d] = true - } - all, err := s.mgr.AllDomains() - if err != nil { - s.redirect(w, r, s.p("/domains"), "error: "+err.Error()) - return - } - changed := 0 - for _, x := range all { - on := checked[x.Domain] - if on != x.ProxyProtocol { - if err := s.mgr.AdminSetDomainProtocol(x.Domain, on); err != nil { - s.redirect(w, r, s.p("/domains"), "error: "+err.Error()) - return - } - _ = s.db.AddAuditLog("000+"+x.Username, "domain_update") - changed++ - } - } - if changed == 0 { - s.redirect(w, r, s.p("/domains"), "ok: no changes") - return - } - s.redirect(w, r, s.p("/domains"), s.t(r, "domains_updated")) + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), 400) + return + } + checked := map[string]bool{} + for _, d := range r.Form["proto"] { + checked[d] = true + } + all, err := s.mgr.AllDomains() + if err != nil { + s.redirect(w, r, s.p("/domains"), "error: "+err.Error()) + return + } + changed := 0 + for _, x := range all { + on := checked[x.Domain] + if on != x.ProxyProtocol { + if err := s.mgr.AdminSetDomainProtocol(x.Domain, on); err != nil { + s.redirect(w, r, s.p("/domains"), "error: "+err.Error()) + return + } + _ = s.db.AddAuditLog("000+"+x.Username, "domain_update") + changed++ + } + } + if changed == 0 { + s.redirect(w, r, s.p("/domains"), "ok: no changes") + return + } + s.redirect(w, r, s.p("/domains"), s.t(r, "domains_updated")) } // handleBlockedDomains saves the admin blocked-domains list from the textarea. @@ -775,193 +787,193 @@ func (s *Server) handleDomainUpdate(w http.ResponseWriter, r *http.Request) { // reported by their 1-based line numbers in a flash banner), the valid lines // are saved. The check lives in mgr.AddDomain, so all add paths are covered. func (s *Server) handleBlockedDomains(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - http.Error(w, err.Error(), 400) - return - } - valid, bad := mgr.ParseBlockedList(r.FormValue("blocked")) - if err := s.db.SetBlockedDomains(valid); err != nil { - s.redirect(w, r, s.p("/domains"), "error: "+err.Error()) - return - } - _ = s.db.AddAuditLog("000", "blocked.update") - if len(bad) > 0 { - lines := make([]string, 0, len(bad)) - for _, n := range bad { - lines = append(lines, strconv.Itoa(n)) - } - s.redirect(w, r, s.p("/domains"), s.t(r, "blocked_skipped_lines", strings.Join(lines, ", "))) - return - } - s.redirect(w, r, s.p("/domains"), s.t(r, "blocked_saved")) + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), 400) + return + } + valid, bad := mgr.ParseBlockedList(r.FormValue("blocked")) + if err := s.db.SetBlockedDomains(valid); err != nil { + s.redirect(w, r, s.p("/domains"), "error: "+err.Error()) + return + } + _ = s.db.AddAuditLog("000", "blocked.update") + if len(bad) > 0 { + lines := make([]string, 0, len(bad)) + for _, n := range bad { + lines = append(lines, strconv.Itoa(n)) + } + s.redirect(w, r, s.p("/domains"), s.t(r, "blocked_skipped_lines", strings.Join(lines, ", "))) + return + } + s.redirect(w, r, s.p("/domains"), s.t(r, "blocked_saved")) } // ---- IPv6 pool management ---- type ipv6PoolPageData struct { - Title string - Prefix string - Msg string - Err string - Lang string - Mode string // "pool" when pool mode is active, else "" - Total int - Used int - Free int - Addrs []mgr.PoolEntries + Title string + Prefix string + Msg string + Err string + Lang string + Mode string // "pool" when pool mode is active, else "" + Total int + Used int + Free int + Addrs []mgr.PoolEntries } func (s *Server) renderIPv6Pool(w http.ResponseWriter, r *http.Request, d ipv6PoolPageData) { - t, err := s.templates() - if err != nil { - http.Error(w, "template error: "+err.Error(), 500) - return - } - if d.Lang == "" { - d.Lang = langEn - if l, ok := r.Context().Value(langCtxKey).(string); ok && l != "" { - d.Lang = l - } - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(http.StatusOK) - if err := t.ExecuteTemplate(w, "admin_ipv6pool.html", d); err != nil { - http.Error(w, err.Error(), 500) - } + t, err := s.templates() + if err != nil { + http.Error(w, "template error: "+err.Error(), 500) + return + } + if d.Lang == "" { + d.Lang = langEn + if l, ok := r.Context().Value(langCtxKey).(string); ok && l != "" { + d.Lang = l + } + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + if err := t.ExecuteTemplate(w, "admin_ipv6pool.html", d); err != nil { + http.Error(w, err.Error(), 500) + } } // handleIPv6Pool renders the pool management page (list + add box). func (s *Server) handleIPv6Pool(w http.ResponseWriter, r *http.Request) { - d := ipv6PoolPageData{Title: "VPS Manager Admin — IPv6 Pool", Prefix: s.prefix()} - if s.mgr.IPv6Mode() == cfg.IPv6ModePool { - d.Mode = cfg.IPv6ModePool - } else if s.mgr.IPv6Mode() == cfg.IPv6ModePrefix { - d.Mode = cfg.IPv6ModePrefix - } - total, used, err := s.mgr.IPv6PoolUsage() - if err != nil { - d.Err = err.Error() - } else { - d.Total, d.Used = total, used - d.Free = total - used - } - addrs, err := s.mgr.PoolList() - if err != nil { - d.Err = d.Err + " " + err.Error() - } else { - d.Addrs = addrs - } - s.renderIPv6Pool(w, r, d) + d := ipv6PoolPageData{Title: "VPS Manager Admin — IPv6 Pool", Prefix: s.prefix()} + if s.mgr.IPv6Mode() == cfg.IPv6ModePool { + d.Mode = cfg.IPv6ModePool + } else if s.mgr.IPv6Mode() == cfg.IPv6ModePrefix { + d.Mode = cfg.IPv6ModePrefix + } + total, used, err := s.mgr.IPv6PoolUsage() + if err != nil { + d.Err = err.Error() + } else { + d.Total, d.Used = total, used + d.Free = total - used + } + addrs, err := s.mgr.PoolList() + if err != nil { + d.Err = d.Err + " " + err.Error() + } else { + d.Addrs = addrs + } + s.renderIPv6Pool(w, r, d) } // handleIPv6PoolAdd batch-adds addresses from the multi-line textarea. func (s *Server) handleIPv6PoolAdd(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - http.Error(w, err.Error(), 400) - return - } - raw := r.FormValue("addresses") - var entries []string - for _, line := range strings.FieldsFunc(raw, func(c rune) bool { return c == '\n' || c == ',' }) { - line = strings.TrimSpace(line) - if line != "" { - entries = append(entries, line) - } - } - added, err := s.mgr.AddPoolIPv6s(entries) - if err != nil { - s.redirect(w, r, s.p("/ipv6pool"), "error: "+err.Error()) - return - } - if len(added) == 0 { - s.redirect(w, r, s.p("/ipv6pool"), "ok: no new addresses") - return - } - _ = s.db.AddAuditLog("000", "ipv6pool.add") - s.redirect(w, r, s.p("/ipv6pool"), s.t(r, "pool_added", len(added))) + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), 400) + return + } + raw := r.FormValue("addresses") + var entries []string + for _, line := range strings.FieldsFunc(raw, func(c rune) bool { return c == '\n' || c == ',' }) { + line = strings.TrimSpace(line) + if line != "" { + entries = append(entries, line) + } + } + added, err := s.mgr.AddPoolIPv6s(entries) + if err != nil { + s.redirect(w, r, s.p("/ipv6pool"), "error: "+err.Error()) + return + } + if len(added) == 0 { + s.redirect(w, r, s.p("/ipv6pool"), "ok: no new addresses") + return + } + _ = s.db.AddAuditLog("000", "ipv6pool.add") + s.redirect(w, r, s.p("/ipv6pool"), s.t(r, "pool_added", len(added))) } // handleIPv6PoolDel removes one address from the pool. func (s *Server) handleIPv6PoolDel(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - http.Error(w, err.Error(), 400) - return - } - addr := strings.TrimSpace(r.FormValue("addr")) - if err := s.mgr.RemovePoolIPv6(addr); err != nil { - s.redirect(w, r, s.p("/ipv6pool"), "error: "+err.Error()) - return - } - _ = s.db.AddAuditLog("000", "ipv6pool.del") - s.redirect(w, r, s.p("/ipv6pool"), s.t(r, "pool_removed", addr)) + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), 400) + return + } + addr := strings.TrimSpace(r.FormValue("addr")) + if err := s.mgr.RemovePoolIPv6(addr); err != nil { + s.redirect(w, r, s.p("/ipv6pool"), "error: "+err.Error()) + return + } + _ = s.db.AddAuditLog("000", "ipv6pool.del") + s.redirect(w, r, s.p("/ipv6pool"), s.t(r, "pool_removed", addr)) } // ---- audit log ---- type auditPageData struct { - Title string - Prefix string - Lang string + Title string + Prefix string + Lang string } func (s *Server) renderAudit(w http.ResponseWriter, r *http.Request, d auditPageData) { - t, err := s.templates() - if err != nil { - http.Error(w, "template error: "+err.Error(), 500) - return - } - if d.Lang == "" { - d.Lang = langEn - if l, ok := r.Context().Value(langCtxKey).(string); ok && l != "" { - d.Lang = l - } - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(http.StatusOK) - if err := t.ExecuteTemplate(w, "admin_audit.html", d); err != nil { - http.Error(w, err.Error(), 500) - } + t, err := s.templates() + if err != nil { + http.Error(w, "template error: "+err.Error(), 500) + return + } + if d.Lang == "" { + d.Lang = langEn + if l, ok := r.Context().Value(langCtxKey).(string); ok && l != "" { + d.Lang = l + } + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + if err := t.ExecuteTemplate(w, "admin_audit.html", d); err != nil { + http.Error(w, err.Error(), 500) + } } // handleAudit renders the audit page shell; rows are fetched chunk-by-chunk by // the browser from /audit/api so the page never renders thousands of rows. func (s *Server) handleAudit(w http.ResponseWriter, r *http.Request) { - s.renderAudit(w, r, auditPageData{Title: "VPS Manager Admin — Audit", Prefix: s.prefix()}) + s.renderAudit(w, r, auditPageData{Title: "VPS Manager Admin — Audit", Prefix: s.prefix()}) } // handleAuditAPI returns one chunk of audit rows as JSON for client-side // rendering (500 per chunk, newest first). func (s *Server) handleAuditAPI(w http.ResponseWriter, r *http.Request) { - offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) - if offset < 0 { - offset = 0 - } - limit := 500 - if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && l > 0 && l <= 1000 { - limit = l - } - rows, err := s.db.ListAuditLog(offset, limit) - if err != nil { - http.Error(w, err.Error(), 500) - return - } - total, _ := s.db.AuditCount() - type auditRowJSON struct { - ID int64 `json:"id"` - Actor string `json:"actor"` - Action string `json:"action"` - CreatedAt string `json:"created_at"` - } - out := make([]auditRowJSON, 0, len(rows)) - for _, a := range rows { - out = append(out, auditRowJSON{a.ID, a.Actor, a.Action, a.CreatedAt}) - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(struct { - Rows []auditRowJSON `json:"rows"` - More bool `json:"more"` - Total int `json:"total"` - }{out, offset+len(rows) < total, total}) + offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) + if offset < 0 { + offset = 0 + } + limit := 500 + if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && l > 0 && l <= 1000 { + limit = l + } + rows, err := s.db.ListAuditLog(offset, limit) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + total, _ := s.db.AuditCount() + type auditRowJSON struct { + ID int64 `json:"id"` + Actor string `json:"actor"` + Action string `json:"action"` + CreatedAt string `json:"created_at"` + } + out := make([]auditRowJSON, 0, len(rows)) + for _, a := range rows { + out = append(out, auditRowJSON{a.ID, a.Actor, a.Action, a.CreatedAt}) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(struct { + Rows []auditRowJSON `json:"rows"` + More bool `json:"more"` + Total int `json:"total"` + }{out, offset+len(rows) < total, total}) } // handleAdminKeys reconciles the operator's SSH-key store from the management @@ -969,31 +981,31 @@ func (s *Server) handleAuditAPI(w http.ResponseWriter, r *http.Request) { // missing rows are deleted), same contract as the user panel's /ssh-keys. // Returns the fresh key list so the panel can re-render without a reload. func (s *Server) handleAdminKeys(w http.ResponseWriter, r *http.Request) { - var req struct { - Keys []mgr.SSHKeyInput `json:"keys"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeAdminKeys(w, false, "", nil) - return - } - keys, err := s.mgr.SaveAdminKeys(req.Keys) - if err != nil { - writeAdminKeys(w, false, err.Error(), nil) - return - } - _ = s.db.AddAuditLog("000", "admin.keys") - writeAdminKeys(w, true, "", keys) + var req struct { + Keys []mgr.SSHKeyInput `json:"keys"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeAdminKeys(w, false, "", nil) + return + } + keys, err := s.mgr.SaveAdminKeys(req.Keys) + if err != nil { + writeAdminKeys(w, false, err.Error(), nil) + return + } + _ = s.db.AddAuditLog("000", "admin.keys") + writeAdminKeys(w, true, "", keys) } func writeAdminKeys(w http.ResponseWriter, ok bool, errMsg string, keys []db.AdminKey) { - rows := make([]sshKeyRow, 0, len(keys)) - for _, k := range keys { - rows = append(rows, sshKeyRow{ID: k.ID, Name: k.Name, Key: k.Key, Active: k.Active}) - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(struct { - OK bool `json:"ok"` - Error string `json:"error,omitempty"` - Keys []sshKeyRow `json:"keys"` - }{ok, errMsg, rows}) + rows := make([]sshKeyRow, 0, len(keys)) + for _, k := range keys { + rows = append(rows, sshKeyRow{ID: k.ID, Name: k.Name, Key: k.Key, Active: k.Active}) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + Keys []sshKeyRow `json:"keys"` + }{ok, errMsg, rows}) }