package vpn import ( "encoding/json" "fmt" "net" "os" "os/exec" "path/filepath" "strings" "sync" "time" "github.com/vishvananda/netlink" ) const ( PPPoEInstancesStorePath = "/etc/proxmox-router/vpn/pppoe_instances.json" ) type PPPoEManager struct { mu sync.Mutex instances []PPPoEInstance } func NewPPPoEManager() *PPPoEManager { m := &PPPoEManager{ instances: make([]PPPoEInstance, 0), } m.load() return m } func (m *PPPoEManager) load() { data, err := os.ReadFile(PPPoEInstancesStorePath) if err == nil { _ = json.Unmarshal(data, &m.instances) } if m.instances == nil { m.instances = make([]PPPoEInstance, 0) } } func (m *PPPoEManager) save() error { _ = os.MkdirAll(filepath.Dir(PPPoEInstancesStorePath), 0755) data, err := json.MarshalIndent(m.instances, "", " ") if err != nil { return err } return os.WriteFile(PPPoEInstancesStorePath, data, 0600) } func (m *PPPoEManager) List() []PPPoEInstance { m.mu.Lock() defer m.mu.Unlock() // Update running state from system m.refreshRuntimeStatus() res := make([]PPPoEInstance, len(m.instances)) copy(res, m.instances) return res } func (m *PPPoEManager) Get(name string) (*PPPoEInstance, error) { m.mu.Lock() defer m.mu.Unlock() for _, inst := range m.instances { if inst.Name == name { return &inst, nil } } return nil, fmt.Errorf("pppoe instance %q not found", name) } func (m *PPPoEManager) Save(inst PPPoEInstance) error { m.mu.Lock() defer m.mu.Unlock() if inst.Name == "" { return fmt.Errorf("instance name is required (e.g. pppoe-out1)") } if inst.Interface == "" { inst.Interface = "eth0" } if inst.MTU <= 0 { inst.MTU = 1492 } if inst.Profile == "" { inst.Profile = "default" } found := false for i, existing := range m.instances { if existing.Name == inst.Name { m.instances[i] = inst found = true break } } if !found { m.instances = append(m.instances, inst) } if err := m.save(); err != nil { return err } // Generate peer configuration file /etc/ppp/peers/ if err := m.generatePeerConfig(inst); err != nil { return err } // Auto-connect if enabled and not disabled if !inst.Disabled { return m.connectInstance(inst.Name) } else { return m.disconnectInstance(inst.Name) } } func (m *PPPoEManager) Delete(name string) error { m.mu.Lock() defer m.mu.Unlock() _ = m.disconnectInstance(name) _ = os.Remove(fmt.Sprintf("/etc/ppp/peers/%s", name)) updated := make([]PPPoEInstance, 0) for _, inst := range m.instances { if inst.Name != name { updated = append(updated, inst) } } m.instances = updated return m.save() } func (m *PPPoEManager) SetEnabled(name string, enabled bool) error { m.mu.Lock() defer m.mu.Unlock() for i, inst := range m.instances { if inst.Name == name { m.instances[i].Disabled = !enabled if err := m.save(); err != nil { return err } if enabled { return m.connectInstance(name) } else { return m.disconnectInstance(name) } } } return fmt.Errorf("instance %q not found", name) } func (m *PPPoEManager) Connect(name string) error { m.mu.Lock() defer m.mu.Unlock() return m.connectInstance(name) } func (m *PPPoEManager) Disconnect(name string) error { m.mu.Lock() defer m.mu.Unlock() return m.disconnectInstance(name) } func (m *PPPoEManager) connectInstance(name string) error { _ = exec.Command("poff", name).Run() time.Sleep(300 * time.Millisecond) cmd := exec.Command("pon", name) if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to dial pppoe %s: %s: %w", name, string(out), err) } return nil } func (m *PPPoEManager) disconnectInstance(name string) error { cmd := exec.Command("poff", name) _ = cmd.Run() return nil } func (m *PPPoEManager) generatePeerConfig(inst PPPoEInstance) error { _ = os.MkdirAll("/etc/ppp/peers", 0755) var sb strings.Builder sb.WriteString(fmt.Sprintf("# MikroTik-Style PPPoE Client: %s\n", inst.Name)) sb.WriteString(fmt.Sprintf("plugin rp-pppoe.so %s\n", inst.Interface)) sb.WriteString(fmt.Sprintf("user %q\n", inst.Username)) sb.WriteString("noauth\n") sb.WriteString("hide-password\n") if inst.DefaultRoute { sb.WriteString("defaultroute\n") sb.WriteString("replacedefaultroute\n") } if inst.AutoReconnect { sb.WriteString("persist\n") sb.WriteString("maxfail 0\n") } sb.WriteString(fmt.Sprintf("mtu %d\n", inst.MTU)) sb.WriteString(fmt.Sprintf("mru %d\n", inst.MTU)) sb.WriteString("usepeerdns\n") sb.WriteString("noipdefault\n") sb.WriteString(fmt.Sprintf("ipparam %q\n", inst.Name)) if inst.ServiceName != "" { sb.WriteString(fmt.Sprintf("rp_pppoe_service %q\n", inst.ServiceName)) } peerPath := fmt.Sprintf("/etc/ppp/peers/%s", inst.Name) if err := os.WriteFile(peerPath, []byte(sb.String()), 0600); err != nil { return err } // Update credentials if inst.Username != "" && inst.Password != "" { updateSecrets(PapSecretsPath, inst.Username, inst.Password) updateSecrets(ChapSecretsPath, inst.Username, inst.Password) } return nil } func (m *PPPoEManager) refreshRuntimeStatus() { psOut, _ := exec.Command("ps", "-ef").Output() psStr := string(psOut) links, _ := netlink.LinkList() linkMap := make(map[string]netlink.Link) for _, l := range links { linkMap[l.Attrs().Name] = l } for i := range m.instances { name := m.instances[i].Name if strings.Contains(psStr, name) { m.instances[i].Running = true } else { m.instances[i].Running = false } // Check for assigned IP on any ppp link m.instances[i].AssignedIP = "" m.instances[i].PPPDevice = "" for _, l := range links { if strings.HasPrefix(l.Attrs().Name, "ppp") && l.Attrs().Flags&net.FlagUp != 0 { addrs, _ := netlink.AddrList(l, netlink.FAMILY_V4) if len(addrs) > 0 { m.instances[i].AssignedIP = addrs[0].IPNet.String() m.instances[i].PPPDevice = l.Attrs().Name m.instances[i].Running = true break } } } } } func (m *PPPoEManager) RenameInterface(oldName, newName string) { m.mu.Lock() defer m.mu.Unlock() changed := false for i := range m.instances { if m.instances[i].Interface == oldName { m.instances[i].Interface = newName changed = true } } if changed { if err := m.save(); err != nil { return } } } func updateSecrets(path, user, pass string) { content, _ := os.ReadFile(path) str := string(content) userCheck := fmt.Sprintf("%q *", user) if !strings.Contains(str, userCheck) { line := fmt.Sprintf("\n%q * %q *\n", user, pass) _ = os.WriteFile(path, []byte(str+line), 0600) } }