Explorar o código

fix(ppp): resolve IP overrides in secrets/profiles and add PPP & VPN server CLI commands

Proxmox Router hai 1 día
pai
achega
2dfdf83552
Modificáronse 11 ficheiros con 1078 adicións e 11 borrados
  1. 1 1
      VERSION
  2. BIN=BIN
      bin/debtik
  3. BIN=BIN
      bin/debtik-arm64
  4. BIN=BIN
      bin/debtik-cli
  5. BIN=BIN
      bin/debtik-cli-arm64
  6. 107 2
      internal/cli/completer.go
  7. 19 0
      internal/cli/executor.go
  8. 627 0
      internal/cli/ppp.go
  9. 72 0
      internal/cli/ppp_test.go
  10. 137 8
      internal/vpn/ppp_server.go
  11. 115 0
      internal/vpn/ppp_server_test.go

+ 1 - 1
VERSION

@@ -1 +1 @@
-1.8.10
+1.8.11

BIN=BIN
bin/debtik


BIN=BIN
bin/debtik-arm64


BIN=BIN
bin/debtik-cli


BIN=BIN
bin/debtik-cli-arm64


+ 107 - 2
internal/cli/completer.go

@@ -345,8 +345,9 @@ func colorizeCandidate(s string) string {
 	switch s {
 	case "print", "add", "remove", "enable", "disable", "set", "reboot", "flush", "renew", "release", "connect", "disconnect", "status":
 		return ColorGreen + s + ColorReset
-	case "interface", "ip", "routing", "tool", "log", "user", "system", "certificate", "certificates",
-		"vlan", "bridge", "wireguard", "eoip", "vxlan", "vrrp", "pppoe-client",
+	case "interface", "ip", "routing", "tool", "log", "user", "system", "certificate", "certificates", "ppp",
+		"vlan", "bridge", "wireguard", "eoip", "vxlan", "vrrp", "pppoe-client", "l2tp-server", "pptp-server", "pppoe-server",
+		"profile", "secret", "active",
 		"address", "route", "dhcp-client", "dhcp-server", "dns", "firewall", "service", "reverse-proxy", "sdwan", "sd-wan", "member", "members",
 		"filter", "nat", "mangle", "address-list", "bgp", "ospf", "port", "peers", "lease", "network", "cache", "static", "rule", "table", "instance", "area", "neighbor", "resource", "identity":
 		return ColorCyan + s + ColorReset
@@ -598,6 +599,110 @@ func (c *Completer) buildCommandTree() *Node {
 	pppoeAdd.AddChild(&Node{Name: "user="})
 	pppoeAdd.AddChild(&Node{Name: "password="})
 
+	// /interface l2tp-server
+	l2tpNode := ifaceNode.AddChild(&Node{Name: "l2tp-server"})
+	addPrint(l2tpNode)
+	l2tpSet := l2tpNode.AddChild(&Node{Name: "set"})
+	l2tpSet.AddChild(&Node{Name: "enabled="})
+	l2tpSet.AddChild(&Node{Name: "port="})
+	l2tpSet.AddChild(&Node{Name: "default-profile="})
+	l2tpSet.AddChild(&Node{Name: "use-ipsec="})
+	l2tpSet.AddChild(&Node{Name: "ipsec-secret="})
+	l2tpSrv := l2tpNode.AddChild(&Node{Name: "server"})
+	addPrint(l2tpSrv)
+	l2tpSrvSet := l2tpSrv.AddChild(&Node{Name: "set"})
+	l2tpSrvSet.AddChild(&Node{Name: "enabled="})
+	l2tpSrvSet.AddChild(&Node{Name: "port="})
+	l2tpSrvSet.AddChild(&Node{Name: "default-profile="})
+	l2tpSrvSet.AddChild(&Node{Name: "use-ipsec="})
+	l2tpSrvSet.AddChild(&Node{Name: "ipsec-secret="})
+
+	// /interface pptp-server
+	pptpNode := ifaceNode.AddChild(&Node{Name: "pptp-server"})
+	addPrint(pptpNode)
+	pptpSet := pptpNode.AddChild(&Node{Name: "set"})
+	pptpSet.AddChild(&Node{Name: "enabled="})
+	pptpSet.AddChild(&Node{Name: "port="})
+	pptpSet.AddChild(&Node{Name: "default-profile="})
+	pptpSet.AddChild(&Node{Name: "require-mppe="})
+	pptpSrv := pptpNode.AddChild(&Node{Name: "server"})
+	addPrint(pptpSrv)
+	pptpSrvSet := pptpSrv.AddChild(&Node{Name: "set"})
+	pptpSrvSet.AddChild(&Node{Name: "enabled="})
+	pptpSrvSet.AddChild(&Node{Name: "port="})
+	pptpSrvSet.AddChild(&Node{Name: "default-profile="})
+	pptpSrvSet.AddChild(&Node{Name: "require-mppe="})
+
+	// /interface pppoe-server
+	pppoeSrvNode := ifaceNode.AddChild(&Node{Name: "pppoe-server"})
+	addPrint(pppoeSrvNode)
+	pppoeSrvSet := pppoeSrvNode.AddChild(&Node{Name: "set"})
+	pppoeSrvSet.AddChild(&Node{Name: "enabled="})
+	pppoeSrvSet.AddChild(&Node{Name: "interface=", Dynamic: dynInterfaces})
+	pppoeSrvSet.AddChild(&Node{Name: "default-profile="})
+	pppoeSrvSet.AddChild(&Node{Name: "service-name="})
+	pppoeSrvSet.AddChild(&Node{Name: "one-session-per-host="})
+	pppoeSrvSub := pppoeSrvNode.AddChild(&Node{Name: "server"})
+	addPrint(pppoeSrvSub)
+	pppoeSrvSubSet := pppoeSrvSub.AddChild(&Node{Name: "set"})
+	pppoeSrvSubSet.AddChild(&Node{Name: "enabled="})
+	pppoeSrvSubSet.AddChild(&Node{Name: "interface=", Dynamic: dynInterfaces})
+	pppoeSrvSubSet.AddChild(&Node{Name: "default-profile="})
+	pppoeSrvSubSet.AddChild(&Node{Name: "service-name="})
+	pppoeSrvSubSet.AddChild(&Node{Name: "one-session-per-host="})
+
+	// /ppp
+	pppNode := root.AddChild(&Node{Name: "ppp"})
+
+	// /ppp profile
+	profNode := pppNode.AddChild(&Node{Name: "profile"})
+	addPrint(profNode)
+	addRemove(profNode)
+	profAdd := profNode.AddChild(&Node{Name: "add"})
+	profAdd.AddChild(&Node{Name: "name="})
+	profAdd.AddChild(&Node{Name: "local-address="})
+	profAdd.AddChild(&Node{Name: "remote-address="})
+	profAdd.AddChild(&Node{Name: "dns-server="})
+	profAdd.AddChild(&Node{Name: "use-encryption="})
+	profAdd.AddChild(&Node{Name: "only-one="})
+	profAdd.AddChild(&Node{Name: "comment="})
+	profSet := profNode.AddChild(&Node{Name: "set"})
+	profSet.AddChild(&Node{Name: "local-address="})
+	profSet.AddChild(&Node{Name: "remote-address="})
+	profSet.AddChild(&Node{Name: "dns-server="})
+	profSet.AddChild(&Node{Name: "use-encryption="})
+	profSet.AddChild(&Node{Name: "only-one="})
+	profSet.AddChild(&Node{Name: "comment="})
+
+	// /ppp secret
+	secretNode := pppNode.AddChild(&Node{Name: "secret"})
+	addPrint(secretNode)
+	addRemove(secretNode)
+	addEnableDisable(secretNode)
+	secAdd := secretNode.AddChild(&Node{Name: "add"})
+	secAdd.AddChild(&Node{Name: "name="})
+	secAdd.AddChild(&Node{Name: "password="})
+	secAdd.AddChild(&Node{Name: "profile="})
+	secAdd.AddChild(&Node{Name: "service="})
+	secAdd.AddChild(&Node{Name: "local-address="})
+	secAdd.AddChild(&Node{Name: "remote-address="})
+	secAdd.AddChild(&Node{Name: "caller-id="})
+	secAdd.AddChild(&Node{Name: "comment="})
+	secSet := secretNode.AddChild(&Node{Name: "set"})
+	secSet.AddChild(&Node{Name: "password="})
+	secSet.AddChild(&Node{Name: "profile="})
+	secSet.AddChild(&Node{Name: "service="})
+	secSet.AddChild(&Node{Name: "local-address="})
+	secSet.AddChild(&Node{Name: "remote-address="})
+	secSet.AddChild(&Node{Name: "caller-id="})
+	secSet.AddChild(&Node{Name: "comment="})
+	secSet.AddChild(&Node{Name: "disabled="})
+
+	// /ppp active
+	actNode := pppNode.AddChild(&Node{Name: "active"})
+	addPrint(actNode)
+	addRemove(actNode)
+
 	// 2. /ip
 	ipNode := root.AddChild(&Node{Name: "ip"})
 

+ 19 - 0
internal/cli/executor.go

@@ -149,6 +149,9 @@ func (e *Executor) Execute(rawCmd string, user *auth.PublicUser) (output string,
 	case "interface":
 		return e.handleInterface(parts[1:], user), false
 
+	case "ppp":
+		return e.handlePPP(parts[1:], user), false
+
 	case "ip":
 		return e.handleIP(parts[1:], user), false
 
@@ -272,6 +275,15 @@ func (e *Executor) handleInterface(parts []string, user *auth.PublicUser) string
 	case "pppoe-client":
 		return e.handleInterfacePPPoE(parts[1:], user)
 
+	case "l2tp-server":
+		return e.handleInterfaceL2TPServer(parts[1:], user)
+
+	case "pptp-server":
+		return e.handleInterfacePPTPServer(parts[1:], user)
+
+	case "pppoe-server":
+		return e.handleInterfacePPPoEServer(parts[1:], user)
+
 	case "enable":
 		if len(parts) < 2 {
 			return ColorRed + "Usage: /interface enable <name>\n" + ColorReset
@@ -5858,6 +5870,13 @@ func (e *Executor) showHelp() string {
 	b.WriteString("    /interface vxlan             - VXLAN overlay tunnels & VTEPs (print, add, set, remove, enable, disable, vteps)\n")
 	b.WriteString("    /interface vrrp              - Virtual Router Redundancy Protocol (print, add, remove)\n")
 	b.WriteString("    /interface pppoe-client      - PPPoE WAN Client sessions (print, add, connect)\n")
+	b.WriteString("    /interface l2tp-server       - L2TP Server configuration (server print, server set)\n")
+	b.WriteString("    /interface pptp-server       - PPTP Server configuration (server print, server set)\n")
+	b.WriteString("    /interface pppoe-server      - PPPoE Access Concentrator Server (server print, server set)\n")
+	b.WriteString("  /ppp                           - PPP Subscribers & Profiles manager\n")
+	b.WriteString("    /ppp profile                 - PPP Profiles & IP pools (print, add, set, remove)\n")
+	b.WriteString("    /ppp secret                  - PPP Subscriber accounts (print, add, set, remove, enable, disable)\n")
+	b.WriteString("    /ppp active                  - Active PPP sessions (print, remove)\n")
 	b.WriteString("  /ip\n")
 	b.WriteString("    /ip address                  - IP address assignments (print, add, remove)\n")
 	b.WriteString("    /ip route                    - Static routes & tables (print, add, remove, rule)\n")

+ 627 - 0
internal/cli/ppp.go

@@ -0,0 +1,627 @@
+package cli
+
+import (
+	"fmt"
+	"strconv"
+	"strings"
+
+	"debtik/internal/auth"
+	"debtik/internal/vpn"
+)
+
+// ==================== PPP (/ppp) ====================
+
+func (e *Executor) handlePPP(parts []string, user *auth.PublicUser) string {
+	if len(parts) == 0 {
+		return ColorRed + "Usage: /ppp [profile|secret|active]\n" + ColorReset
+	}
+
+	sub := strings.ToLower(parts[0])
+	switch sub {
+	case "profile":
+		return e.handlePPPProfile(parts[1:], user)
+	case "secret":
+		return e.handlePPPSecret(parts[1:], user)
+	case "active":
+		return e.handlePPPActive(parts[1:], user)
+	default:
+		return fmt.Sprintf(ColorRed+"Unknown ppp command %q. Usage: /ppp [profile|secret|active]\n"+ColorReset, sub)
+	}
+}
+
+// ----------------- /ppp profile -----------------
+
+func (e *Executor) handlePPPProfile(parts []string, user *auth.PublicUser) string {
+	if e.vpnMgr == nil || e.vpnMgr.PPPServer == nil {
+		return ColorRed + "PPP server subsystem not available.\n" + ColorReset
+	}
+	mgr := e.vpnMgr.PPPServer
+
+	action := "print"
+	if len(parts) > 0 {
+		action = strings.ToLower(parts[0])
+		parts = parts[1:]
+	}
+	pos, params := parseArgs(parts)
+
+	switch action {
+	case "print", "pr", "show", "list":
+		list := mgr.ListProfiles()
+		tbl := NewTable("#", "NAME", "LOCAL-ADDRESS", "REMOTE-ADDRESS", "DNS-SERVERS", "ENCRYPTION", "ONLY-ONE", "COMMENT")
+		for i, p := range list {
+			tbl.AddRow(
+				strconv.Itoa(i),
+				p.Name,
+				p.LocalAddress,
+				p.RemoteAddress,
+				p.DNSServers,
+				p.UseEncryption,
+				strconv.FormatBool(p.OnlyOne),
+				p.Comment,
+			)
+		}
+		return tbl.String()
+
+	case "add":
+		if user != nil && user.Role == auth.RoleRead {
+			return ColorRed + "Permission denied: viewer cannot modify profiles\n" + ColorReset
+		}
+		name := params["name"]
+		if name == "" {
+			if len(pos) > 0 {
+				name = pos[0]
+			} else {
+				return ColorRed + "Syntax error: name is required. Usage: /ppp profile add name=<name> [local-address=...] [remote-address=...]\n" + ColorReset
+			}
+		}
+
+		p := vpn.PPPProfile{
+			ID:            name,
+			Name:          name,
+			LocalAddress:  params["local-address"],
+			RemoteAddress: params["remote-address"],
+			DNSServers:    params["dns-server"],
+			UseEncryption: params["use-encryption"],
+			Comment:       params["comment"],
+		}
+		if p.DNSServers == "" {
+			p.DNSServers = params["dns-servers"]
+		}
+		if p.LocalAddress == "" {
+			p.LocalAddress = "10.10.10.1"
+		}
+		if p.RemoteAddress == "" {
+			p.RemoteAddress = "10.10.10.10-10.10.10.50"
+		}
+		if p.UseEncryption == "" {
+			p.UseEncryption = "no"
+		}
+		if val, ok := params["only-one"]; ok {
+			p.OnlyOne = val == "yes" || val == "true"
+		} else {
+			p.OnlyOne = true
+		}
+
+		if err := mgr.SaveProfile(&p); err != nil {
+			return ColorRed + "Error adding profile: " + err.Error() + "\n" + ColorReset
+		}
+		return ColorGreen + fmt.Sprintf("PPP profile %q created successfully.\n", name) + ColorReset
+
+	case "set":
+		if user != nil && user.Role == auth.RoleRead {
+			return ColorRed + "Permission denied: viewer cannot modify profiles\n" + ColorReset
+		}
+		if len(pos) == 0 {
+			return ColorRed + "Syntax error: target profile name or index required. Usage: /ppp profile set <name> [params...]\n" + ColorReset
+		}
+		target := pos[0]
+		prof := mgr.GetProfile(target)
+		if prof == nil {
+			// Check if numeric index was provided
+			if idx, err := strconv.Atoi(target); err == nil {
+				all := mgr.ListProfiles()
+				if idx >= 0 && idx < len(all) {
+					prof = all[idx]
+				}
+			}
+		}
+		if prof == nil {
+			return ColorRed + fmt.Sprintf("Profile %q not found.\n", target) + ColorReset
+		}
+
+		if val, ok := params["local-address"]; ok {
+			prof.LocalAddress = val
+		}
+		if val, ok := params["remote-address"]; ok {
+			prof.RemoteAddress = val
+		}
+		if val, ok := params["dns-server"]; ok {
+			prof.DNSServers = val
+		}
+		if val, ok := params["dns-servers"]; ok {
+			prof.DNSServers = val
+		}
+		if val, ok := params["use-encryption"]; ok {
+			prof.UseEncryption = val
+		}
+		if val, ok := params["only-one"]; ok {
+			prof.OnlyOne = val == "yes" || val == "true"
+		}
+		if val, ok := params["comment"]; ok {
+			prof.Comment = val
+		}
+
+		if err := mgr.SaveProfile(prof); err != nil {
+			return ColorRed + "Error updating profile: " + err.Error() + "\n" + ColorReset
+		}
+		return ColorGreen + fmt.Sprintf("PPP profile %q updated successfully.\n", prof.Name) + ColorReset
+
+	case "remove", "delete", "rem":
+		if user != nil && user.Role == auth.RoleRead {
+			return ColorRed + "Permission denied: viewer cannot delete profiles\n" + ColorReset
+		}
+		if len(pos) == 0 {
+			return ColorRed + "Syntax error: target profile name or index required. Usage: /ppp profile remove <name>\n" + ColorReset
+		}
+		target := pos[0]
+		if prof := mgr.GetProfile(target); prof == nil {
+			if idx, err := strconv.Atoi(target); err == nil {
+				all := mgr.ListProfiles()
+				if idx >= 0 && idx < len(all) {
+					target = all[idx].Name
+				}
+			}
+		}
+		if err := mgr.DeleteProfile(target); err != nil {
+			return ColorRed + "Error deleting profile: " + err.Error() + "\n" + ColorReset
+		}
+		return ColorGreen + fmt.Sprintf("PPP profile %q removed successfully.\n", target) + ColorReset
+
+	default:
+		return ColorRed + fmt.Sprintf("Unknown action %q. Usage: /ppp profile [print|add|set|remove]\n", action) + ColorReset
+	}
+}
+
+// ----------------- /ppp secret -----------------
+
+func (e *Executor) handlePPPSecret(parts []string, user *auth.PublicUser) string {
+	if e.vpnMgr == nil || e.vpnMgr.PPPServer == nil {
+		return ColorRed + "PPP server subsystem not available.\n" + ColorReset
+	}
+	mgr := e.vpnMgr.PPPServer
+
+	action := "print"
+	if len(parts) > 0 {
+		action = strings.ToLower(parts[0])
+		parts = parts[1:]
+	}
+	pos, params := parseArgs(parts)
+
+	switch action {
+	case "print", "pr", "show", "list":
+		list := mgr.ListSecrets()
+		tbl := NewTable("#", "FLAGS", "NAME", "SERVICE", "PROFILE", "LOCAL-ADDRESS", "REMOTE-ADDRESS", "CALLER-ID", "COMMENT")
+		for i, s := range list {
+			flags := " "
+			if s.Disabled {
+				flags = "X"
+			}
+			tbl.AddRow(
+				strconv.Itoa(i),
+				flags,
+				s.Name,
+				s.Service,
+				s.Profile,
+				s.LocalAddress,
+				s.RemoteAddress,
+				s.CallerID,
+				s.Comment,
+			)
+		}
+		return tbl.String()
+
+	case "add":
+		if user != nil && user.Role == auth.RoleRead {
+			return ColorRed + "Permission denied: viewer cannot add secrets\n" + ColorReset
+		}
+		name := params["name"]
+		pass := params["password"]
+		if name == "" || pass == "" {
+			return ColorRed + "Syntax error: name and password are required. Usage: /ppp secret add name=<user> password=<pass> [profile=...] [service=any|l2tp|pptp|pppoe]\n" + ColorReset
+		}
+
+		sec := vpn.PPPSecret{
+			Name:          name,
+			Password:      pass,
+			Service:       params["service"],
+			Profile:       params["profile"],
+			LocalAddress:  params["local-address"],
+			RemoteAddress: params["remote-address"],
+			CallerID:      params["caller-id"],
+			Comment:       params["comment"],
+		}
+		if sec.Service == "" {
+			sec.Service = "any"
+		}
+		if sec.Profile == "" {
+			sec.Profile = "default"
+		}
+		if val, ok := params["disabled"]; ok {
+			sec.Disabled = val == "yes" || val == "true"
+		}
+
+		if err := mgr.SaveSecret(&sec); err != nil {
+			return ColorRed + "Error adding secret: " + err.Error() + "\n" + ColorReset
+		}
+		return ColorGreen + fmt.Sprintf("PPP secret %q created successfully.\n", name) + ColorReset
+
+	case "set":
+		if user != nil && user.Role == auth.RoleRead {
+			return ColorRed + "Permission denied: viewer cannot modify secrets\n" + ColorReset
+		}
+		if len(pos) == 0 {
+			return ColorRed + "Syntax error: target secret name or index required. Usage: /ppp secret set <name> [params...]\n" + ColorReset
+		}
+		target := pos[0]
+		sec := findSecret(mgr, target)
+		if sec == nil {
+			return ColorRed + fmt.Sprintf("Secret %q not found.\n", target) + ColorReset
+		}
+
+		if val, ok := params["password"]; ok {
+			sec.Password = val
+		}
+		if val, ok := params["profile"]; ok {
+			sec.Profile = val
+		}
+		if val, ok := params["service"]; ok {
+			sec.Service = val
+		}
+		if val, ok := params["local-address"]; ok {
+			sec.LocalAddress = val
+		}
+		if val, ok := params["remote-address"]; ok {
+			sec.RemoteAddress = val
+		}
+		if val, ok := params["caller-id"]; ok {
+			sec.CallerID = val
+		}
+		if val, ok := params["comment"]; ok {
+			sec.Comment = val
+		}
+		if val, ok := params["disabled"]; ok {
+			sec.Disabled = val == "yes" || val == "true"
+		}
+
+		if err := mgr.SaveSecret(sec); err != nil {
+			return ColorRed + "Error updating secret: " + err.Error() + "\n" + ColorReset
+		}
+		return ColorGreen + fmt.Sprintf("PPP secret %q updated successfully.\n", sec.Name) + ColorReset
+
+	case "remove", "delete", "rem":
+		if user != nil && user.Role == auth.RoleRead {
+			return ColorRed + "Permission denied: viewer cannot delete secrets\n" + ColorReset
+		}
+		if len(pos) == 0 {
+			return ColorRed + "Syntax error: target secret name or index required. Usage: /ppp secret remove <name>\n" + ColorReset
+		}
+		sec := findSecret(mgr, pos[0])
+		if sec == nil {
+			return ColorRed + fmt.Sprintf("Secret %q not found.\n", pos[0]) + ColorReset
+		}
+		if err := mgr.DeleteSecret(sec.ID); err != nil {
+			return ColorRed + "Error deleting secret: " + err.Error() + "\n" + ColorReset
+		}
+		return ColorGreen + fmt.Sprintf("PPP secret %q removed successfully.\n", sec.Name) + ColorReset
+
+	case "enable":
+		if user != nil && user.Role == auth.RoleRead {
+			return ColorRed + "Permission denied\n" + ColorReset
+		}
+		if len(pos) == 0 {
+			return ColorRed + "Usage: /ppp secret enable <name>\n" + ColorReset
+		}
+		sec := findSecret(mgr, pos[0])
+		if sec == nil {
+			return ColorRed + fmt.Sprintf("Secret %q not found.\n", pos[0]) + ColorReset
+		}
+		if err := mgr.SetSecretState(sec.ID, false); err != nil {
+			return ColorRed + err.Error() + "\n" + ColorReset
+		}
+		return ColorGreen + fmt.Sprintf("PPP secret %q enabled.\n", sec.Name) + ColorReset
+
+	case "disable":
+		if user != nil && user.Role == auth.RoleRead {
+			return ColorRed + "Permission denied\n" + ColorReset
+		}
+		if len(pos) == 0 {
+			return ColorRed + "Usage: /ppp secret disable <name>\n" + ColorReset
+		}
+		sec := findSecret(mgr, pos[0])
+		if sec == nil {
+			return ColorRed + fmt.Sprintf("Secret %q not found.\n", pos[0]) + ColorReset
+		}
+		if err := mgr.SetSecretState(sec.ID, true); err != nil {
+			return ColorRed + err.Error() + "\n" + ColorReset
+		}
+		return ColorGreen + fmt.Sprintf("PPP secret %q disabled.\n", sec.Name) + ColorReset
+
+	default:
+		return ColorRed + fmt.Sprintf("Unknown action %q. Usage: /ppp secret [print|add|set|remove|enable|disable]\n", action) + ColorReset
+	}
+}
+
+func findSecret(mgr *vpn.PPPServerManager, target string) *vpn.PPPSecret {
+	list := mgr.ListSecrets()
+	for _, s := range list {
+		if s.ID == target || s.Name == target {
+			return s
+		}
+	}
+	if idx, err := strconv.Atoi(target); err == nil && idx >= 0 && idx < len(list) {
+		return list[idx]
+	}
+	return nil
+}
+
+// ----------------- /ppp active -----------------
+
+func (e *Executor) handlePPPActive(parts []string, user *auth.PublicUser) string {
+	if e.vpnMgr == nil || e.vpnMgr.PPPServer == nil {
+		return ColorRed + "PPP server subsystem not available.\n" + ColorReset
+	}
+	mgr := e.vpnMgr.PPPServer
+
+	action := "print"
+	if len(parts) > 0 {
+		action = strings.ToLower(parts[0])
+		parts = parts[1:]
+	}
+	pos, _ := parseArgs(parts)
+
+	switch action {
+	case "print", "pr", "show", "list":
+		list := mgr.ListActiveSessions()
+		tbl := NewTable("#", "INTERFACE", "USER", "SERVICE", "CALLER-ID", "ADDRESS", "UPTIME", "RX", "TX")
+		for i, s := range list {
+			tbl.AddRow(
+				strconv.Itoa(i),
+				s.Interface,
+				s.Username,
+				s.Service,
+				s.CallerID,
+				s.Address,
+				s.Uptime,
+				s.RxHuman,
+				s.TxHuman,
+			)
+		}
+		return tbl.String()
+
+	case "remove", "disconnect", "kill":
+		if user != nil && user.Role == auth.RoleRead {
+			return ColorRed + "Permission denied\n" + ColorReset
+		}
+		if len(pos) == 0 {
+			return ColorRed + "Usage: /ppp active remove <interface>\n" + ColorReset
+		}
+		iface := pos[0]
+		if err := mgr.DisconnectSession(iface); err != nil {
+			return ColorRed + "Error disconnecting session: " + err.Error() + "\n" + ColorReset
+		}
+		return ColorGreen + fmt.Sprintf("Session on %s disconnected.\n", iface) + ColorReset
+
+	default:
+		return ColorRed + fmt.Sprintf("Unknown action %q. Usage: /ppp active [print|remove <iface>]\n", action) + ColorReset
+	}
+}
+
+// ==================== INTERFACE VPN SERVERS ====================
+
+// ----------------- /interface l2tp-server server -----------------
+
+func (e *Executor) handleInterfaceL2TPServer(parts []string, user *auth.PublicUser) string {
+	if e.vpnMgr == nil || e.vpnMgr.PPPServer == nil {
+		return ColorRed + "VPN server subsystem not available.\n" + ColorReset
+	}
+	mgr := e.vpnMgr.PPPServer
+
+	// Skip optional "server" subkeyword if present (e.g. /interface l2tp-server server print)
+	if len(parts) > 0 && strings.ToLower(parts[0]) == "server" {
+		parts = parts[1:]
+	}
+
+	action := "print"
+	if len(parts) > 0 {
+		action = strings.ToLower(parts[0])
+		parts = parts[1:]
+	}
+	_, params := parseArgs(parts)
+
+	switch action {
+	case "print", "pr", "show":
+		cfg := mgr.GetL2TPServer()
+		tbl := NewTable("ENABLED", "PORT", "DEFAULT-PROFILE", "USE-IPSEC", "RUNNING", "ACTIVE-SESSIONS", "LAST-ERROR")
+		tbl.AddRow(
+			strconv.FormatBool(cfg.Enabled),
+			strconv.Itoa(cfg.Port),
+			cfg.DefaultProfile,
+			cfg.UseIPSec,
+			strconv.FormatBool(cfg.Running),
+			strconv.Itoa(cfg.ActiveSessions),
+			cfg.LastError,
+		)
+		return tbl.String()
+
+	case "set":
+		if user != nil && user.Role == auth.RoleRead {
+			return ColorRed + "Permission denied\n" + ColorReset
+		}
+		cfg := mgr.GetL2TPServer()
+		if val, ok := params["enabled"]; ok {
+			cfg.Enabled = val == "yes" || val == "true"
+		}
+		if val, ok := params["port"]; ok {
+			if p, err := strconv.Atoi(val); err == nil && p > 0 {
+				cfg.Port = p
+			}
+		}
+		if val, ok := params["default-profile"]; ok {
+			cfg.DefaultProfile = val
+		}
+		if val, ok := params["use-ipsec"]; ok {
+			cfg.UseIPSec = val
+		}
+		if val, ok := params["ipsec-secret"]; ok {
+			cfg.IPSecSecret = val
+		}
+
+		if err := mgr.SetL2TPServer(cfg); err != nil {
+			return ColorRed + "Error applying L2TP server: " + err.Error() + "\n" + ColorReset
+		}
+		return ColorGreen + "L2TP server configuration updated successfully.\n" + ColorReset
+
+	default:
+		return ColorRed + fmt.Sprintf("Unknown action %q. Usage: /interface l2tp-server server [print|set enabled=yes|no default-profile=...]\n", action) + ColorReset
+	}
+}
+
+// ----------------- /interface pptp-server server -----------------
+
+func (e *Executor) handleInterfacePPTPServer(parts []string, user *auth.PublicUser) string {
+	if e.vpnMgr == nil || e.vpnMgr.PPPServer == nil {
+		return ColorRed + "VPN server subsystem not available.\n" + ColorReset
+	}
+	mgr := e.vpnMgr.PPPServer
+
+	if len(parts) > 0 && strings.ToLower(parts[0]) == "server" {
+		parts = parts[1:]
+	}
+
+	action := "print"
+	if len(parts) > 0 {
+		action = strings.ToLower(parts[0])
+		parts = parts[1:]
+	}
+	_, params := parseArgs(parts)
+
+	switch action {
+	case "print", "pr", "show":
+		cfg := mgr.GetPPTPServer()
+		tbl := NewTable("ENABLED", "PORT", "DEFAULT-PROFILE", "REQUIRE-MPPE", "RUNNING", "ACTIVE-SESSIONS", "LAST-ERROR")
+		tbl.AddRow(
+			strconv.FormatBool(cfg.Enabled),
+			strconv.Itoa(cfg.Port),
+			cfg.DefaultProfile,
+			strconv.FormatBool(cfg.RequireMPPE),
+			strconv.FormatBool(cfg.Running),
+			strconv.Itoa(cfg.ActiveSessions),
+			cfg.LastError,
+		)
+		return tbl.String()
+
+	case "set":
+		if user != nil && user.Role == auth.RoleRead {
+			return ColorRed + "Permission denied\n" + ColorReset
+		}
+		cfg := mgr.GetPPTPServer()
+		if val, ok := params["enabled"]; ok {
+			cfg.Enabled = val == "yes" || val == "true"
+		}
+		if val, ok := params["port"]; ok {
+			if p, err := strconv.Atoi(val); err == nil && p > 0 {
+				cfg.Port = p
+			}
+		}
+		if val, ok := params["default-profile"]; ok {
+			cfg.DefaultProfile = val
+		}
+		if val, ok := params["require-mppe"]; ok {
+			cfg.RequireMPPE = val == "yes" || val == "true"
+		}
+
+		if err := mgr.SetPPTPServer(cfg); err != nil {
+			return ColorRed + "Error applying PPTP server: " + err.Error() + "\n" + ColorReset
+		}
+		return ColorGreen + "PPTP server configuration updated successfully.\n" + ColorReset
+
+	default:
+		return ColorRed + fmt.Sprintf("Unknown action %q. Usage: /interface pptp-server server [print|set enabled=yes|no default-profile=...]\n", action) + ColorReset
+	}
+}
+
+// ----------------- /interface pppoe-server server -----------------
+
+func (e *Executor) handleInterfacePPPoEServer(parts []string, user *auth.PublicUser) string {
+	if e.vpnMgr == nil || e.vpnMgr.PPPServer == nil {
+		return ColorRed + "VPN server subsystem not available.\n" + ColorReset
+	}
+	mgr := e.vpnMgr.PPPServer
+
+	if len(parts) > 0 && strings.ToLower(parts[0]) == "server" {
+		parts = parts[1:]
+	}
+
+	action := "print"
+	if len(parts) > 0 {
+		action = strings.ToLower(parts[0])
+		parts = parts[1:]
+	}
+	_, params := parseArgs(parts)
+
+	switch action {
+	case "print", "pr", "show":
+		cfg := mgr.GetPPPoEServer()
+		tbl := NewTable("ENABLED", "SERVICE-NAME", "INTERFACE", "DEFAULT-PROFILE", "ONE-SESSION-PER-HOST", "RUNNING", "ACTIVE-SESSIONS", "LAST-ERROR")
+		tbl.AddRow(
+			strconv.FormatBool(cfg.Enabled),
+			cfg.ServiceName,
+			cfg.Interface,
+			cfg.DefaultProfile,
+			strconv.FormatBool(cfg.OneSessionPerHost),
+			strconv.FormatBool(cfg.Running),
+			strconv.Itoa(cfg.ActiveSessions),
+			cfg.LastError,
+		)
+		return tbl.String()
+
+	case "set":
+		if user != nil && user.Role == auth.RoleRead {
+			return ColorRed + "Permission denied\n" + ColorReset
+		}
+		cfg := mgr.GetPPPoEServer()
+		if val, ok := params["enabled"]; ok {
+			cfg.Enabled = val == "yes" || val == "true"
+		}
+		if val, ok := params["service-name"]; ok {
+			cfg.ServiceName = val
+		}
+		if val, ok := params["interface"]; ok {
+			cfg.Interface = val
+		}
+		if val, ok := params["default-profile"]; ok {
+			cfg.DefaultProfile = val
+		}
+		if val, ok := params["one-session-per-host"]; ok {
+			cfg.OneSessionPerHost = val == "yes" || val == "true"
+		}
+		if val, ok := params["max-mtu"]; ok {
+			if m, err := strconv.Atoi(val); err == nil && m > 0 {
+				cfg.MaxMTU = m
+			}
+		}
+		if val, ok := params["max-mru"]; ok {
+			if m, err := strconv.Atoi(val); err == nil && m > 0 {
+				cfg.MaxMRU = m
+			}
+		}
+
+		if err := mgr.SetPPPoEServer(cfg); err != nil {
+			return ColorRed + "Error applying PPPoE server: " + err.Error() + "\n" + ColorReset
+		}
+		return ColorGreen + "PPPoE server configuration updated successfully.\n" + ColorReset
+
+	default:
+		return ColorRed + fmt.Sprintf("Unknown action %q. Usage: /interface pppoe-server server [print|set enabled=yes|no interface=... default-profile=...]\n", action) + ColorReset
+	}
+}

+ 72 - 0
internal/cli/ppp_test.go

@@ -0,0 +1,72 @@
+package cli
+
+import (
+	"strings"
+	"testing"
+
+	"debtik/internal/auth"
+	"debtik/internal/vpn"
+)
+
+func TestPPPCLICommands(t *testing.T) {
+	vpnMgr := vpn.NewManager()
+	adminUser := &auth.PublicUser{Username: "admin", Role: auth.RoleFull}
+	executor := &Executor{vpnMgr: vpnMgr}
+
+	// 1. /ppp profile add
+	out, _ := executor.Execute("/ppp profile add name=test-p2p local-address=192.168.133.1 remote-address=192.168.133.2-192.168.133.254", adminUser)
+	if !strings.Contains(out, "created successfully") {
+		t.Fatalf("unexpected profile add output: %s", out)
+	}
+
+	// 2. /ppp profile print
+	out, _ = executor.Execute("/ppp profile print", adminUser)
+	if !strings.Contains(out, "test-p2p") || !strings.Contains(out, "192.168.133.1") {
+		t.Fatalf("unexpected profile print output: %s", out)
+	}
+
+	// 3. /ppp profile set
+	out, _ = executor.Execute("/ppp profile set test-p2p remote-address=192.168.133.50", adminUser)
+	if !strings.Contains(out, "updated successfully") {
+		t.Fatalf("unexpected profile set output: %s", out)
+	}
+
+	// 4. /ppp secret add
+	out, _ = executor.Execute("/ppp secret add name=test-user password=secretpass profile=test-p2p remote-address=192.168.133.55", adminUser)
+	if !strings.Contains(out, "created successfully") {
+		t.Fatalf("unexpected secret add output: %s", out)
+	}
+
+	// 5. /ppp secret print
+	out, _ = executor.Execute("/ppp secret print", adminUser)
+	if !strings.Contains(out, "test-user") || !strings.Contains(out, "192.168.133.55") {
+		t.Fatalf("unexpected secret print output: %s", out)
+	}
+
+	// 6. /ppp secret disable & enable
+	out, _ = executor.Execute("/ppp secret disable test-user", adminUser)
+	if !strings.Contains(out, "disabled") {
+		t.Fatalf("unexpected secret disable output: %s", out)
+	}
+	out, _ = executor.Execute("/ppp secret enable test-user", adminUser)
+	if !strings.Contains(out, "enabled") {
+		t.Fatalf("unexpected secret enable output: %s", out)
+	}
+
+	// 7. /interface l2tp-server server print and set
+	out, _ = executor.Execute("/interface l2tp-server server print", adminUser)
+	if !strings.Contains(out, "ENABLED") || !strings.Contains(out, "PORT") {
+		t.Fatalf("unexpected l2tp server print: %s", out)
+	}
+
+	out, _ = executor.Execute("/interface l2tp-server server set enabled=yes default-profile=test-p2p", adminUser)
+	if !strings.Contains(out, "updated successfully") {
+		t.Fatalf("unexpected l2tp server set: %s", out)
+	}
+
+	// 8. /ppp active print
+	out, _ = executor.Execute("/ppp active print", adminUser)
+	if !strings.Contains(out, "INTERFACE") {
+		t.Fatalf("unexpected active print output: %s", out)
+	}
+}

+ 137 - 8
internal/vpn/ppp_server.go

@@ -280,7 +280,25 @@ func (m *PPPServerManager) SaveProfile(p *PPPProfile) error {
 	for _, pr := range m.profiles {
 		list = append(list, pr)
 	}
-	return m.save("ppp_profiles.json", list)
+	if err := m.save("ppp_profiles.json", list); err != nil {
+		return err
+	}
+
+	// Re-synchronize secrets to system since secrets may inherit IP addresses from this profile
+	_ = m.syncSecretsToSystemLocked()
+
+	// Re-apply servers if their configured DefaultProfile was updated
+	if m.l2tpServer.Enabled && m.l2tpServer.DefaultProfile == p.Name {
+		_ = m.applyL2TPServer()
+	}
+	if m.pptpServer.Enabled && m.pptpServer.DefaultProfile == p.Name {
+		_ = m.applyPPTPServer()
+	}
+	if m.pppoeServer.Enabled && m.pppoeServer.DefaultProfile == p.Name {
+		_ = m.applyPPPoEServer()
+	}
+
+	return nil
 }
 
 func (m *PPPServerManager) DeleteProfile(name string) error {
@@ -296,7 +314,10 @@ func (m *PPPServerManager) DeleteProfile(name string) error {
 	for _, pr := range m.profiles {
 		list = append(list, pr)
 	}
-	return m.save("ppp_profiles.json", list)
+	if err := m.save("ppp_profiles.json", list); err != nil {
+		return err
+	}
+	return m.syncSecretsToSystemLocked()
 }
 
 // ======================== PPP Secrets CRUD ========================
@@ -340,7 +361,7 @@ func (m *PPPServerManager) SaveSecret(s *PPPSecret) error {
 		return err
 	}
 
-	return m.SyncSecretsToSystem()
+	return m.syncSecretsToSystemLocked()
 }
 
 func (m *PPPServerManager) SetSecretState(id string, disabled bool) error {
@@ -358,7 +379,7 @@ func (m *PPPServerManager) SetSecretState(id string, disabled bool) error {
 		list = append(list, sc)
 	}
 	_ = m.save("ppp_secrets.json", list)
-	return m.SyncSecretsToSystem()
+	return m.syncSecretsToSystemLocked()
 }
 
 func (m *PPPServerManager) DeleteSecret(id string) error {
@@ -371,16 +392,80 @@ func (m *PPPServerManager) DeleteSecret(id string) error {
 		list = append(list, sc)
 	}
 	_ = m.save("ppp_secrets.json", list)
-	return m.SyncSecretsToSystem()
+	return m.syncSecretsToSystemLocked()
+}
+
+// Helper IP math utilities for dynamic pool allocation per profile
+func parseIPRange(r string) (net.IP, net.IP, error) {
+	r = strings.TrimSpace(r)
+	parts := strings.Split(r, "-")
+	if len(parts) != 2 {
+		return nil, nil, fmt.Errorf("invalid range format")
+	}
+	startIP := net.ParseIP(strings.TrimSpace(parts[0])).To4()
+	endIP := net.ParseIP(strings.TrimSpace(parts[1])).To4()
+	if startIP == nil || endIP == nil {
+		return nil, nil, fmt.Errorf("invalid IP in range")
+	}
+	return startIP, endIP, nil
+}
+
+func firstAvailableIPFromRange(r string, used map[string]bool) string {
+	startIP, endIP, err := parseIPRange(r)
+	if err != nil {
+		return ""
+	}
+	startInt := ipToUint32(startIP)
+	endInt := ipToUint32(endIP)
+	if startInt > endInt {
+		startInt, endInt = endInt, startInt
+	}
+	for cur := startInt; cur <= endInt; cur++ {
+		ipStr := uint32ToIP(cur).String()
+		if !used[ipStr] {
+			return ipStr
+		}
+	}
+	return startIP.String()
+}
+
+func ipToUint32(ip net.IP) uint32 {
+	return uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(ip[2])<<8 | uint32(ip[3])
+}
+
+func uint32ToIP(n uint32) net.IP {
+	return net.IPv4(byte(n>>24), byte(n>>16), byte(n>>8), byte(n))
 }
 
 // SyncSecretsToSystem synchronizes all enabled secrets into /etc/ppp/chap-secrets and /etc/ppp/pap-secrets
 func (m *PPPServerManager) SyncSecretsToSystem() error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	return m.syncSecretsToSystemLocked()
+}
+
+func (m *PPPServerManager) syncSecretsToSystemLocked() error {
 	var lines []string
 	lines = append(lines, "# Autogenerated by DebTik - DO NOT EDIT MANUALLY")
 	lines = append(lines, "# client\tserver\tsecret\tIP addresses")
 
+	// Track allocated IPs to avoid assigning duplicate static IPs from pools
+	allocatedIPs := make(map[string]bool)
+	for _, s := range m.secrets {
+		if !s.Disabled && s.RemoteAddress != "" && !strings.Contains(s.RemoteAddress, "-") {
+			allocatedIPs[strings.TrimSpace(s.RemoteAddress)] = true
+		}
+	}
+
+	var secretList []*PPPSecret
 	for _, s := range m.secrets {
+		secretList = append(secretList, s)
+	}
+	sort.Slice(secretList, func(i, j int) bool {
+		return secretList[i].Name < secretList[j].Name
+	})
+
+	for _, s := range secretList {
 		if s.Disabled {
 			continue
 		}
@@ -388,9 +473,40 @@ func (m *PPPServerManager) SyncSecretsToSystem() error {
 		// Setting this to "*" allows pppd to authenticate the user across L2TP, PPTP, and PPPoE daemons.
 		serviceName := "*"
 		ipAddr := "*"
+
 		if s.RemoteAddress != "" {
-			ipAddr = s.RemoteAddress
+			trimmed := strings.TrimSpace(s.RemoteAddress)
+			if strings.Contains(trimmed, "-") {
+				avail := firstAvailableIPFromRange(trimmed, allocatedIPs)
+				if avail != "" {
+					ipAddr = avail
+					allocatedIPs[avail] = true
+				}
+			} else {
+				ipAddr = trimmed
+			}
+		} else if s.Profile != "" {
+			if prof, ok := m.profiles[s.Profile]; ok && prof != nil && prof.RemoteAddress != "" {
+				trimmed := strings.TrimSpace(prof.RemoteAddress)
+				if !strings.Contains(trimmed, "-") {
+					// Single static IP defined in profile
+					ipAddr = trimmed
+					allocatedIPs[trimmed] = true
+				} else {
+					// Range defined in profile (e.g. 192.168.133.2-192.168.133.254)
+					avail := firstAvailableIPFromRange(trimmed, allocatedIPs)
+					if avail != "" {
+						ipAddr = avail
+						allocatedIPs[avail] = true
+					}
+				}
+			}
+		}
+
+		if ipAddr == "" {
+			ipAddr = "*"
 		}
+
 		lines = append(lines, fmt.Sprintf("\"%s\"\t%s\t\"%s\"\t%s", s.Name, serviceName, s.Password, ipAddr))
 	}
 
@@ -590,6 +706,7 @@ func (m *PPPServerManager) applyPPTPServer() error {
 	// Write /etc/pptpd.conf
 	pptpdConf := fmt.Sprintf(`option /etc/ppp/pptpd-options
 logwtmp
+delegate
 localip %s
 remoteip %s
 `, localIP, remotePool)
@@ -693,7 +810,11 @@ func (m *PPPServerManager) applyL2TPServer() error {
 
 	remotePool := "10.10.40.10-10.10.40.50"
 	if profile != nil && profile.RemoteAddress != "" {
-		remotePool = profile.RemoteAddress
+		remotePool = strings.TrimSpace(profile.RemoteAddress)
+	}
+	// xl2tpd requires range format A-B. If a single IP is provided, convert to IP-IP
+	if !strings.Contains(remotePool, "-") {
+		remotePool = fmt.Sprintf("%s-%s", remotePool, remotePool)
 	}
 
 	// Write /etc/xl2tpd/xl2tpd.conf with [lns default]
@@ -730,6 +851,8 @@ length bit = yes
 ms-dns %s
 ms-dns %s
 auth
+ipcp-accept-local
+ipcp-accept-remote
 mtu 1400
 mru 1400
 proxyarp
@@ -737,7 +860,13 @@ nodefaultroute
 `, dns1, dns2)
 	_ = os.WriteFile("/etc/ppp/options.xl2tpd.server", []byte(opts), 0644)
 
-	_ = exec.Command("systemctl", "restart", "xl2tpd").Run()
+	// Clean restart of xl2tpd to avoid stale PID locks
+	_ = exec.Command("systemctl", "stop", "xl2tpd").Run()
+	time.Sleep(100 * time.Millisecond)
+	_ = exec.Command("pkill", "-9", "-x", "xl2tpd").Run()
+	_ = os.Remove("/var/run/xl2tpd.pid")
+	_ = os.Remove("/run/xl2tpd.pid")
+	_ = exec.Command("systemctl", "start", "xl2tpd").Run()
 	time.Sleep(300 * time.Millisecond)
 
 	out, _ := exec.Command("systemctl", "is-active", "xl2tpd").Output()

+ 115 - 0
internal/vpn/ppp_server_test.go

@@ -0,0 +1,115 @@
+package vpn
+
+import (
+	"os"
+	"strings"
+	"testing"
+)
+
+func TestParseIPRangeAndFirstAvailable(t *testing.T) {
+	start, end, err := parseIPRange("192.168.133.2-192.168.133.254")
+	if err != nil {
+		t.Fatalf("unexpected error parsing range: %v", err)
+	}
+	if start.String() != "192.168.133.2" || end.String() != "192.168.133.254" {
+		t.Fatalf("unexpected parsed range: %s - %s", start, end)
+	}
+
+	used := map[string]bool{
+		"192.168.133.2": true,
+		"192.168.133.3": true,
+	}
+	first := firstAvailableIPFromRange("192.168.133.2-192.168.133.254", used)
+	if first != "192.168.133.4" {
+		t.Fatalf("expected 192.168.133.4, got %s", first)
+	}
+}
+
+func TestSyncSecretsInheritanceAndOverrides(t *testing.T) {
+	tmpDir, err := os.MkdirTemp("", "ppp-test-*")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer os.RemoveAll(tmpDir)
+
+	mgr := &PPPServerManager{
+		baseDir:  tmpDir,
+		profiles: make(map[string]*PPPProfile),
+		secrets:  make(map[string]*PPPSecret),
+	}
+	mgr.initDefaults()
+
+	// Add custom profile with range
+	mgr.profiles["p2p"] = &PPPProfile{
+		ID:            "p2p",
+		Name:          "p2p",
+		LocalAddress:  "192.168.133.1",
+		RemoteAddress: "192.168.133.2-192.168.133.254",
+	}
+
+	// Add profile with single static IP
+	mgr.profiles["static-prof"] = &PPPProfile{
+		ID:            "static-prof",
+		Name:          "static-prof",
+		LocalAddress:  "10.200.1.1",
+		RemoteAddress: "10.200.1.50",
+	}
+
+	// Secret 1: explicit override on secret
+	mgr.secrets["s1"] = &PPPSecret{
+		ID:            "s1",
+		Name:          "user_explicit",
+		Password:      "pass1",
+		Profile:       "p2p",
+		RemoteAddress: "192.168.133.99",
+	}
+
+	// Secret 2: inherits from range profile
+	mgr.secrets["s2"] = &PPPSecret{
+		ID:       "s2",
+		Name:     "user_range_inherit",
+		Password: "pass2",
+		Profile:  "p2p",
+	}
+
+	// Secret 3: inherits from single static IP profile
+	mgr.secrets["s3"] = &PPPSecret{
+		ID:       "s3",
+		Name:     "user_static_inherit",
+		Password: "pass3",
+		Profile:  "static-prof",
+	}
+
+	// Secret 4: default profile range inherit
+	mgr.secrets["s4"] = &PPPSecret{
+		ID:       "s4",
+		Name:     "user_default_inherit",
+		Password: "pass4",
+		Profile:  "default",
+	}
+
+	if err := mgr.syncSecretsToSystemLocked(); err != nil {
+		t.Fatalf("sync error: %v", err)
+	}
+
+	data, err := os.ReadFile("/etc/ppp/chap-secrets")
+	if err != nil {
+		t.Skip("skipping chap-secrets read if no root permission")
+	}
+	content := string(data)
+
+	// Verify user_explicit got 192.168.133.99
+	if !strings.Contains(content, "\"user_explicit\"\t*\t\"pass1\"\t192.168.133.99") {
+		t.Errorf("user_explicit expected 192.168.133.99 in chap-secrets, got:\n%s", content)
+	}
+
+	// Verify user_static_inherit got 10.200.1.50
+	if !strings.Contains(content, "\"user_static_inherit\"\t*\t\"pass3\"\t10.200.1.50") {
+		t.Errorf("user_static_inherit expected 10.200.1.50 in chap-secrets, got:\n%s", content)
+	}
+
+	// Verify user_range_inherit got first available from p2p (192.168.133.2)
+	if !strings.Contains(content, "\"user_range_inherit\"\t*\t\"pass2\"\t192.168.133.2") {
+		t.Errorf("user_range_inherit expected 192.168.133.2 in chap-secrets, got:\n%s", content)
+	}
+}