package opnsense import ( "fmt" "regexp" "strconv" "strings" "time" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "github.com/ngoduykhanh/wireguard-ui/model" "github.com/ngoduykhanh/wireguard-ui/util" ) // PreviewClient is a single OPNsense client mapped into an admin-editable // staging structure. It is not a model.Client yet - ToModel performs the // final conversion at commit time. type PreviewClient struct { SourceUUID string `json:"source_uuid,omitempty"` Name string `json:"name"` Email string `json:"email,omitempty"` Enabled bool `json:"enabled"` PublicKey string `json:"public_key"` PresharedKey string `json:"preshared_key,omitempty"` AllocatedIPs []string `json:"allocated_ips"` AllowedIPs []string `json:"allowed_ips"` PersistentKeepalive int `json:"persistent_keepalive,omitempty"` AdditionalNotes string `json:"additional_notes,omitempty"` Warnings []string `json:"warnings,omitempty"` } // PreviewServer is a single OPNsense WireGuard server instance mapped into // an admin-editable staging structure, together with the clients OPNsense // associated with it via its uuid list. type PreviewServer struct { SourceUUID string `json:"source_uuid,omitempty"` ID string `json:"id"` Name string `json:"name"` Interface string `json:"interface"` Addresses []string `json:"addresses"` ListenPort int `json:"listen_port"` PrivateKey string `json:"private_key,omitempty"` PublicKey string `json:"public_key,omitempty"` EndpointAddress string `json:"endpoint_address,omitempty"` DNSServers []string `json:"dns_servers,omitempty"` MTU int `json:"mtu,omitempty"` Enabled bool `json:"enabled"` Clients []PreviewClient `json:"clients"` Warnings []string `json:"warnings,omitempty"` } // PreviewResult is the full response of the preview step: every server // OPNsense defined, mapped and ready for the admin to review/edit before // confirming the import. It is never written to the store by itself. type PreviewResult struct { Servers []PreviewServer `json:"servers"` } var slugInvalidChars = regexp.MustCompile(`[^a-zA-Z0-9_-]+`) // slugify turns a free-text name into a safe record-id/interface-name // candidate (see util.ValidateRecordID / util.ValidateInterfaceName). func slugify(name string) string { s := slugInvalidChars.ReplaceAllString(strings.TrimSpace(name), "-") s = strings.Trim(s, "-_") if s == "" { s = "opnsense-server" } return s } // uniqueID returns slug, or slug-2, slug-3, ... until it no longer collides // with existingIDs or anything already produced in this same batch (used // so importing several OPNsense servers whose names collide with each // other, or with already-existing wireguard-ui-multi servers, still // produces distinct IDs). func uniqueID(slug string, existingIDs map[string]bool) string { candidate := slug if !existingIDs[candidate] { existingIDs[candidate] = true return candidate } for i := 2; ; i++ { candidate = fmt.Sprintf("%s-%d", slug, i) if !existingIDs[candidate] { existingIDs[candidate] = true return candidate } } } // truncateInterfaceName shortens a candidate interface name to fit // util.ValidateInterfaceName's 15-char limit while staying unique within // this batch, reusing the numeric suffix strategy from uniqueID. func truncateInterfaceName(slug string, existingNames map[string]bool) string { const maxLen = 15 base := slug if len(base) > maxLen { base = base[:maxLen] } if !existingNames[base] && util.ValidateInterfaceName(base) { existingNames[base] = true return base } for i := 2; ; i++ { suffix := fmt.Sprintf("-%d", i) cut := maxLen - len(suffix) if cut < 1 { cut = 1 } truncated := slug if len(truncated) > cut { truncated = truncated[:cut] } candidate := truncated + suffix if !existingNames[candidate] && util.ValidateInterfaceName(candidate) { existingNames[candidate] = true return candidate } } } // ToPreview maps a ParsedConfig into admin-editable PreviewServer/ // PreviewClient structures. existingServerIDs should list every server ID // already present in the store (see store.IStore.GetServers), so imported // IDs never collide with them. func ToPreview(cfg *ParsedConfig, existingServerIDs []string) *PreviewResult { usedIDs := make(map[string]bool, len(existingServerIDs)) for _, id := range existingServerIDs { usedIDs[id] = true } usedIfaceNames := make(map[string]bool) // index clients by their OPNsense uuid so each server's list // (the only place the client<->server association is recorded) can // pull in the right ones. clientsByUUID := make(map[string]rawClient, len(cfg.Clients)) for _, c := range cfg.Clients { clientsByUUID[c.UUID] = c } result := &PreviewResult{} for _, rs := range cfg.Servers { ps := PreviewServer{ SourceUUID: rs.UUID, Name: rs.Name, Enabled: isTruthy(rs.Enabled), DNSServers: splitList(rs.DNS), } if ps.Name == "" { ps.Name = fmt.Sprintf("opnsense-server-%s", rs.Instance) } slug := slugify(ps.Name) ps.ID = uniqueID(slug, usedIDs) ps.Interface = truncateInterfaceName(slug, usedIfaceNames) ps.Addresses = splitList(rs.TunnelAddress) if len(ps.Addresses) == 0 { ps.Warnings = append(ps.Warnings, "no tunnel address found; please set the server's address range manually") } else if !util.ValidateServerAddresses(ps.Addresses) { ps.Warnings = append(ps.Warnings, "tunnel address is not valid CIDR; please fix before importing") } if rs.Port != "" { if port, err := strconv.Atoi(strings.TrimSpace(rs.Port)); err == nil && port > 0 && port <= 65535 { ps.ListenPort = port } else { ps.Warnings = append(ps.Warnings, fmt.Sprintf("invalid listen port %q; please set one manually", rs.Port)) } } else { ps.Warnings = append(ps.Warnings, "no listen port found; please set one manually") } if rs.MTU != "" { if mtu, err := strconv.Atoi(strings.TrimSpace(rs.MTU)); err == nil && mtu > 0 { ps.MTU = mtu } } if rs.PrivKey == "" { ps.Warnings = append(ps.Warnings, "no private key found; a server cannot be imported without one") } else { ps.PrivateKey = rs.PrivKey key, err := wgtypes.ParseKey(rs.PrivKey) if err != nil { ps.Warnings = append(ps.Warnings, fmt.Sprintf("private key does not parse as a valid WireGuard key: %v", err)) ps.PrivateKey = "" } else { // Always derive the public key from the private key rather // than trusting the exported blindly. ps.PublicKey = key.PublicKey().String() } } peerUUIDs := splitList(rs.Peers) ps.EndpointAddress = firstServerAddress(cfg.Clients, peerUUIDs) for _, peerUUID := range peerUUIDs { rc, ok := clientsByUUID[peerUUID] if !ok { ps.Warnings = append(ps.Warnings, fmt.Sprintf("referenced peer %q was not found among the parsed clients", peerUUID)) continue } ps.Clients = append(ps.Clients, mapClient(rc)) } result.Servers = append(result.Servers, ps) } return result } func mapClient(rc rawClient) PreviewClient { pc := PreviewClient{ SourceUUID: rc.UUID, Name: rc.Name, Enabled: isTruthy(rc.Enabled), PublicKey: rc.PubKey, PresharedKey: rc.PSK, AdditionalNotes: "Imported from OPNsense", } if pc.Name == "" { pc.Name = rc.UUID } if rc.PubKey == "" { pc.Warnings = append(pc.Warnings, "no public key found; this client cannot be imported without one") } else if _, err := wgtypes.ParseKey(rc.PubKey); err != nil { pc.Warnings = append(pc.Warnings, fmt.Sprintf("public key does not parse as a valid WireGuard key: %v", err)) } pc.AllocatedIPs = splitList(rc.TunnelAddress) if len(pc.AllocatedIPs) == 0 { pc.Warnings = append(pc.Warnings, "no tunnel address found; please set the client's allocated address manually") } else if !util.ValidateAllowedIPs(pc.AllocatedIPs) { pc.Warnings = append(pc.Warnings, "tunnel address is not valid CIDR; please fix before importing") } // OPNsense has no separate "allowed IPs for this peer" field for // clients; default to the client's own allocated address(es), same as // wireguard-ui-multi's own "new client" default. pc.AllowedIPs = append([]string(nil), pc.AllocatedIPs...) if rc.Keepalive != "" { if ka, err := strconv.Atoi(strings.TrimSpace(rc.Keepalive)); err == nil && ka > 0 { pc.PersistentKeepalive = ka } } if rc.ServerAddress != "" { // stashed for the caller to optionally fold into the server's // EndpointAddress override; not part of the per-client model. pc.AdditionalNotes += fmt.Sprintf(" (OPNsense serveraddress: %s)", rc.ServerAddress) } return pc } // firstServerAddress returns the first non-empty serveraddress[:serverport] // combination found among rc's clients, used to prefill a server's // EndpointAddress override in the preview. func firstServerAddress(clients []rawClient, peerUUIDs []string) string { wanted := make(map[string]bool, len(peerUUIDs)) for _, u := range peerUUIDs { wanted[u] = true } for _, c := range clients { if !wanted[c.UUID] || c.ServerAddress == "" { continue } if c.ServerPort != "" { return fmt.Sprintf("%s:%s", c.ServerAddress, strings.TrimSpace(c.ServerPort)) } return c.ServerAddress } return "" } // ToModel converts an admin-reviewed PreviewServer into the model records // wireguard-ui-multi actually stores: a Server, its ServerSetting, and the // Clients that belong to it. It does not touch the store itself - callers // (the commit handler) are responsible for the actual SaveXxx calls, and // for checking public-key collisions against clients already in the store. func ToModel(ps PreviewServer) (model.Server, model.ServerSetting, []model.Client, error) { now := time.Now().UTC() if !util.ValidateRecordID(ps.ID) { return model.Server{}, model.ServerSetting{}, nil, fmt.Errorf("invalid server id %q", ps.ID) } if !util.ValidateInterfaceName(ps.Interface) { return model.Server{}, model.ServerSetting{}, nil, fmt.Errorf("invalid interface name %q", ps.Interface) } if !util.ValidateServerAddresses(ps.Addresses) { return model.Server{}, model.ServerSetting{}, nil, fmt.Errorf("invalid server addresses %v", ps.Addresses) } if ps.ListenPort <= 0 || ps.ListenPort > 65535 { return model.Server{}, model.ServerSetting{}, nil, fmt.Errorf("invalid listen port %d", ps.ListenPort) } if ps.PrivateKey == "" { return model.Server{}, model.ServerSetting{}, nil, fmt.Errorf("server %q has no private key", ps.ID) } key, err := wgtypes.ParseKey(ps.PrivateKey) if err != nil { return model.Server{}, model.ServerSetting{}, nil, fmt.Errorf("server %q private key invalid: %w", ps.ID, err) } server := model.Server{ ID: ps.ID, Name: ps.Name, KeyPair: &model.ServerKeypair{ PrivateKey: key.String(), PublicKey: key.PublicKey().String(), UpdatedAt: now, }, Interface: &model.ServerInterface{ Name: ps.Interface, Addresses: ps.Addresses, ListenPort: ps.ListenPort, UpdatedAt: now, }, } settings := model.ServerSetting{ EndpointAddress: ps.EndpointAddress, ConfigFilePath: fmt.Sprintf("/etc/wireguard/%s.conf", ps.Interface), FirewallMark: util.DefaultFirewallMark, Table: util.DefaultTable, DNSServers: ps.DNSServers, MTU: ps.MTU, UpdatedAt: now, } clients := make([]model.Client, 0, len(ps.Clients)) for _, pc := range ps.Clients { if pc.PublicKey == "" { return model.Server{}, model.ServerSetting{}, nil, fmt.Errorf("client %q has no public key", pc.Name) } if _, err := wgtypes.ParseKey(pc.PublicKey); err != nil { return model.Server{}, model.ServerSetting{}, nil, fmt.Errorf("client %q public key invalid: %w", pc.Name, err) } if !util.ValidateAllowedIPs(pc.AllocatedIPs) { return model.Server{}, model.ServerSetting{}, nil, fmt.Errorf("client %q has invalid allocated IPs", pc.Name) } if !util.ValidateAllowedIPs(pc.AllowedIPs) { return model.Server{}, model.ServerSetting{}, nil, fmt.Errorf("client %q has invalid allowed IPs", pc.Name) } presharedKey := pc.PresharedKey if presharedKey != "" { if _, err := wgtypes.ParseKey(presharedKey); err != nil { return model.Server{}, model.ServerSetting{}, nil, fmt.Errorf("client %q preshared key invalid: %w", pc.Name, err) } } clients = append(clients, model.Client{ ServerID: ps.ID, PublicKey: pc.PublicKey, PresharedKey: presharedKey, Name: pc.Name, Email: pc.Email, AllocatedIPs: pc.AllocatedIPs, AllowedIPs: pc.AllowedIPs, AdditionalNotes: pc.AdditionalNotes, PersistentKeepalive: pc.PersistentKeepalive, Enabled: pc.Enabled, CreatedAt: now, UpdatedAt: now, }) } return server, settings, clients, nil }