package vpn import ( "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strings" "sync" ) const ( SwanctlConfPath = "/etc/swanctl/conf.d/tunnels.conf" IPSecStorePath = "/etc/debtik/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.IKEVersion == "" { conn.IKEVersion = "ikev2" } if conn.IKEProposals == "" { conn.IKEProposals = "aes256-sha256-modp2048,default" } if conn.ESPProposals == "" { conn.ESPProposals = "aes256-sha256-modp2048,default" } if conn.LocalAddrs == "" { conn.LocalAddrs = "%any" } if conn.IKEKeylife <= 0 { conn.IKEKeylife = 86400 } if conn.ESPKeylife <= 0 { conn.ESPKeylife = 28800 } if conn.Phase2Name == "" { conn.Phase2Name = conn.Name + "_p2" } // Normalize subnets if len(conn.LocalSubnets) == 0 && conn.LocalSubnet != "" { conn.LocalSubnets = []string{conn.LocalSubnet} } if len(conn.LocalSubnets) > 0 { conn.LocalSubnet = conn.LocalSubnets[0] } if len(conn.RemoteSubnets) == 0 && conn.RemoteSubnet != "" { conn.RemoteSubnets = []string{conn.RemoteSubnet} } if len(conn.RemoteSubnets) > 0 { conn.RemoteSubnet = conn.RemoteSubnets[0] } 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 DebTik - strongSwan IPSec Configuration (FortiGate Phase 1 & 2 Engine)\n\n") sb.WriteString("connections {\n") for _, c := range m.config.Connections { if c.Disabled { continue } connName := sanitizeConnName(c.Name) sb.WriteString(fmt.Sprintf(" %s {\n", connName)) // IKE Version if strings.EqualFold(c.IKEVersion, "ikev1") { sb.WriteString(" version = 1\n") } else { sb.WriteString(" version = 2\n") } localAddrs := strings.TrimSpace(c.LocalAddrs) if localAddrs == "" { localAddrs = "%any" } sb.WriteString(fmt.Sprintf(" local_addrs = %s\n", localAddrs)) sb.WriteString(fmt.Sprintf(" remote_addrs = %s\n", strings.TrimSpace(c.RemoteAddrs))) // Local auth sb.WriteString(" local {\n") sb.WriteString(" auth = psk\n") if strings.TrimSpace(c.LocalID) != "" { sb.WriteString(fmt.Sprintf(" id = %s\n", strings.TrimSpace(c.LocalID))) } sb.WriteString(" }\n") // Remote auth sb.WriteString(" remote {\n") sb.WriteString(" auth = psk\n") if strings.TrimSpace(c.RemoteID) != "" { sb.WriteString(fmt.Sprintf(" id = %s\n", strings.TrimSpace(c.RemoteID))) } sb.WriteString(" }\n") // DPD dpd := strings.ToLower(strings.TrimSpace(c.DPD)) if dpd == "disabled" { // DPD disabled } else if dpd == "periodic" { sb.WriteString(" dpd_delay = 20s\n") sb.WriteString(" dpd_timeout = 60s\n") } else { // default on-demand sb.WriteString(" dpd_delay = 30s\n") sb.WriteString(" dpd_timeout = 120s\n") } // IKE Proposals & Rekey ikeProps := strings.TrimSpace(c.IKEProposals) if ikeProps == "" { ikeProps = "aes256-sha256-modp2048,aes128-sha256-modp2048,default" } sb.WriteString(fmt.Sprintf(" proposals = %s\n", ikeProps)) ikeKeylife := 86400 if c.IKEKeylife > 0 { ikeKeylife = c.IKEKeylife } sb.WriteString(fmt.Sprintf(" rekey_time = %ds\n", ikeKeylife)) // Children (Phase 2) sb.WriteString(" children {\n") p2Name := strings.TrimSpace(c.Phase2Name) if p2Name == "" { p2Name = connName + "_p2" } else { p2Name = sanitizeConnName(p2Name) } sb.WriteString(fmt.Sprintf(" %s {\n", p2Name)) // Traffic Selectors: Collect all local and remote subnets! var localNets []string for _, s := range c.LocalSubnets { s = strings.TrimSpace(s) if s != "" { localNets = append(localNets, s) } } if len(localNets) == 0 && strings.TrimSpace(c.LocalSubnet) != "" { localNets = append(localNets, strings.TrimSpace(c.LocalSubnet)) } if len(localNets) == 0 { localNets = []string{"0.0.0.0/0"} } var remoteNets []string for _, s := range c.RemoteSubnets { s = strings.TrimSpace(s) if s != "" { remoteNets = append(remoteNets, s) } } if len(remoteNets) == 0 && strings.TrimSpace(c.RemoteSubnet) != "" { remoteNets = append(remoteNets, strings.TrimSpace(c.RemoteSubnet)) } if len(remoteNets) == 0 { remoteNets = []string{"0.0.0.0/0"} } sb.WriteString(fmt.Sprintf(" local_ts = %s\n", strings.Join(localNets, ","))) sb.WriteString(fmt.Sprintf(" remote_ts = %s\n", strings.Join(remoteNets, ","))) // ESP Proposals espProps := strings.TrimSpace(c.ESPProposals) if espProps == "" { espProps = "aes256-sha256-modp2048,aes128-sha256,default" } sb.WriteString(fmt.Sprintf(" esp_proposals = %s\n", espProps)) espKeylife := 28800 if c.ESPKeylife > 0 { espKeylife = c.ESPKeylife } sb.WriteString(fmt.Sprintf(" rekey_time = %ds\n", espKeylife)) if c.AutoStart { sb.WriteString(" start_action = start\n") sb.WriteString(" dpd_action = restart\n") } else { sb.WriteString(" start_action = none\n") } sb.WriteString(" }\n") sb.WriteString(" }\n") sb.WriteString(" }\n\n") } sb.WriteString("}\n\n") // Secrets sb.WriteString("secrets {\n") for _, c := range m.config.Connections { if c.Disabled { continue } if c.PresharedKey != "" { connName := sanitizeConnName(c.Name) remoteAddrs := strings.TrimSpace(c.RemoteAddrs) sb.WriteString(fmt.Sprintf(" ike-%s {\n", connName)) if remoteAddrs != "" && remoteAddrs != "%any" { sb.WriteString(fmt.Sprintf(" id = %s\n", remoteAddrs)) } sb.WriteString(fmt.Sprintf(" secret = %q\n", c.PresharedKey)) sb.WriteString(" }\n") } } sb.WriteString("}\n") if err := os.WriteFile(SwanctlConfPath, []byte(sb.String()), 0600); err != nil { return err } if err := exec.Command("systemctl", "start", "strongswan").Run(); err != nil { return err } if out, err := exec.Command("swanctl", "--load-all").CombinedOutput(); err != nil { return fmt.Errorf("swanctl load failed: %s: %w", out, err) } return nil } func sanitizeConnName(raw string) string { raw = strings.TrimSpace(raw) var b strings.Builder for _, r := range raw { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { b.WriteRune(r) } else { b.WriteRune('_') } } res := b.String() if res == "" { res = "tunnel1" } return res } func (m *IPSecManager) StartTunnel(name string) error { m.mu.Lock() defer m.mu.Unlock() return m.startTunnelLocked(name) } func (m *IPSecManager) startTunnelLocked(name string) error { if err := exec.Command("systemctl", "start", "strongswan").Run(); err != nil { return err } if out, err := exec.Command("swanctl", "--load-all").CombinedOutput(); err != nil { return fmt.Errorf("swanctl load failed: %s: %w", out, err) } var p2Name string for _, c := range m.config.Connections { if c.Name == name { p2Name = c.Phase2Name break } } if p2Name == "" { p2Name = name + "_p2" } cmd := exec.Command("swanctl", "--initiate", "--child", p2Name) if out, err := cmd.CombinedOutput(); err != nil { cmd2 := exec.Command("swanctl", "--initiate", "--child", name) if out2, err2 := cmd2.CombinedOutput(); err2 != nil { return fmt.Errorf("failed to initiate IPSec tunnel %s: %s / %s", name, string(out), string(out2)) } } return nil } func (m *IPSecManager) StopTunnel(name string) error { m.mu.Lock() defer m.mu.Unlock() return m.stopTunnelLocked(name) } func (m *IPSecManager) stopTunnelLocked(name string) error { var p2Name string for _, c := range m.config.Connections { if c.Name == name { p2Name = c.Phase2Name break } } if p2Name == "" { p2Name = name + "_p2" } _ = exec.Command("swanctl", "--terminate", "--child", p2Name).Run() _ = exec.Command("swanctl", "--terminate", "--child", name).Run() _ = exec.Command("swanctl", "--terminate", "--ike", name).Run() 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 if err := m.save(); err != nil { return err } if enabled { return m.startTunnelLocked(name) } else { return m.stopTunnelLocked(name) } } } return fmt.Errorf("connection %q not found", name) } func (m *IPSecManager) List() []IPSecConnection { st := m.GetStatus() return st.Connections } 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, } }