package vpn import ( "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strings" "sync" ) const ( SwanctlConfPath = "/etc/swanctl/conf.d/tunnels.conf" IPSecStorePath = "/etc/proxmox-router/vpn/ipsec.json" ) type IPSecManager struct { mu sync.Mutex config IPSecConfig } func NewIPSecManager() *IPSecManager { m := &IPSecManager{ config: IPSecConfig{ Connections: make([]IPSecConnection, 0), }, } m.load() return m } func (m *IPSecManager) load() { data, err := os.ReadFile(IPSecStorePath) if err == nil { _ = json.Unmarshal(data, &m.config) if m.config.Connections == nil { m.config.Connections = make([]IPSecConnection, 0) } } } func (m *IPSecManager) save() error { _ = os.MkdirAll(filepath.Dir(IPSecStorePath), 0755) data, err := json.MarshalIndent(m.config, "", " ") if err != nil { return err } return os.WriteFile(IPSecStorePath, data, 0600) } func (m *IPSecManager) GetConfig() IPSecConfig { m.mu.Lock() defer m.mu.Unlock() return m.config } func (m *IPSecManager) SetConfig(cfg IPSecConfig) error { m.mu.Lock() defer m.mu.Unlock() if cfg.Connections == nil { cfg.Connections = make([]IPSecConnection, 0) } m.config = cfg if err := m.save(); err != nil { return err } return m.generateSwanctlConfig() } func (m *IPSecManager) SaveConnection(conn IPSecConnection) error { m.mu.Lock() defer m.mu.Unlock() if conn.Name == "" { return fmt.Errorf("connection name is required") } if conn.IKEProposals == "" { conn.IKEProposals = "aes256-sha256-modp2048,default" } if conn.ESPProposals == "" { conn.ESPProposals = "aes256-sha256-modp2048,default" } if conn.LocalAddrs == "" { conn.LocalAddrs = "%any" } found := false for i, c := range m.config.Connections { if c.Name == conn.Name { m.config.Connections[i] = conn found = true break } } if !found { m.config.Connections = append(m.config.Connections, conn) } if err := m.save(); err != nil { return err } return m.generateSwanctlConfig() } func (m *IPSecManager) DeleteConnection(name string) error { m.mu.Lock() defer m.mu.Unlock() var updated []IPSecConnection for _, c := range m.config.Connections { if c.Name != name { updated = append(updated, c) } } m.config.Connections = updated if err := m.save(); err != nil { return err } return m.generateSwanctlConfig() } func (m *IPSecManager) generateSwanctlConfig() error { _ = os.MkdirAll("/etc/swanctl/conf.d", 0755) var sb strings.Builder sb.WriteString("# Generated by Proxmox RouterOS - strongSwan IPSec Configuration\n\n") sb.WriteString("connections {\n") for _, c := range m.config.Connections { sb.WriteString(fmt.Sprintf(" %s {\n", c.Name)) sb.WriteString(fmt.Sprintf(" local_addrs = %s\n", c.LocalAddrs)) sb.WriteString(fmt.Sprintf(" remote_addrs = %s\n", c.RemoteAddrs)) sb.WriteString(" local {\n") sb.WriteString(" auth = psk\n") sb.WriteString(" }\n") sb.WriteString(" remote {\n") sb.WriteString(" auth = psk\n") sb.WriteString(" }\n") sb.WriteString(" children {\n") sb.WriteString(fmt.Sprintf(" %s {\n", c.Name)) if c.LocalSubnet != "" { sb.WriteString(fmt.Sprintf(" local_ts = %s\n", c.LocalSubnet)) } if c.RemoteSubnet != "" { sb.WriteString(fmt.Sprintf(" remote_ts = %s\n", c.RemoteSubnet)) } sb.WriteString(fmt.Sprintf(" esp_proposals = %s\n", c.ESPProposals)) if c.AutoStart { sb.WriteString(" start_action = start\n") } sb.WriteString(" }\n") sb.WriteString(" }\n") sb.WriteString(fmt.Sprintf(" proposals = %s\n", c.IKEProposals)) sb.WriteString(" }\n\n") } sb.WriteString("}\n\n") // Secrets sb.WriteString("secrets {\n") for _, c := range m.config.Connections { if c.PresharedKey != "" { sb.WriteString(fmt.Sprintf(" ike-%s {\n", c.Name)) sb.WriteString(fmt.Sprintf(" id = %s\n", c.RemoteAddrs)) sb.WriteString(fmt.Sprintf(" secret = %q\n", c.PresharedKey)) sb.WriteString(" }\n") } } sb.WriteString("}\n") return os.WriteFile(SwanctlConfPath, []byte(sb.String()), 0600) } func (m *IPSecManager) StartTunnel(name string) error { m.mu.Lock() defer m.mu.Unlock() _ = exec.Command("systemctl", "start", "strongswan").Run() _ = exec.Command("swanctl", "--load-all").Run() cmd := exec.Command("swanctl", "--initiate", "--child", name) if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to initiate IPSec tunnel %s: %s: %w", name, string(out), err) } return nil } func (m *IPSecManager) SetEnabled(name string, enabled bool) error { m.mu.Lock() defer m.mu.Unlock() for i, c := range m.config.Connections { if c.Name == name { m.config.Connections[i].Disabled = !enabled _ = m.save() if enabled { go m.StartTunnel(name) } else { go m.StopTunnel(name) } return nil } } return fmt.Errorf("connection %q not found", name) } func (m *IPSecManager) List() []IPSecConnection { st := m.GetStatus() return st.Connections } func (m *IPSecManager) StopTunnel(name string) error { m.mu.Lock() defer m.mu.Unlock() cmd := exec.Command("swanctl", "--terminate", "--child", name) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to terminate tunnel %s: %s: %w", name, string(out), err) } return nil } func (m *IPSecManager) GetStatus() IPSecStatus { m.mu.Lock() defer m.mu.Unlock() out, err := exec.Command("swanctl", "--list-sas").CombinedOutput() rawOutput := string(out) running := err == nil activeSAs := 0 conns := make([]IPSecConnection, len(m.config.Connections)) copy(conns, m.config.Connections) for i := range conns { if strings.Contains(rawOutput, conns[i].Name) && strings.Contains(rawOutput, "INSTALLED") { conns[i].Status = "ESTABLISHED" conns[i].Running = true activeSAs++ } else { conns[i].Status = "DISCONNECTED" conns[i].Running = false } } return IPSecStatus{ Running: running, ActiveSAs: activeSAs, Connections: conns, RawStatus: rawOutput, } }