package vpn import ( "encoding/json" "fmt" "net" "os" "os/exec" "path/filepath" "strings" "sync" "time" "github.com/vishvananda/netlink" ) const ( PPTPInstancesStorePath = "/etc/proxmox-router/vpn/pptp_instances.json" ) type PPTPManager struct { mu sync.Mutex instances []PPTPInstance } func NewPPTPManager() *PPTPManager { m := &PPTPManager{ instances: make([]PPTPInstance, 0), } m.load() return m } func (m *PPTPManager) load() { data, err := os.ReadFile(PPTPInstancesStorePath) if err == nil { _ = json.Unmarshal(data, &m.instances) } if m.instances == nil { m.instances = make([]PPTPInstance, 0) } } func (m *PPTPManager) save() error { _ = os.MkdirAll(filepath.Dir(PPTPInstancesStorePath), 0755) data, err := json.MarshalIndent(m.instances, "", " ") if err != nil { return err } return os.WriteFile(PPTPInstancesStorePath, data, 0600) } func (m *PPTPManager) List() []PPTPInstance { m.mu.Lock() defer m.mu.Unlock() m.refreshRuntimeStatus() res := make([]PPTPInstance, len(m.instances)) copy(res, m.instances) return res } func (m *PPTPManager) Save(inst PPTPInstance) error { m.mu.Lock() defer m.mu.Unlock() if inst.Name == "" { return fmt.Errorf("instance name is required (e.g. pptp-out1)") } if inst.ConnectTo == "" { return fmt.Errorf("server address (connect-to) is required") } 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 } if err := m.generatePeerConfig(inst); err != nil { return err } if !inst.Disabled { go m.connectInstance(inst.Name) } else { go m.disconnectInstance(inst.Name) } return nil } func (m *PPTPManager) 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([]PPTPInstance, 0) for _, inst := range m.instances { if inst.Name != name { updated = append(updated, inst) } } m.instances = updated return m.save() } func (m *PPTPManager) 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 _ = m.save() if enabled { go m.connectInstance(name) } else { go m.disconnectInstance(name) } return nil } } return fmt.Errorf("instance %q not found", name) } func (m *PPTPManager) Connect(name string) error { m.mu.Lock() defer m.mu.Unlock() return m.connectInstance(name) } func (m *PPTPManager) Disconnect(name string) error { m.mu.Lock() defer m.mu.Unlock() return m.disconnectInstance(name) } func (m *PPTPManager) 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 start PPTP %s: %s: %w", name, string(out), err) } return nil } func (m *PPTPManager) disconnectInstance(name string) error { cmd := exec.Command("poff", name) _ = cmd.Run() return nil } func (m *PPTPManager) generatePeerConfig(inst PPTPInstance) error { _ = os.MkdirAll("/etc/ppp/peers", 0755) var sb strings.Builder sb.WriteString(fmt.Sprintf("# MikroTik-Style PPTP Client: %s\n", inst.Name)) sb.WriteString(fmt.Sprintf("pty \"pptp %s --nolaunchpppd\"\n", inst.ConnectTo)) sb.WriteString(fmt.Sprintf("name %q\n", inst.Username)) sb.WriteString("remotename PPTP\n") if inst.RequireMPPE { sb.WriteString("require-mppe-128\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("file /etc/ppp/options.pptp\n") sb.WriteString(fmt.Sprintf("ipparam %q\n", inst.Name)) peerPath := fmt.Sprintf("/etc/ppp/peers/%s", inst.Name) if err := os.WriteFile(peerPath, []byte(sb.String()), 0600); err != nil { return err } if inst.Username != "" && inst.Password != "" { line := fmt.Sprintf("%q PPTP %q *\n", inst.Username, inst.Password) content, _ := os.ReadFile(ChapSecretsPath) str := string(content) if !strings.Contains(str, fmt.Sprintf("%q PPTP", inst.Username)) { str += "\n" + line _ = os.WriteFile(ChapSecretsPath, []byte(str), 0600) } } return nil } func (m *PPTPManager) refreshRuntimeStatus() { psOut, _ := exec.Command("ps", "-ef").Output() psStr := string(psOut) links, _ := netlink.LinkList() 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 } 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 } } } } }