Files
wg-portal/internal/app/wireguard/wireguard_interfaces.go
rwjack 54ca1d8aed
Some checks failed
Docker / Build and Push (push) Has been cancelled
Docker / release (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Add Pfsense backend (ALPHA) (#585)
* Add pfSense backend domain types and configuration

This adds the necessary domain types and configuration structures
for the pfSense backend support. Includes PfsenseInterfaceExtras and
PfsensePeerExtras structs, and the BackendPfsense configuration
with API URL, key, and timeout settings.

* Add low-level pfSense REST API client

Implements the HTTP client for interacting with the pfSense REST API.
Handles authentication via X-API-Key header, request/response parsing,
and error handling. Uses the pfSense REST API v2 endpoints as documented
at https://pfrest.org/.

* Implement pfSense WireGuard controller

This implements the InterfaceController interface for pfSense firewalls.
Handles WireGuard tunnel and peer management through the pfSense REST API.
Includes proper filtering of peers by interface (since API filtering doesn't
work) and parsing of the allowedips array structure with address/mask fields.

* Register pfSense controllers and update configuration

Registers the pfSense backend controllers in the controller manager
and adds example configuration to config.yml.sample. Also updates
README to mention pfSense backend support.

* Fix peer filtering and allowedips parsing for pfSense backend

The pfSense REST API doesn't support filtering peers by interface
via query parameters, so all peers are returned regardless of the
filter. This caused peers from all interfaces to be randomly assigned
to a single interface in wg-portal.

Additionally, the API returns allowedips as an array of objects with
"address" and "mask" fields instead of a comma-separated string,
which caused parsing failures.

Changes:
- Remove API filter from GetPeers() since it doesn't work
- Add client-side filtering by checking the "tun" field in peer responses
- Update convertWireGuardPeer() to parse allowedips array structure
- Add parseAddressArray() helper for parsing address objects
- Attempt to fetch interface addresses from /tunnel/{id}/address endpoint
  (endpoint may not be available in all pfSense versions)
- Add debug logging for peer filtering and address loading operations

Note: Interface addresses may still be empty if the address endpoint
is not available. Public Endpoint and Default DNS Servers are typically
configured manually in wg-portal as the pfSense API doesn't provide
this information.

* Extract endpoint, DNS, and peer names from pfSense peer data

The pfSense API provides endpoint, port, and description (descr) fields
in peer responses that can be used to populate interface defaults and
peer display names.

Changes:
- Extract endpoint and port from peers and combine them properly
- Fix peer name/description extraction to check "descr" field first
  (pfSense API uses "descr" instead of "description" or "comment")
- Add extractPfsenseDefaultsFromPeers() helper to extract common
  endpoint and DNS from peers during interface import
- Set PeerDefEndpoint and PeerDefDnsStr from peer data for pfSense
  backends during interface import
- Use most common endpoint/DNS values when multiple peers are present

* Fix interface display name to use descr field from pfSense API

The pfSense API uses "descr" field for tunnel descriptions, not
"description" or "comment". Updated convertWireGuardInterface()
to check "descr" first so that tunnel descriptions (e.g., "HQ VPN")
are displayed in the UI instead of just the tunnel name (e.g., "tun_wg0").

* Remove calls to non-working tunnel and peer detail endpoints

The pfSense REST API endpoints /api/v2/vpn/wireguard/tunnel/{id}
and /api/v2/vpn/wireguard/tunnel/{id}/address don't work and were
causing log spam. Removed these calls and use only the data from
the tunnel/peer list responses.

Also removed the peer detail endpoint call that was added for
statistics collection, as it likely doesn't work either.

* Fix unused variable compilation error

Removed unused deviceId variable that was causing build failure.

* Optimize tunnel address fetching to use /tunnel?id endpoint

Instead of using the separate /tunnel/address endpoint, now query
the specific tunnel endpoint /tunnel?id={id} which includes the
addresses array in the response. This avoids unnecessary API calls
and simplifies the code.

- GetInterface() now queries /tunnel?id={id} after getting tunnel ID
- loadInterfaceData() queries /tunnel?id={id} as fallback if addresses missing
- extractAddresses() properly parses addresses array from tunnel response
- Removed /tunnel/address endpoint calls

Signed-off-by: rwjack <jack@foss.family>

* Fix URL encoding issue in tunnel endpoint queries

Use Filters in PfsenseRequestOptions instead of passing query strings
directly in the path. This prevents the ? character from being encoded
as %3F, which was causing 404 errors.

- GetInterface() now uses Filters map for id parameter
- loadInterfaceData() now uses Filters map for id parameter

Signed-off-by: rwjack <jack@foss.family>

* update backend docs for pfsense

---------

Signed-off-by: rwjack <jack@foss.family>
2025-12-09 22:33:12 +01:00

1082 lines
33 KiB
Go

package wireguard
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"slices"
"strings"
"time"
"github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/app/audit"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
// GetInterfaceAndPeers returns the interface and all peers for the given interface identifier.
func (m Manager) GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceIdentifier) (
*domain.Interface,
[]domain.Peer,
error,
) {
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return nil, nil, err
}
return m.db.GetInterfaceAndPeers(ctx, id)
}
// GetAllInterfaces returns all interfaces that are available in the database.
func (m Manager) GetAllInterfaces(ctx context.Context) ([]domain.Interface, error) {
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return nil, err
}
return m.db.GetAllInterfaces(ctx)
}
// GetAllInterfacesAndPeers returns all interfaces and their peers.
func (m Manager) GetAllInterfacesAndPeers(ctx context.Context) ([]domain.Interface, [][]domain.Peer, error) {
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return nil, nil, err
}
interfaces, err := m.db.GetAllInterfaces(ctx)
if err != nil {
return nil, nil, fmt.Errorf("unable to load all interfaces: %w", err)
}
allPeers := make([][]domain.Peer, len(interfaces))
for i, iface := range interfaces {
peers, err := m.db.GetInterfacePeers(ctx, iface.Identifier)
if err != nil {
return nil, nil, fmt.Errorf("failed to load peers for interface %s: %w", iface.Identifier, err)
}
allPeers[i] = peers
}
return interfaces, allPeers, nil
}
// GetUserInterfaces returns all interfaces that are available for users to create new peers.
// If self-provisioning is disabled, this function will return an empty list.
// At the moment, there are no interfaces specific to single users, thus the user id is not used.
func (m Manager) GetUserInterfaces(ctx context.Context, _ domain.UserIdentifier) ([]domain.Interface, error) {
if !m.cfg.Core.SelfProvisioningAllowed {
return nil, nil // self-provisioning is disabled - no interfaces for users
}
interfaces, err := m.db.GetAllInterfaces(ctx)
if err != nil {
return nil, fmt.Errorf("unable to load all interfaces: %w", err)
}
// strip sensitive data, users only need very limited information
userInterfaces := make([]domain.Interface, 0, len(interfaces))
for _, iface := range interfaces {
if iface.IsDisabled() {
continue // skip disabled interfaces
}
if iface.Type != domain.InterfaceTypeServer {
continue // skip client interfaces
}
userInterfaces = append(userInterfaces, iface.PublicInfo())
}
return userInterfaces, nil
}
// ImportNewInterfaces imports all new physical interfaces that are available on the system.
// If a filter is set, only interfaces that match the filter will be imported.
func (m Manager) ImportNewInterfaces(ctx context.Context, filter ...domain.InterfaceIdentifier) (int, error) {
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return 0, err
}
var existingInterfaceIds []domain.InterfaceIdentifier
existingInterfaces, err := m.db.GetAllInterfaces(ctx)
if err != nil {
return 0, err
}
for _, existingInterface := range existingInterfaces {
existingInterfaceIds = append(existingInterfaceIds, existingInterface.Identifier)
}
imported := 0
for _, wgBackend := range m.wg.GetAllControllers() {
physicalInterfaces, err := wgBackend.Implementation.GetInterfaces(ctx)
if err != nil {
return 0, err
}
for _, physicalInterface := range physicalInterfaces {
if slices.Contains(wgBackend.Config.IgnoredInterfaces, string(physicalInterface.Identifier)) {
slog.Info("ignoring interface due to backend filter restrictions",
"interface", physicalInterface.Identifier, "filter", wgBackend.Config.IgnoredInterfaces,
"backend", wgBackend.Config.Id)
continue // skip ignored interfaces
}
if slices.Contains(existingInterfaceIds, physicalInterface.Identifier) {
continue // skip interfaces that already exist
}
if len(filter) > 0 && !slices.Contains(filter, physicalInterface.Identifier) {
slog.Info("ignoring interface due to filter restrictions",
"interface", physicalInterface.Identifier, "filter", wgBackend.Config.IgnoredInterfaces,
"backend", wgBackend.Config.Id)
continue
}
slog.Info("importing new interface",
"interface", physicalInterface.Identifier, "backend", wgBackend.Config.Id)
physicalPeers, err := wgBackend.Implementation.GetPeers(ctx, physicalInterface.Identifier)
if err != nil {
return 0, err
}
err = m.importInterface(ctx, wgBackend.Implementation, &physicalInterface, physicalPeers)
if err != nil {
return 0, fmt.Errorf("import of %s failed: %w", physicalInterface.Identifier, err)
}
slog.Info("imported new interface",
"interface", physicalInterface.Identifier, "peers", len(physicalPeers), "backend", wgBackend.Config.Id)
imported++
}
}
return imported, nil
}
// ApplyPeerDefaults applies the interface defaults to all peers of the given interface.
func (m Manager) ApplyPeerDefaults(ctx context.Context, in *domain.Interface) error {
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return err
}
existingInterface, err := m.db.GetInterface(ctx, in.Identifier)
if err != nil {
return fmt.Errorf("unable to load existing interface %s: %w", in.Identifier, err)
}
if err := m.validateInterfaceModifications(ctx, existingInterface, in); err != nil {
return fmt.Errorf("update not allowed: %w", err)
}
peers, err := m.db.GetInterfacePeers(ctx, in.Identifier)
if err != nil {
return fmt.Errorf("failed to find peers for interface %s: %w", in.Identifier, err)
}
for i := range peers {
(&peers[i]).ApplyInterfaceDefaults(in)
_, err := m.UpdatePeer(ctx, &peers[i])
if err != nil {
return fmt.Errorf("failed to apply interface defaults to peer %s: %w", peers[i].Identifier, err)
}
}
return nil
}
// RestoreInterfaceState restores the state of all physical interfaces and their peers.
// The final state of the interfaces and peers will be the same as stored in the database.
func (m Manager) RestoreInterfaceState(
ctx context.Context,
updateDbOnError bool,
filter ...domain.InterfaceIdentifier,
) error {
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return err
}
interfaces, err := m.db.GetAllInterfaces(ctx)
if err != nil {
return err
}
for _, iface := range interfaces {
if len(filter) != 0 && !slices.Contains(filter, iface.Identifier) {
continue // ignore filtered interface
}
peers, err := m.db.GetInterfacePeers(ctx, iface.Identifier)
if err != nil {
return fmt.Errorf("failed to load peers for %s: %w", iface.Identifier, err)
}
controller := m.wg.GetController(iface)
_, err = controller.GetInterface(ctx, iface.Identifier)
if err != nil && !iface.IsDisabled() {
slog.Debug("creating missing interface", "interface", iface.Identifier, "backend", controller.GetId())
// temporarily disable interface in database so that the current state is reflected correctly
_ = m.db.SaveInterface(ctx, iface.Identifier,
func(in *domain.Interface) (*domain.Interface, error) {
now := time.Now()
in.Disabled = &now // set
in.DisabledReason = domain.DisabledReasonInterfaceMissing
return in, nil
})
// try to create a new interface
_, err = m.saveInterface(ctx, &iface)
if err != nil {
if updateDbOnError {
// disable interface in database as no physical interface exists
_ = m.db.SaveInterface(ctx, iface.Identifier,
func(in *domain.Interface) (*domain.Interface, error) {
now := time.Now()
in.Disabled = &now // set
in.DisabledReason = domain.DisabledReasonInterfaceMissing
return in, nil
})
}
return fmt.Errorf("failed to create physical interface %s: %w", iface.Identifier, err)
}
} else {
slog.Debug("restoring interface state",
"interface", iface.Identifier, "disabled", iface.IsDisabled(), "backend", controller.GetId())
// try to move interface to stored state
_, err = m.saveInterface(ctx, &iface)
if err != nil {
if updateDbOnError {
// disable interface in database as no physical interface is available
_ = m.db.SaveInterface(ctx, iface.Identifier,
func(in *domain.Interface) (*domain.Interface, error) {
if iface.IsDisabled() {
now := time.Now()
in.Disabled = &now // set
in.DisabledReason = domain.DisabledReasonInterfaceMissing
} else {
in.Disabled = nil
in.DisabledReason = ""
}
return in, nil
})
}
return fmt.Errorf("failed to change physical interface state for %s: %w", iface.Identifier, err)
}
}
// restore peers
for _, peer := range peers {
switch {
case iface.IsDisabled() && iface.Backend == config.LocalBackendName: // if interface is disabled, delete all peers
if err := controller.DeletePeer(ctx, iface.Identifier,
peer.Identifier); err != nil {
return fmt.Errorf("failed to remove peer %s for disabled interface %s: %w",
peer.Identifier, iface.Identifier, err)
}
default: // update peer
err := controller.SavePeer(ctx, iface.Identifier, peer.Identifier,
func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) {
domain.MergeToPhysicalPeer(pp, &peer)
return pp, nil
})
if err != nil {
return fmt.Errorf("failed to create/update physical peer %s for interface %s: %w",
peer.Identifier, iface.Identifier, err)
}
}
}
// remove non-wgportal peers
physicalPeers, _ := controller.GetPeers(ctx, iface.Identifier)
for _, physicalPeer := range physicalPeers {
isWgPortalPeer := false
for _, peer := range peers {
if peer.Identifier == domain.PeerIdentifier(physicalPeer.PublicKey) {
isWgPortalPeer = true
break
}
}
if !isWgPortalPeer {
err := controller.DeletePeer(ctx, iface.Identifier,
domain.PeerIdentifier(physicalPeer.PublicKey))
if err != nil {
return fmt.Errorf("failed to remove non-wgportal peer %s from interface %s: %w",
physicalPeer.PublicKey, iface.Identifier, err)
}
}
}
}
return nil
}
// PrepareInterface generates a new interface with fresh keys, ip addresses and a listen port.
func (m Manager) PrepareInterface(ctx context.Context) (*domain.Interface, error) {
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return nil, err
}
currentUser := domain.GetUserInfo(ctx)
kp, err := domain.NewFreshKeypair()
if err != nil {
return nil, fmt.Errorf("failed to generate keys: %w", err)
}
id, err := m.getNewInterfaceName(ctx)
if err != nil {
return nil, fmt.Errorf("failed to generate new identifier: %w", err)
}
ipv4, ipv6, err := m.getFreshInterfaceIpConfig(ctx)
if err != nil {
return nil, fmt.Errorf("failed to generate new ip config: %w", err)
}
port, err := m.getFreshListenPort(ctx)
if err != nil {
return nil, fmt.Errorf("failed to generate new listen port: %w", err)
}
ips := []domain.Cidr{ipv4}
if m.cfg.Advanced.UseIpV6 {
ips = append(ips, ipv6)
}
networks := []domain.Cidr{ipv4.NetworkAddr()}
if m.cfg.Advanced.UseIpV6 {
networks = append(networks, ipv6.NetworkAddr())
}
freshInterface := &domain.Interface{
BaseModel: domain.BaseModel{
CreatedBy: string(currentUser.Id),
UpdatedBy: string(currentUser.Id),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
Identifier: id,
KeyPair: kp,
ListenPort: port,
Addresses: ips,
DnsStr: "",
DnsSearchStr: "",
Mtu: 1420,
FirewallMark: 0,
RoutingTable: "",
PreUp: "",
PostUp: "",
PreDown: "",
PostDown: "",
SaveConfig: m.cfg.Advanced.ConfigStoragePath != "",
DisplayName: string(id),
Type: domain.InterfaceTypeServer,
DriverType: "",
Disabled: nil,
DisabledReason: "",
PeerDefNetworkStr: domain.CidrsToString(networks),
PeerDefDnsStr: "",
PeerDefDnsSearchStr: "",
PeerDefEndpoint: "",
PeerDefAllowedIPsStr: domain.CidrsToString(networks),
PeerDefMtu: 1420,
PeerDefPersistentKeepalive: 16,
PeerDefFirewallMark: 0,
PeerDefRoutingTable: "",
PeerDefPreUp: "",
PeerDefPostUp: "",
PeerDefPreDown: "",
PeerDefPostDown: "",
}
return freshInterface, nil
}
// CreateInterface creates a new interface with the given configuration.
func (m Manager) CreateInterface(ctx context.Context, in *domain.Interface) (*domain.Interface, error) {
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return nil, err
}
existingInterface, err := m.db.GetInterface(ctx, in.Identifier)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return nil, fmt.Errorf("unable to load existing interface %s: %w", in.Identifier, err)
}
if existingInterface != nil {
return nil, fmt.Errorf("interface %s already exists: %w", in.Identifier, domain.ErrDuplicateEntry)
}
if err := m.validateInterfaceCreation(ctx, existingInterface, in); err != nil {
return nil, fmt.Errorf("creation not allowed: %w", err)
}
in, err = m.saveInterface(ctx, in)
if err != nil {
return nil, fmt.Errorf("creation failure: %w", err)
}
m.bus.Publish(app.TopicInterfaceCreated, *in)
return in, nil
}
// UpdateInterface updates the given interface with the new configuration.
func (m Manager) UpdateInterface(ctx context.Context, in *domain.Interface) (*domain.Interface, []domain.Peer, error) {
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return nil, nil, err
}
existingInterface, existingPeers, err := m.db.GetInterfaceAndPeers(ctx, in.Identifier)
if err != nil {
return nil, nil, fmt.Errorf("unable to load existing interface %s: %w", in.Identifier, err)
}
if err := m.validateInterfaceModifications(ctx, existingInterface, in); err != nil {
return nil, nil, fmt.Errorf("update not allowed: %w", err)
}
in, err = m.saveInterface(ctx, in)
if err != nil {
return nil, nil, fmt.Errorf("update failure: %w", err)
}
m.bus.Publish(app.TopicInterfaceUpdated, *in)
return in, existingPeers, nil
}
// DeleteInterface deletes the given interface.
func (m Manager) DeleteInterface(ctx context.Context, id domain.InterfaceIdentifier) error {
if err := domain.ValidateAdminAccessRights(ctx); err != nil {
return err
}
existingInterface, existingPeers, err := m.db.GetInterfaceAndPeers(ctx, id)
if err != nil {
return fmt.Errorf("unable to find interface %s: %w", id, err)
}
if err := m.validateInterfaceDeletion(ctx, existingInterface); err != nil {
return fmt.Errorf("deletion not allowed: %w", err)
}
m.bus.Publish(app.TopicRouteRemove, domain.RoutingTableInfo{
Interface: *existingInterface,
AllowedIps: existingInterface.GetAllowedIPs(existingPeers),
FwMark: existingInterface.FirewallMark,
Table: existingInterface.GetRoutingTable(),
TableStr: existingInterface.RoutingTable,
IsDeleted: true,
})
now := time.Now()
existingInterface.Disabled = &now // simulate a disabled interface
existingInterface.DisabledReason = domain.DisabledReasonDeleted
if err := m.handleInterfacePreSaveHooks(ctx, existingInterface, !existingInterface.IsDisabled(),
false); err != nil {
return fmt.Errorf("pre-delete hooks failed: %w", err)
}
if err := m.handleInterfacePreSaveActions(ctx, existingInterface); err != nil {
return fmt.Errorf("pre-delete actions failed: %w", err)
}
if err := m.deleteInterfacePeers(ctx, existingInterface, existingPeers); err != nil {
return fmt.Errorf("peer deletion failure: %w", err)
}
if err := m.wg.GetController(*existingInterface).DeleteInterface(ctx, id); err != nil {
return fmt.Errorf("wireguard deletion failure: %w", err)
}
if err := m.db.DeleteInterface(ctx, id); err != nil {
return fmt.Errorf("deletion failure: %w", err)
}
if err := m.handleInterfacePostSaveHooks(
ctx,
existingInterface,
!existingInterface.IsDisabled(),
false,
); err != nil {
return fmt.Errorf("post-delete hooks failed: %w", err)
}
m.bus.Publish(app.TopicInterfaceDeleted, *existingInterface)
return nil
}
// region helper-functions
func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) (
*domain.Interface,
error,
) {
if err := iface.Validate(); err != nil {
return nil, fmt.Errorf("interface validation failed: %w", err)
}
oldEnabled, newEnabled, routeTableChanged := false, !iface.IsDisabled(), false // if the interface did not exist, we assume it was not enabled
oldInterface, err := m.db.GetInterface(ctx, iface.Identifier)
if err == nil {
oldEnabled, newEnabled, routeTableChanged = m.getInterfaceStateHistory(oldInterface, iface)
}
if err := m.handleInterfacePreSaveHooks(ctx, iface, oldEnabled, newEnabled); err != nil {
return nil, fmt.Errorf("pre-save hooks failed: %w", err)
}
if err := m.handleInterfacePreSaveActions(ctx, iface); err != nil {
return nil, fmt.Errorf("pre-save actions failed: %w", err)
}
err = m.db.SaveInterface(ctx, iface.Identifier, func(i *domain.Interface) (*domain.Interface, error) {
iface.CopyCalculatedAttributes(i)
err := m.wg.GetController(*iface).SaveInterface(ctx, iface.Identifier,
func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error) {
domain.MergeToPhysicalInterface(pi, iface)
return pi, nil
})
if err != nil {
return nil, fmt.Errorf("failed to save physical interface %s: %w", iface.Identifier, err)
}
return iface, nil
})
if err != nil {
return nil, fmt.Errorf("failed to save interface: %w", err)
}
// update the interface type of peers in db
peers, err := m.db.GetInterfacePeers(ctx, iface.Identifier)
if err != nil {
return nil, fmt.Errorf("failed to load peers for interface %s: %w", iface.Identifier, err)
}
for _, peer := range peers {
err := m.db.SavePeer(ctx, peer.Identifier, func(_ *domain.Peer) (*domain.Peer, error) {
switch iface.Type {
case domain.InterfaceTypeAny:
peer.Interface.Type = domain.InterfaceTypeAny
case domain.InterfaceTypeClient:
peer.Interface.Type = domain.InterfaceTypeServer
case domain.InterfaceTypeServer:
peer.Interface.Type = domain.InterfaceTypeClient
}
return &peer, nil
})
if err != nil {
return nil, fmt.Errorf("failed to update peer %s for interface %s: %w", peer.Identifier,
iface.Identifier, err)
}
}
if iface.IsDisabled() {
m.bus.Publish(app.TopicRouteRemove, domain.RoutingTableInfo{
Interface: *iface,
AllowedIps: iface.GetAllowedIPs(peers),
FwMark: iface.FirewallMark,
Table: iface.GetRoutingTable(),
TableStr: iface.RoutingTable,
})
} else {
m.bus.Publish(app.TopicRouteUpdate, domain.RoutingTableInfo{
Interface: *iface,
AllowedIps: iface.GetAllowedIPs(peers),
FwMark: iface.FirewallMark,
Table: iface.GetRoutingTable(),
TableStr: iface.RoutingTable,
})
// if the route table changed, ensure that the old entries are remove
if routeTableChanged {
m.bus.Publish(app.TopicRouteRemove, domain.RoutingTableInfo{
Interface: *oldInterface,
AllowedIps: oldInterface.GetAllowedIPs(peers),
FwMark: oldInterface.FirewallMark,
Table: oldInterface.GetRoutingTable(),
TableStr: oldInterface.RoutingTable,
IsDeleted: true, // mark the old entries as deleted
})
}
}
if err := m.handleInterfacePostSaveHooks(ctx, iface, oldEnabled, newEnabled); err != nil {
return nil, fmt.Errorf("post-save hooks failed: %w", err)
}
// If the interface has just been enabled, restore its peers on the physical controller
if !oldEnabled && newEnabled && iface.Backend == config.LocalBackendName {
peers, err := m.db.GetInterfacePeers(ctx, iface.Identifier)
if err != nil {
return nil, fmt.Errorf("failed to load peers for interface %s: %w", iface.Identifier, err)
}
for _, peer := range peers {
saveErr := m.wg.GetController(*iface).SavePeer(ctx, iface.Identifier, peer.Identifier,
func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) {
domain.MergeToPhysicalPeer(pp, &peer)
return pp, nil
})
if saveErr != nil {
return nil, fmt.Errorf("failed to restore peer %s for interface %s: %w", peer.Identifier,
iface.Identifier, saveErr)
}
}
// notify that peers for this interface have changed so config/routes can be updated
m.bus.Publish(app.TopicPeerInterfaceUpdated, iface.Identifier)
}
m.bus.Publish(app.TopicAuditInterfaceChanged, domain.AuditEventWrapper[audit.InterfaceEvent]{
Ctx: ctx,
Event: audit.InterfaceEvent{
Interface: *iface,
Action: "save",
},
})
return iface, nil
}
func (m Manager) getInterfaceStateHistory(
oldInterface *domain.Interface,
iface *domain.Interface,
) (oldEnabled, newEnabled, routeTableChanged bool) {
return !oldInterface.IsDisabled(), !iface.IsDisabled(), oldInterface.RoutingTable != iface.RoutingTable
}
func (m Manager) handleInterfacePreSaveActions(ctx context.Context, iface *domain.Interface) error {
wgQuickController, ok := m.wg.GetController(*iface).(WgQuickController)
if !ok {
slog.Warn("failed to perform pre-save actions", "interface", iface.Identifier,
"error", "no capable controller found")
return nil
}
// update DNS settings only for client interfaces
if iface.Type == domain.InterfaceTypeClient || iface.Type == domain.InterfaceTypeAny {
if !iface.IsDisabled() {
if err := wgQuickController.SetDNS(ctx, iface.Identifier, iface.DnsStr, iface.DnsSearchStr); err != nil {
return fmt.Errorf("failed to update dns settings: %w", err)
}
} else {
if err := wgQuickController.UnsetDNS(ctx, iface.Identifier, iface.DnsStr, iface.DnsSearchStr); err != nil {
return fmt.Errorf("failed to clear dns settings: %w", err)
}
}
}
return nil
}
func (m Manager) handleInterfacePreSaveHooks(
ctx context.Context,
iface *domain.Interface,
oldEnabled, newEnabled bool,
) error {
if oldEnabled == newEnabled {
return nil // do nothing if state did not change
}
slog.Debug("executing pre-save hooks", "interface", iface.Identifier, "up", newEnabled)
wgQuickController, ok := m.wg.GetController(*iface).(WgQuickController)
if !ok {
slog.Warn("failed to execute pre-save hooks", "interface", iface.Identifier, "up", newEnabled,
"error", "no capable controller found")
return nil
}
if newEnabled {
if err := wgQuickController.ExecuteInterfaceHook(ctx, iface.Identifier, iface.PreUp); err != nil {
return fmt.Errorf("failed to execute pre-up hook: %w", err)
}
} else {
if err := wgQuickController.ExecuteInterfaceHook(ctx, iface.Identifier, iface.PreDown); err != nil {
return fmt.Errorf("failed to execute pre-down hook: %w", err)
}
}
return nil
}
func (m Manager) handleInterfacePostSaveHooks(
ctx context.Context,
iface *domain.Interface,
oldEnabled, newEnabled bool,
) error {
if oldEnabled == newEnabled {
return nil // do nothing if state did not change
}
slog.Debug("executing post-save hooks", "interface", iface.Identifier, "up", newEnabled)
wgQuickController, ok := m.wg.GetController(*iface).(WgQuickController)
if !ok {
slog.Warn("failed to execute post-save hooks", "interface", iface.Identifier, "up", newEnabled,
"error", "no capable controller found")
return nil
}
if newEnabled {
if err := wgQuickController.ExecuteInterfaceHook(ctx, iface.Identifier, iface.PostUp); err != nil {
return fmt.Errorf("failed to execute post-up hook: %w", err)
}
} else {
if err := wgQuickController.ExecuteInterfaceHook(ctx, iface.Identifier, iface.PostDown); err != nil {
return fmt.Errorf("failed to execute post-down hook: %w", err)
}
}
return nil
}
func (m Manager) getNewInterfaceName(ctx context.Context) (domain.InterfaceIdentifier, error) {
namePrefix := "wg"
nameSuffix := 0
existingInterfaces, err := m.db.GetAllInterfaces(ctx)
if err != nil {
return "", err
}
var name domain.InterfaceIdentifier
for {
name = domain.InterfaceIdentifier(fmt.Sprintf("%s%d", namePrefix, nameSuffix))
conflict := false
for _, in := range existingInterfaces {
if in.Identifier == name {
conflict = true
break
}
}
if !conflict {
break
}
nameSuffix++
}
return name, nil
}
func (m Manager) getFreshInterfaceIpConfig(ctx context.Context) (ipV4, ipV6 domain.Cidr, err error) {
ips, err := m.db.GetInterfaceIps(ctx)
if err != nil {
err = fmt.Errorf("failed to get existing IP addresses: %w", err)
return
}
useV6 := m.cfg.Advanced.UseIpV6
ipV4, _ = domain.CidrFromString(m.cfg.Advanced.StartCidrV4)
ipV6, _ = domain.CidrFromString(m.cfg.Advanced.StartCidrV6)
ipV4 = ipV4.FirstAddr()
ipV6 = ipV6.FirstAddr()
netV4 := ipV4.NetworkAddr()
netV6 := ipV6.NetworkAddr()
for {
v4Conflict := false
v6Conflict := false
for _, usedIps := range ips {
for _, usedIp := range usedIps {
usedNetwork := usedIp.NetworkAddr()
if netV4 == usedNetwork {
v4Conflict = true
}
if netV6 == usedNetwork {
v6Conflict = true
}
}
}
if !v4Conflict && (!useV6 || !v6Conflict) {
break
}
if v4Conflict {
netV4 = netV4.NextSubnet()
}
if v6Conflict && useV6 {
netV6 = netV6.NextSubnet()
}
if !netV4.IsValid() {
return domain.Cidr{}, domain.Cidr{}, fmt.Errorf("IPv4 space exhausted")
}
if useV6 && !netV6.IsValid() {
return domain.Cidr{}, domain.Cidr{}, fmt.Errorf("IPv6 space exhausted")
}
}
// use first address in network for interface
ipV4 = netV4.NextAddr()
ipV6 = netV6.NextAddr()
return
}
func (m Manager) getFreshListenPort(ctx context.Context) (port int, err error) {
existingInterfaces, err := m.db.GetAllInterfaces(ctx)
if err != nil {
return -1, err
}
port = m.cfg.Advanced.StartListenPort
for {
conflict := false
for _, in := range existingInterfaces {
if in.ListenPort == port {
conflict = true
break
}
}
if !conflict {
break
}
port++
}
if port > 65535 { // maximum allowed port number (16 bit uint)
return -1, fmt.Errorf("port space exhausted")
}
return
}
func (m Manager) importInterface(
ctx context.Context,
backend domain.InterfaceController,
in *domain.PhysicalInterface,
peers []domain.PhysicalPeer,
) error {
now := time.Now()
iface := domain.ConvertPhysicalInterface(in)
iface.BaseModel = domain.BaseModel{
CreatedBy: domain.CtxSystemWgImporter,
UpdatedBy: domain.CtxSystemWgImporter,
CreatedAt: now,
UpdatedAt: now,
}
iface.Backend = backend.GetId()
iface.PeerDefAllowedIPsStr = iface.AddressStr()
// For pfSense backends, extract endpoint and DNS from peers
if backend.GetId() == domain.ControllerTypePfsense {
endpoint, dns := extractPfsenseDefaultsFromPeers(peers, iface.ListenPort)
if endpoint != "" {
iface.PeerDefEndpoint = endpoint
}
if dns != "" {
iface.PeerDefDnsStr = dns
}
}
// try to predict the interface type based on the number of peers
switch len(peers) {
case 0:
iface.Type = domain.InterfaceTypeAny // no peers means this is an unknown interface
case 1:
iface.Type = domain.InterfaceTypeClient // one peer means this is a client interface
default: // multiple peers means this is a server interface
iface.Type = domain.InterfaceTypeServer
}
existingInterface, err := m.db.GetInterface(ctx, iface.Identifier)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return err
}
if existingInterface != nil {
return errors.New("interface already exists")
}
err = m.db.SaveInterface(ctx, iface.Identifier, func(_ *domain.Interface) (*domain.Interface, error) {
return iface, nil
})
if err != nil {
return fmt.Errorf("database save failed: %w", err)
}
// import peers
for _, peer := range peers {
err = m.importPeer(ctx, iface, &peer)
if err != nil {
return fmt.Errorf("import of peer %s failed: %w", peer.Identifier, err)
}
}
return nil
}
// extractPfsenseDefaultsFromPeers extracts common endpoint and DNS information from peers
// For server interfaces, peers typically have endpoints pointing to the server, so we use the most common one
func extractPfsenseDefaultsFromPeers(peers []domain.PhysicalPeer, listenPort int) (endpoint, dns string) {
if len(peers) == 0 {
return "", ""
}
// Count endpoint occurrences to find the most common one
endpointCounts := make(map[string]int)
dnsValues := make(map[string]int)
for _, peer := range peers {
// Extract endpoint from peer
if peer.Endpoint != "" {
endpointCounts[peer.Endpoint]++
}
// Extract DNS from peer extras if available
if extras := peer.GetExtras(); extras != nil {
if pfsenseExtras, ok := extras.(domain.PfsensePeerExtras); ok {
if pfsenseExtras.ClientDns != "" {
dnsValues[pfsenseExtras.ClientDns]++
}
}
}
}
// Find the most common endpoint
maxCount := 0
for ep, count := range endpointCounts {
if count > maxCount {
maxCount = count
endpoint = ep
}
}
// If endpoint doesn't have a port and we have a listenPort, add it
if endpoint != "" && listenPort > 0 {
if !strings.Contains(endpoint, ":") {
endpoint = fmt.Sprintf("%s:%d", endpoint, listenPort)
}
}
// Find the most common DNS
maxDnsCount := 0
for dnsVal, count := range dnsValues {
if count > maxDnsCount {
maxDnsCount = count
dns = dnsVal
}
}
return endpoint, dns
}
func (m Manager) importPeer(ctx context.Context, in *domain.Interface, p *domain.PhysicalPeer) error {
now := time.Now()
peer := domain.ConvertPhysicalPeer(p)
peer.BaseModel = domain.BaseModel{
CreatedBy: domain.CtxSystemWgImporter,
UpdatedBy: domain.CtxSystemWgImporter,
CreatedAt: now,
UpdatedAt: now,
}
peer.InterfaceIdentifier = in.Identifier
peer.EndpointPublicKey = domain.NewConfigOption(in.PublicKey, true)
peer.AllowedIPsStr = domain.NewConfigOption(in.PeerDefAllowedIPsStr, true)
peer.Interface.Addresses = p.AllowedIPs // use allowed IP's as the peer IP's TODO: Should this also match server interface address' prefix length?
peer.Interface.DnsStr = domain.NewConfigOption(in.PeerDefDnsStr, true)
peer.Interface.DnsSearchStr = domain.NewConfigOption(in.PeerDefDnsSearchStr, true)
peer.Interface.Mtu = domain.NewConfigOption(in.PeerDefMtu, true)
peer.Interface.FirewallMark = domain.NewConfigOption(in.PeerDefFirewallMark, true)
peer.Interface.RoutingTable = domain.NewConfigOption(in.PeerDefRoutingTable, true)
peer.Interface.PreUp = domain.NewConfigOption(in.PeerDefPreUp, true)
peer.Interface.PostUp = domain.NewConfigOption(in.PeerDefPostUp, true)
peer.Interface.PreDown = domain.NewConfigOption(in.PeerDefPreDown, true)
peer.Interface.PostDown = domain.NewConfigOption(in.PeerDefPostDown, true)
var displayName string
switch in.Type {
case domain.InterfaceTypeAny:
peer.Interface.Type = domain.InterfaceTypeAny
displayName = "Autodetected Peer (" + peer.Interface.PublicKey[0:8] + ")"
case domain.InterfaceTypeClient:
peer.Interface.Type = domain.InterfaceTypeServer
displayName = "Autodetected Endpoint (" + peer.Interface.PublicKey[0:8] + ")"
case domain.InterfaceTypeServer:
peer.Interface.Type = domain.InterfaceTypeClient
displayName = "Autodetected Client (" + peer.Interface.PublicKey[0:8] + ")"
}
if peer.DisplayName == "" {
peer.DisplayName = displayName // use auto-generated display name if not set
}
err := m.db.SavePeer(ctx, peer.Identifier, func(_ *domain.Peer) (*domain.Peer, error) {
return peer, nil
})
if err != nil {
return fmt.Errorf("database save failed: %w", err)
}
return nil
}
func (m Manager) deleteInterfacePeers(ctx context.Context, iface *domain.Interface, allPeers []domain.Peer) error {
for _, peer := range allPeers {
err := m.wg.GetController(*iface).DeletePeer(ctx, iface.Identifier, peer.Identifier)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("wireguard peer deletion failure for %s: %w", peer.Identifier, err)
}
err = m.db.DeletePeer(ctx, peer.Identifier)
if err != nil {
return fmt.Errorf("peer deletion failure for %s: %w", peer.Identifier, err)
}
}
return nil
}
func (m Manager) validateInterfaceModifications(ctx context.Context, _, _ *domain.Interface) error {
currentUser := domain.GetUserInfo(ctx)
if !currentUser.IsAdmin {
return fmt.Errorf("insufficient permissions")
}
return nil
}
func (m Manager) validateInterfaceCreation(ctx context.Context, _, new *domain.Interface) error {
currentUser := domain.GetUserInfo(ctx)
if new.Identifier == "" {
return fmt.Errorf("invalid interface identifier")
}
if !currentUser.IsAdmin {
return fmt.Errorf("insufficient permissions")
}
// validate public key if it is set
if new.PublicKey != "" && new.PrivateKey != "" {
if domain.PublicKeyFromPrivateKey(new.PrivateKey) != new.PublicKey {
return fmt.Errorf("invalid public key for given privatekey: %w", domain.ErrInvalidData)
}
}
return nil
}
func (m Manager) validateInterfaceDeletion(ctx context.Context, _ *domain.Interface) error {
currentUser := domain.GetUserInfo(ctx)
if !currentUser.IsAdmin {
return fmt.Errorf("insufficient permissions")
}
return nil
}
// endregion helper-functions