package wireguard import ( "bufio" "context" "errors" "fmt" "io/fs" "os" "path/filepath" "strconv" "strings" "git.netcell-it.de/projekte/edgeguard-native/internal/models" "git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets" ) // ImportResult summarises what an Import call did so the CLI can // report it back to the operator. type ImportResult struct { IfacesAdded int `json:"ifaces_added"` PeersAdded int `json:"peers_added"` Skipped []string `json:"skipped,omitempty"` // ifaces already present, with reason } // Importer takes existing /etc/wireguard/*.conf files and translates // them into wireguard_interfaces + wireguard_peers rows so the // operator can keep their pre-EdgeGuard tunnels live across the // migration. Heuristics: // // - Iface name = filename without .conf (so wg0.conf → wg0) // - One [Interface] block becomes the wireguard_interfaces row // - [Peer] blocks with Endpoint+ no AllowedIPs/0.0.0.0/0 → mode=client // (rare — a wg conf usually has many peers in server mode) // - >1 [Peer] block → mode=server, peers go to the peer roster // - Single [Peer] with Endpoint set → mode=client (the peer is the // upstream we dial) // // Existing iface names in the DB are skipped (idempotent re-run). type Importer struct { Ifaces *InterfacesRepo Peers *PeersRepo Box *secrets.Box } func NewImporter(ifaces *InterfacesRepo, peers *PeersRepo, box *secrets.Box) *Importer { return &Importer{Ifaces: ifaces, Peers: peers, Box: box} } func (im *Importer) ImportDir(ctx context.Context, dir string) (*ImportResult, error) { res := &ImportResult{} entries, err := os.ReadDir(dir) if err != nil { if errors.Is(err, fs.ErrNotExist) { return res, nil } return nil, err } for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".conf") { continue } path := filepath.Join(dir, e.Name()) ifaceName := strings.TrimSuffix(e.Name(), ".conf") // Skip names that violate our regex — operator can rename // the file and re-run. if !validIfaceName(ifaceName) { res.Skipped = append(res.Skipped, ifaceName+" (invalid name)") continue } if err := im.importFile(ctx, ifaceName, path, res); err != nil { res.Skipped = append(res.Skipped, ifaceName+": "+err.Error()) } } return res, nil } func (im *Importer) importFile(ctx context.Context, ifaceName, path string, res *ImportResult) error { // Skip if already present. all, err := im.Ifaces.List(ctx) if err != nil { return err } for _, x := range all { if x.Name == ifaceName { res.Skipped = append(res.Skipped, ifaceName+" (already in DB)") return nil } } parsed, err := parseWGConf(path) if err != nil { return err } if parsed.PrivateKey == "" { return errors.New("no PrivateKey in [Interface]") } if parsed.Address == "" { return errors.New("no Address in [Interface]") } // Mode heuristic: an [Interface] without ListenPort plus a single // [Peer] with Endpoint set is a client tunnel. Anything else // (multiple peers, ListenPort set, peers without Endpoint) we // treat as server. mode := "server" if parsed.ListenPort == 0 && len(parsed.Peers) == 1 && parsed.Peers[0].Endpoint != "" { mode = "client" } pub, err := PublicFromPrivate(parsed.PrivateKey) if err != nil { return fmt.Errorf("derive pubkey: %w", err) } encPriv, err := im.Box.Seal([]byte(parsed.PrivateKey)) if err != nil { return fmt.Errorf("seal: %w", err) } ifc := models.WireguardInterface{ Name: ifaceName, Mode: mode, AddressCIDR: parsed.Address, PublicKey: pub, PrivateKeyEnc: encPriv, Role: "wan", Active: true, MTU: intPtrIfNonzero(parsed.MTU), } if mode == "server" { port := parsed.ListenPort if port == 0 { port = 51820 } ifc.ListenPort = &port } else { // client mode → fold the single peer into the iface row. p := parsed.Peers[0] ep := p.Endpoint pk := p.PublicKey ifc.PeerEndpoint = &ep ifc.PeerPublicKey = &pk if p.AllowedIPs != "" { ai := p.AllowedIPs ifc.AllowedIPs = &ai } if p.Keepalive > 0 { k := p.Keepalive ifc.PersistentKeepalive = &k } if p.PSK != "" { pskEnc, err := im.Box.Seal([]byte(p.PSK)) if err != nil { return fmt.Errorf("seal psk: %w", err) } ifc.PeerPSKEnc = pskEnc } } created, err := im.Ifaces.Create(ctx, ifc) if err != nil { return fmt.Errorf("insert iface: %w", err) } res.IfacesAdded++ if mode == "server" { for i, p := range parsed.Peers { if p.PublicKey == "" { res.Skipped = append(res.Skipped, fmt.Sprintf("%s peer #%d (no PublicKey)", ifaceName, i)) continue } peer := models.WireguardPeer{ InterfaceID: created.ID, Name: p.Name, PublicKey: p.PublicKey, AllowedIPs: p.AllowedIPs, Enabled: true, } if peer.Name == "" { peer.Name = fmt.Sprintf("imported-%d", i+1) } if peer.AllowedIPs == "" { peer.AllowedIPs = "0.0.0.0/0" } if p.Keepalive > 0 { k := p.Keepalive peer.Keepalive = &k } if p.PSK != "" { pskEnc, err := im.Box.Seal([]byte(p.PSK)) if err != nil { return fmt.Errorf("seal peer psk: %w", err) } peer.PSKEnc = pskEnc } if _, err := im.Peers.Create(ctx, peer); err != nil { res.Skipped = append(res.Skipped, fmt.Sprintf("%s peer %s: %v", ifaceName, peer.Name, err)) continue } res.PeersAdded++ } } return nil } // parsedConf is the intermediate shape of a parsed wg-quick file. type parsedConf struct { Address string ListenPort int PrivateKey string MTU int Peers []parsedPeer } type parsedPeer struct { Name string PublicKey string Endpoint string AllowedIPs string Keepalive int PSK string } // parseWGConf is intentionally lenient — it accepts anything wg-quick // would accept, ignores PostUp/PostDown/Table/SaveConfig since // edgeguard owns runtime, and collapses comment lines starting with // '#' into the next [Peer]'s Name (mirrors the wg-easy convention). func parseWGConf(path string) (*parsedConf, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() var ( out parsedConf section string currentPeer *parsedPeer nextName string ) flushPeer := func() { if currentPeer != nil { if currentPeer.Name == "" && nextName != "" { currentPeer.Name = nextName } out.Peers = append(out.Peers, *currentPeer) currentPeer = nil nextName = "" } } sc := bufio.NewScanner(f) for sc.Scan() { line := strings.TrimSpace(sc.Text()) if line == "" { continue } if strings.HasPrefix(line, "#") { // Comment line; if the next thing we see is a [Peer], // use this as the peer's name. nextName = strings.TrimSpace(strings.TrimPrefix(line, "#")) continue } if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { flushPeer() section = strings.ToLower(strings.Trim(line, "[]")) if section == "peer" { currentPeer = &parsedPeer{} } continue } idx := strings.Index(line, "=") if idx < 0 { continue } key := strings.TrimSpace(strings.ToLower(line[:idx])) val := strings.TrimSpace(line[idx+1:]) switch section { case "interface": switch key { case "address": out.Address = strings.Split(val, ",")[0] // first addr only case "listenport": if n, err := strconv.Atoi(val); err == nil { out.ListenPort = n } case "privatekey": out.PrivateKey = val case "mtu": if n, err := strconv.Atoi(val); err == nil { out.MTU = n } } case "peer": if currentPeer == nil { continue } switch key { case "publickey": currentPeer.PublicKey = val case "endpoint": currentPeer.Endpoint = val case "allowedips": currentPeer.AllowedIPs = val case "persistentkeepalive": if n, err := strconv.Atoi(val); err == nil { currentPeer.Keepalive = n } case "presharedkey": currentPeer.PSK = val } } } flushPeer() if err := sc.Err(); err != nil { return nil, err } return &out, nil } func validIfaceName(s string) bool { if len(s) < 2 || len(s) > 15 { return false } if !strings.HasPrefix(s, "wg") { return false } for _, r := range s[2:] { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-': default: return false } } return true } func intPtrIfNonzero(n int) *int { if n == 0 { return nil } return &n }