2023-08-04 13:34:18 +02:00
|
|
|
package domain
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
2025-03-02 08:51:13 +01:00
|
|
|
"log/slog"
|
2023-08-04 13:34:18 +02:00
|
|
|
"math"
|
2025-01-26 09:52:09 +01:00
|
|
|
"net"
|
2023-08-04 13:34:18 +02:00
|
|
|
"regexp"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
2024-10-15 15:44:47 +02:00
|
|
|
|
2025-08-10 14:42:02 +02:00
|
|
|
"golang.org/x/sys/unix"
|
|
|
|
|
|
2025-02-28 08:29:40 +01:00
|
|
|
"github.com/h44z/wg-portal/internal"
|
2025-10-12 14:31:19 +02:00
|
|
|
"github.com/h44z/wg-portal/internal/config"
|
2023-08-04 13:34:18 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
InterfaceTypeServer InterfaceType = "server"
|
|
|
|
|
InterfaceTypeClient InterfaceType = "client"
|
|
|
|
|
InterfaceTypeAny InterfaceType = "any"
|
|
|
|
|
)
|
|
|
|
|
|
2025-02-28 08:29:40 +01:00
|
|
|
var allowedFileNameRegex = regexp.MustCompile("[^a-zA-Z0-9-_]+")
|
|
|
|
|
|
2023-08-04 13:34:18 +02:00
|
|
|
type InterfaceIdentifier string
|
|
|
|
|
type InterfaceType string
|
2025-08-10 14:42:02 +02:00
|
|
|
type InterfaceBackend string
|
2023-08-04 13:34:18 +02:00
|
|
|
|
|
|
|
|
type Interface struct {
|
|
|
|
|
BaseModel
|
|
|
|
|
|
|
|
|
|
// WireGuard specific (for the [interface] section of the config file)
|
|
|
|
|
|
|
|
|
|
Identifier InterfaceIdentifier `gorm:"primaryKey"` // device name, for example: wg0
|
|
|
|
|
KeyPair // private/public Key of the server interface
|
|
|
|
|
ListenPort int // the listening port, for example: 51820
|
|
|
|
|
|
|
|
|
|
Addresses []Cidr `gorm:"many2many:interface_addresses;"` // the interface ip addresses
|
|
|
|
|
DnsStr string // the dns server that should be set if the interface is up, comma separated
|
|
|
|
|
DnsSearchStr string // the dns search option string that should be set if the interface is up, will be appended to DnsStr
|
|
|
|
|
|
|
|
|
|
Mtu int // the device MTU
|
2024-10-15 15:44:47 +02:00
|
|
|
FirewallMark uint32 // a firewall mark
|
2023-08-04 13:34:18 +02:00
|
|
|
RoutingTable string // the routing table number or "off" if the routing table should not be managed
|
|
|
|
|
|
|
|
|
|
PreUp string // action that is executed before the device is up
|
|
|
|
|
PostUp string // action that is executed after the device is up
|
|
|
|
|
PreDown string // action that is executed before the device is down
|
|
|
|
|
PostDown string // action that is executed after the device is down
|
|
|
|
|
|
|
|
|
|
SaveConfig bool // automatically persist config changes to the wgX.conf file
|
|
|
|
|
|
|
|
|
|
// WG Portal specific
|
2025-08-10 14:42:02 +02:00
|
|
|
DisplayName string // a nice display name/ description for the interface
|
|
|
|
|
Type InterfaceType // the interface type, either InterfaceTypeServer or InterfaceTypeClient
|
|
|
|
|
Backend InterfaceBackend // the backend that is used to manage the interface (wgctrl, mikrotik, ...)
|
|
|
|
|
DriverType string // the interface driver type (linux, software, ...)
|
|
|
|
|
Disabled *time.Time `gorm:"index"` // flag that specifies if the interface is enabled (up) or not (down)
|
|
|
|
|
DisabledReason string // the reason why the interface has been disabled
|
2023-08-04 13:34:18 +02:00
|
|
|
|
|
|
|
|
// Default settings for the peer, used for new peers, those settings will be published to ConfigOption options of
|
|
|
|
|
// the peer config
|
|
|
|
|
|
|
|
|
|
PeerDefNetworkStr string // the default subnets from which peers will get their IP addresses, comma seperated
|
|
|
|
|
PeerDefDnsStr string // the default dns server for the peer
|
|
|
|
|
PeerDefDnsSearchStr string // the default dns search options for the peer
|
|
|
|
|
PeerDefEndpoint string // the default endpoint for the peer
|
|
|
|
|
PeerDefAllowedIPsStr string // the default allowed IP string for the peer
|
|
|
|
|
PeerDefMtu int // the default device MTU
|
|
|
|
|
PeerDefPersistentKeepalive int // the default persistent keep-alive Value
|
2024-10-15 15:44:47 +02:00
|
|
|
PeerDefFirewallMark uint32 // default firewall mark
|
2023-08-04 13:34:18 +02:00
|
|
|
PeerDefRoutingTable string // the default routing table
|
|
|
|
|
|
|
|
|
|
PeerDefPreUp string // default action that is executed before the device is up
|
|
|
|
|
PeerDefPostUp string // default action that is executed after the device is up
|
|
|
|
|
PeerDefPreDown string // default action that is executed before the device is down
|
|
|
|
|
PeerDefPostDown string // default action that is executed after the device is down
|
|
|
|
|
}
|
|
|
|
|
|
2025-01-26 11:35:24 +01:00
|
|
|
// PublicInfo returns a copy of the interface with only the public information.
|
|
|
|
|
// Sensible information like keys are not included.
|
|
|
|
|
func (i *Interface) PublicInfo() Interface {
|
|
|
|
|
return Interface{
|
|
|
|
|
Identifier: i.Identifier,
|
|
|
|
|
DisplayName: i.DisplayName,
|
|
|
|
|
Type: i.Type,
|
|
|
|
|
Disabled: i.Disabled,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-01-26 09:52:09 +01:00
|
|
|
// Validate performs checks to ensure that the interface is valid.
|
|
|
|
|
func (i *Interface) Validate() error {
|
|
|
|
|
// validate peer default endpoint, add port if needed
|
|
|
|
|
if i.PeerDefEndpoint != "" {
|
|
|
|
|
host, port, err := net.SplitHostPort(i.PeerDefEndpoint)
|
|
|
|
|
switch {
|
|
|
|
|
case err != nil && !strings.Contains(err.Error(), "missing port in address"):
|
|
|
|
|
return fmt.Errorf("invalid default endpoint: %w", err)
|
|
|
|
|
case err != nil && strings.Contains(err.Error(), "missing port in address"):
|
|
|
|
|
// In this case, the entire string is the host, and there's no port.
|
|
|
|
|
host = i.PeerDefEndpoint
|
|
|
|
|
port = strconv.Itoa(i.ListenPort)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
i.PeerDefEndpoint = net.JoinHostPort(host, port)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
2023-08-04 13:34:18 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (i *Interface) IsDisabled() bool {
|
|
|
|
|
if i == nil {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
return i.Disabled != nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (i *Interface) AddressStr() string {
|
|
|
|
|
return CidrsToString(i.Addresses)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (i *Interface) CopyCalculatedAttributes(src *Interface) {
|
|
|
|
|
i.BaseModel = src.BaseModel
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (i *Interface) GetConfigFileName() string {
|
2025-04-19 13:12:31 +02:00
|
|
|
filename := allowedFileNameRegex.ReplaceAllString(string(i.Identifier), "")
|
|
|
|
|
filename = internal.TruncateString(filename, 16)
|
2023-08-04 13:34:18 +02:00
|
|
|
filename += ".conf"
|
|
|
|
|
|
|
|
|
|
return filename
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-12 14:31:19 +02:00
|
|
|
// GetAllowedIPs returns the allowed IPs for the interface depending on the interface type and peers.
|
|
|
|
|
// For example, if the interface type is Server, the allowed IPs are the IPs of the peers.
|
|
|
|
|
// If the interface type is Client, the allowed IPs correspond to the AllowedIPsStr of the peers.
|
2023-08-04 13:34:18 +02:00
|
|
|
func (i *Interface) GetAllowedIPs(peers []Peer) []Cidr {
|
|
|
|
|
var allowedCidrs []Cidr
|
|
|
|
|
|
2025-10-12 14:31:19 +02:00
|
|
|
switch i.Type {
|
|
|
|
|
case InterfaceTypeServer, InterfaceTypeAny:
|
|
|
|
|
for _, peer := range peers {
|
|
|
|
|
for _, ip := range peer.Interface.Addresses {
|
|
|
|
|
allowedCidrs = append(allowedCidrs, ip.HostAddr())
|
|
|
|
|
}
|
|
|
|
|
if peer.ExtraAllowedIPsStr != "" {
|
|
|
|
|
extraIPs, err := CidrsFromString(peer.ExtraAllowedIPsStr)
|
|
|
|
|
if err == nil {
|
|
|
|
|
allowedCidrs = append(allowedCidrs, extraIPs...)
|
|
|
|
|
}
|
|
|
|
|
}
|
2023-10-20 04:53:51 +08:00
|
|
|
}
|
2025-10-12 14:31:19 +02:00
|
|
|
case InterfaceTypeClient:
|
|
|
|
|
for _, peer := range peers {
|
|
|
|
|
allowedIPs, err := CidrsFromString(peer.AllowedIPsStr.GetValue())
|
2023-08-04 13:34:18 +02:00
|
|
|
if err == nil {
|
2025-10-12 14:31:19 +02:00
|
|
|
allowedCidrs = append(allowedCidrs, allowedIPs...)
|
2023-08-04 13:34:18 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return allowedCidrs
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (i *Interface) ManageRoutingTable() bool {
|
|
|
|
|
routingTableStr := strings.ToLower(i.RoutingTable)
|
|
|
|
|
return routingTableStr != "off"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GetRoutingTable returns the routing table number or
|
|
|
|
|
//
|
|
|
|
|
// -1 if RoutingTable was set to "off" or an error occurred
|
|
|
|
|
func (i *Interface) GetRoutingTable() int {
|
2025-10-12 14:31:19 +02:00
|
|
|
|
2023-08-04 13:34:18 +02:00
|
|
|
routingTableStr := strings.ToLower(i.RoutingTable)
|
|
|
|
|
switch {
|
|
|
|
|
case routingTableStr == "":
|
|
|
|
|
return 0
|
|
|
|
|
case routingTableStr == "off":
|
|
|
|
|
return -1
|
|
|
|
|
case strings.HasPrefix(routingTableStr, "0x"):
|
2025-10-12 14:31:19 +02:00
|
|
|
if i.Backend != config.LocalBackendName {
|
|
|
|
|
return 0 // ignore numeric routing table numbers for non-local controllers
|
|
|
|
|
}
|
2023-08-04 13:34:18 +02:00
|
|
|
numberStr := strings.ReplaceAll(routingTableStr, "0x", "")
|
|
|
|
|
routingTable, err := strconv.ParseUint(numberStr, 16, 64)
|
|
|
|
|
if err != nil {
|
2025-03-02 08:51:13 +01:00
|
|
|
slog.Error("failed to parse routing table number", "table", routingTableStr, "error", err)
|
2023-08-04 13:34:18 +02:00
|
|
|
return -1
|
|
|
|
|
}
|
|
|
|
|
if routingTable > math.MaxInt32 {
|
2025-03-02 08:51:13 +01:00
|
|
|
slog.Error("routing table number too large", "table", routingTable, "max", math.MaxInt32)
|
2023-08-04 13:34:18 +02:00
|
|
|
return -1
|
|
|
|
|
}
|
|
|
|
|
return int(routingTable)
|
|
|
|
|
default:
|
2025-10-12 14:31:19 +02:00
|
|
|
if i.Backend != config.LocalBackendName {
|
|
|
|
|
return 0 // ignore numeric routing table numbers for non-local controllers
|
|
|
|
|
}
|
2023-08-04 13:34:18 +02:00
|
|
|
routingTable, err := strconv.Atoi(routingTableStr)
|
|
|
|
|
if err != nil {
|
2025-03-02 08:51:13 +01:00
|
|
|
slog.Error("failed to parse routing table number", "table", routingTableStr, "error", err)
|
|
|
|
|
return -1
|
|
|
|
|
}
|
|
|
|
|
if routingTable > math.MaxInt32 {
|
|
|
|
|
slog.Error("routing table number too large", "table", routingTable, "max", math.MaxInt32)
|
2023-08-04 13:34:18 +02:00
|
|
|
return -1
|
|
|
|
|
}
|
|
|
|
|
return routingTable
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type PhysicalInterface struct {
|
|
|
|
|
Identifier InterfaceIdentifier // device name, for example: wg0
|
|
|
|
|
KeyPair // private/public Key of the server interface
|
|
|
|
|
ListenPort int // the listening port, for example: 51820
|
|
|
|
|
|
|
|
|
|
Addresses []Cidr // the interface ip addresses
|
|
|
|
|
|
2024-10-15 15:44:47 +02:00
|
|
|
Mtu int // the device MTU
|
|
|
|
|
FirewallMark uint32 // a firewall mark
|
2023-08-04 13:34:18 +02:00
|
|
|
|
|
|
|
|
DeviceUp bool // device status
|
|
|
|
|
|
|
|
|
|
ImportSource string // import source (wgctrl, file, ...)
|
|
|
|
|
DeviceType string // device type (Linux kernel, userspace, ...)
|
|
|
|
|
|
|
|
|
|
BytesUpload uint64
|
|
|
|
|
BytesDownload uint64
|
2025-08-10 14:42:02 +02:00
|
|
|
|
|
|
|
|
backendExtras any // additional backend-specific extras, e.g., domain.MikrotikInterfaceExtras
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (p *PhysicalInterface) GetExtras() any {
|
|
|
|
|
return p.backendExtras
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (p *PhysicalInterface) SetExtras(extras any) {
|
|
|
|
|
switch extras.(type) {
|
|
|
|
|
case MikrotikInterfaceExtras: // OK
|
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
|
|
|
case PfsenseInterfaceExtras: // OK
|
|
|
|
|
default: // we only support MikrotikInterfaceExtras and PfsenseInterfaceExtras for now
|
2025-08-10 14:42:02 +02:00
|
|
|
panic(fmt.Sprintf("unsupported interface backend extras type %T", extras))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
p.backendExtras = extras
|
2023-08-04 13:34:18 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func ConvertPhysicalInterface(pi *PhysicalInterface) *Interface {
|
2025-08-10 14:42:02 +02:00
|
|
|
networks := make([]Cidr, 0, len(pi.Addresses))
|
|
|
|
|
for _, addr := range pi.Addresses {
|
|
|
|
|
networks = append(networks, addr.NetworkAddr())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// create a new basic interface with the data from the physical interface
|
2023-08-04 13:34:18 +02:00
|
|
|
iface := &Interface{
|
|
|
|
|
Identifier: pi.Identifier,
|
|
|
|
|
KeyPair: pi.KeyPair,
|
|
|
|
|
ListenPort: pi.ListenPort,
|
|
|
|
|
Addresses: pi.Addresses,
|
|
|
|
|
DnsStr: "",
|
|
|
|
|
DnsSearchStr: "",
|
|
|
|
|
Mtu: pi.Mtu,
|
|
|
|
|
FirewallMark: pi.FirewallMark,
|
|
|
|
|
RoutingTable: "",
|
|
|
|
|
PreUp: "",
|
|
|
|
|
PostUp: "",
|
|
|
|
|
PreDown: "",
|
|
|
|
|
PostDown: "",
|
|
|
|
|
SaveConfig: false,
|
|
|
|
|
DisplayName: string(pi.Identifier),
|
|
|
|
|
Type: InterfaceTypeAny,
|
|
|
|
|
DriverType: pi.DeviceType,
|
|
|
|
|
Disabled: nil,
|
2025-08-10 14:42:02 +02:00
|
|
|
PeerDefNetworkStr: CidrsToString(networks),
|
2023-08-04 13:34:18 +02:00
|
|
|
PeerDefDnsStr: "",
|
|
|
|
|
PeerDefDnsSearchStr: "",
|
|
|
|
|
PeerDefEndpoint: "",
|
2025-08-10 14:42:02 +02:00
|
|
|
PeerDefAllowedIPsStr: CidrsToString(networks),
|
2023-08-04 13:34:18 +02:00
|
|
|
PeerDefMtu: pi.Mtu,
|
|
|
|
|
PeerDefPersistentKeepalive: 0,
|
|
|
|
|
PeerDefFirewallMark: 0,
|
|
|
|
|
PeerDefRoutingTable: "",
|
|
|
|
|
PeerDefPreUp: "",
|
|
|
|
|
PeerDefPostUp: "",
|
|
|
|
|
PeerDefPreDown: "",
|
|
|
|
|
PeerDefPostDown: "",
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-10 14:42:02 +02:00
|
|
|
if pi.GetExtras() == nil {
|
|
|
|
|
return iface
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// enrich the data with controller-specific extras
|
|
|
|
|
now := time.Now()
|
|
|
|
|
switch pi.ImportSource {
|
|
|
|
|
case ControllerTypeMikrotik:
|
|
|
|
|
extras := pi.GetExtras().(MikrotikInterfaceExtras)
|
|
|
|
|
iface.DisplayName = extras.Comment
|
|
|
|
|
if extras.Disabled {
|
|
|
|
|
iface.Disabled = &now
|
|
|
|
|
} else {
|
|
|
|
|
iface.Disabled = nil
|
|
|
|
|
}
|
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
|
|
|
case ControllerTypePfsense:
|
|
|
|
|
extras := pi.GetExtras().(PfsenseInterfaceExtras)
|
|
|
|
|
iface.DisplayName = extras.Comment
|
|
|
|
|
if extras.Disabled {
|
|
|
|
|
iface.Disabled = &now
|
|
|
|
|
} else {
|
|
|
|
|
iface.Disabled = nil
|
|
|
|
|
}
|
2025-08-10 14:42:02 +02:00
|
|
|
}
|
|
|
|
|
|
2023-08-04 13:34:18 +02:00
|
|
|
return iface
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func MergeToPhysicalInterface(pi *PhysicalInterface, i *Interface) {
|
|
|
|
|
pi.Identifier = i.Identifier
|
|
|
|
|
pi.PublicKey = i.PublicKey
|
|
|
|
|
pi.PrivateKey = i.PrivateKey
|
|
|
|
|
pi.ListenPort = i.ListenPort
|
|
|
|
|
pi.Mtu = i.Mtu
|
|
|
|
|
pi.FirewallMark = i.FirewallMark
|
|
|
|
|
pi.DeviceUp = !i.IsDisabled()
|
|
|
|
|
pi.Addresses = i.Addresses
|
2025-08-10 14:42:02 +02:00
|
|
|
|
|
|
|
|
switch pi.ImportSource {
|
|
|
|
|
case ControllerTypeMikrotik:
|
|
|
|
|
extras := MikrotikInterfaceExtras{
|
|
|
|
|
Comment: i.DisplayName,
|
|
|
|
|
Disabled: i.IsDisabled(),
|
|
|
|
|
}
|
|
|
|
|
pi.SetExtras(extras)
|
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
|
|
|
case ControllerTypePfsense:
|
|
|
|
|
extras := PfsenseInterfaceExtras{
|
|
|
|
|
Comment: i.DisplayName,
|
|
|
|
|
Disabled: i.IsDisabled(),
|
|
|
|
|
}
|
|
|
|
|
pi.SetExtras(extras)
|
2025-08-10 14:42:02 +02:00
|
|
|
}
|
2023-08-04 13:34:18 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type RoutingTableInfo struct {
|
2025-10-12 14:31:19 +02:00
|
|
|
Interface Interface
|
|
|
|
|
AllowedIps []Cidr
|
|
|
|
|
FwMark uint32
|
|
|
|
|
Table int
|
|
|
|
|
TableStr string // the routing table number as string (used by mikrotik, linux uses the numeric value)
|
|
|
|
|
IsDeleted bool // true if the interface was deleted, false otherwise
|
2023-08-04 13:34:18 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (r RoutingTableInfo) String() string {
|
2025-10-12 14:31:19 +02:00
|
|
|
v4, v6 := CidrsPerFamily(r.AllowedIps)
|
|
|
|
|
return fmt.Sprintf("%s: fwmark=%d; table=%d; routes_4=%d; routes_6=%d", r.Interface.Identifier, r.FwMark, r.Table,
|
|
|
|
|
len(v4), len(v6))
|
2023-08-04 13:34:18 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (r RoutingTableInfo) ManagementEnabled() bool {
|
|
|
|
|
if r.Table == -1 {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (r RoutingTableInfo) GetRoutingTable() int {
|
|
|
|
|
if r.Table <= 0 {
|
2024-10-15 15:44:47 +02:00
|
|
|
return int(r.FwMark) // use the dynamic routing table which has the same number as the firewall mark
|
2023-08-04 13:34:18 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return r.Table
|
|
|
|
|
}
|
2025-08-10 14:42:02 +02:00
|
|
|
|
|
|
|
|
type IpFamily int
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
IpFamilyIPv4 IpFamily = unix.AF_INET
|
|
|
|
|
IpFamilyIPv6 IpFamily = unix.AF_INET6
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func (f IpFamily) String() string {
|
|
|
|
|
switch f {
|
|
|
|
|
case IpFamilyIPv4:
|
|
|
|
|
return "IPv4"
|
|
|
|
|
case IpFamilyIPv6:
|
|
|
|
|
return "IPv6"
|
|
|
|
|
default:
|
|
|
|
|
return "unknown"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// RouteRule represents a routing table rule.
|
|
|
|
|
type RouteRule struct {
|
|
|
|
|
InterfaceId InterfaceIdentifier
|
|
|
|
|
IpFamily IpFamily
|
|
|
|
|
FwMark uint32
|
|
|
|
|
Table int
|
|
|
|
|
HasDefault bool
|
|
|
|
|
}
|