2023-08-04 13:34:18 +02:00
package domain
import (
2025-09-21 13:02:12 +02:00
"errors"
2023-08-04 13:34:18 +02:00
"fmt"
"net"
"strings"
"time"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
2025-09-21 13:02:12 +02:00
"gorm.io/gorm"
2025-02-28 08:29:40 +01:00
"github.com/h44z/wg-portal/internal"
2025-05-02 18:48:35 +02:00
"github.com/h44z/wg-portal/internal/config"
2023-08-04 13:34:18 +02:00
)
type PeerIdentifier string
func ( i PeerIdentifier ) IsPublicKey ( ) bool {
_ , err := wgtypes . ParseKey ( string ( i ) )
if err != nil {
return false
}
return true
}
func ( i PeerIdentifier ) ToPublicKey ( ) wgtypes . Key {
publicKey , _ := wgtypes . ParseKey ( string ( i ) )
return publicKey
}
type Peer struct {
BaseModel
// WireGuard specific (for the [peer] section of the config file)
2024-10-15 15:44:47 +02:00
Endpoint ConfigOption [ string ] ` gorm:"embedded;embeddedPrefix:endpoint_" ` // the endpoint address
EndpointPublicKey ConfigOption [ string ] ` gorm:"embedded;embeddedPrefix:endpoint_pubkey_" ` // the endpoint public key
AllowedIPsStr ConfigOption [ string ] ` gorm:"embedded;embeddedPrefix:allowed_ips_str_" ` // all allowed ip subnets, comma seperated
ExtraAllowedIPsStr string // all allowed ip subnets on the server side, comma seperated
2025-05-02 18:48:35 +02:00
PresharedKey PreSharedKey ` gorm:"serializer:encstr" ` // the pre-shared Key of the peer
2024-10-15 15:44:47 +02:00
PersistentKeepalive ConfigOption [ int ] ` gorm:"embedded;embeddedPrefix:persistent_keep_alive_" ` // the persistent keep-alive interval
2023-08-04 13:34:18 +02:00
// WG Portal specific
2024-04-02 22:29:10 +02:00
DisplayName string // a nice display name/ description for the peer
Identifier PeerIdentifier ` gorm:"primaryKey;column:identifier" ` // peer unique identifier
UserIdentifier UserIdentifier ` gorm:"index;column:user_identifier" ` // the owner
2025-09-21 13:02:12 +02:00
User * User ` gorm:"-" ` // the owner user object; loaded automatically after fetch
2024-04-02 22:29:10 +02:00
InterfaceIdentifier InterfaceIdentifier ` gorm:"index;column:interface_identifier" ` // the interface id
Disabled * time . Time ` gorm:"column:disabled" ` // if this field is set, the peer is disabled
DisabledReason string // the reason why the peer has been disabled
ExpiresAt * time . Time ` gorm:"column:expires_at" ` // expiry dates for peers
Notes string ` form:"notes" binding:"omitempty" ` // a note field for peers
AutomaticallyCreated bool ` gorm:"column:auto_created" ` // specifies if the peer was automatically created
2023-08-04 13:34:18 +02:00
// Interface settings for the peer, used to generate the [interface] section in the peer config file
Interface PeerInterfaceConfig ` gorm:"embedded" `
}
func ( p * Peer ) IsDisabled ( ) bool {
return p . Disabled != nil
}
func ( p * Peer ) IsExpired ( ) bool {
if p . ExpiresAt == nil {
return false
}
if p . ExpiresAt . Before ( time . Now ( ) ) {
return true
}
return false
}
func ( p * Peer ) CheckAliveAddress ( ) string {
if p . Interface . CheckAliveAddress != "" {
return p . Interface . CheckAliveAddress
}
if len ( p . Interface . Addresses ) > 0 {
return p . Interface . Addresses [ 0 ] . Addr // take the first peer address
}
return ""
}
func ( p * Peer ) CopyCalculatedAttributes ( src * Peer ) {
p . BaseModel = src . BaseModel
}
func ( p * Peer ) GetConfigFileName ( ) string {
filename := ""
if p . DisplayName != "" {
filename = p . DisplayName
filename = strings . ReplaceAll ( filename , " " , "_" )
2025-04-19 13:12:31 +02:00
// Eliminate the automatically detected peer part,
// as it makes the filename indistinguishable among multiple auto-detected peers.
filename = strings . ReplaceAll ( filename , "Autodetected_" , "" )
filename = allowedFileNameRegex . ReplaceAllString ( filename , "" )
2023-08-04 13:34:18 +02:00
filename = internal . TruncateString ( filename , 16 )
filename += ".conf"
} else {
filename = fmt . Sprintf ( "wg_%s" , internal . TruncateString ( string ( p . Identifier ) , 8 ) )
2025-04-19 13:12:31 +02:00
filename = allowedFileNameRegex . ReplaceAllString ( filename , "" )
2023-08-04 13:34:18 +02:00
filename += ".conf"
}
return filename
}
func ( p * Peer ) ApplyInterfaceDefaults ( in * Interface ) {
p . Endpoint . TrySetValue ( in . PeerDefEndpoint )
p . EndpointPublicKey . TrySetValue ( in . PublicKey )
p . AllowedIPsStr . TrySetValue ( in . PeerDefAllowedIPsStr )
p . PersistentKeepalive . TrySetValue ( in . PeerDefPersistentKeepalive )
p . Interface . DnsStr . TrySetValue ( in . PeerDefDnsStr )
p . Interface . DnsSearchStr . TrySetValue ( in . PeerDefDnsSearchStr )
p . Interface . Mtu . TrySetValue ( in . PeerDefMtu )
p . Interface . FirewallMark . TrySetValue ( in . PeerDefFirewallMark )
p . Interface . RoutingTable . TrySetValue ( in . PeerDefRoutingTable )
p . Interface . PreUp . TrySetValue ( in . PeerDefPreUp )
p . Interface . PostUp . TrySetValue ( in . PeerDefPostUp )
p . Interface . PreDown . TrySetValue ( in . PeerDefPreDown )
p . Interface . PostDown . TrySetValue ( in . PeerDefPostDown )
}
2025-01-11 18:44:55 +01:00
func ( p * Peer ) GenerateDisplayName ( prefix string ) {
if prefix != "" {
prefix = fmt . Sprintf ( "%s " , strings . TrimSpace ( prefix ) ) // add a space after the prefix
}
p . DisplayName = fmt . Sprintf ( "%sPeer %s" , prefix , internal . TruncateString ( string ( p . Identifier ) , 8 ) )
}
2025-08-10 14:42:02 +02:00
// OverwriteUserEditableFields overwrites the user-editable fields of the peer with the values from the userPeer
2025-04-30 22:05:40 +02:00
func ( p * Peer ) OverwriteUserEditableFields ( userPeer * Peer , cfg * config . Config ) {
2025-01-26 11:35:24 +01:00
p . DisplayName = userPeer . DisplayName
2025-04-30 22:05:40 +02:00
if cfg . Core . EditableKeys {
p . Interface . PublicKey = userPeer . Interface . PublicKey
p . Interface . PrivateKey = userPeer . Interface . PrivateKey
p . PresharedKey = userPeer . PresharedKey
2025-06-08 11:17:04 +02:00
p . Identifier = userPeer . Identifier
2025-04-30 22:05:40 +02:00
}
2025-01-26 11:35:24 +01:00
p . Interface . Mtu = userPeer . Interface . Mtu
p . PersistentKeepalive = userPeer . PersistentKeepalive
p . ExpiresAt = userPeer . ExpiresAt
p . Disabled = userPeer . Disabled
p . DisabledReason = userPeer . DisabledReason
}
2023-08-04 13:34:18 +02:00
type PeerInterfaceConfig struct {
KeyPair // private/public Key of the peer
Type InterfaceType ` gorm:"column:iface_type" ` // the interface type (server, client, any)
2024-10-15 15:44:47 +02:00
Addresses [ ] Cidr ` gorm:"many2many:peer_addresses;" ` // the interface ip addresses
CheckAliveAddress string ` gorm:"column:check_alive_address" ` // optional ip address or DNS name that is used for ping checks
DnsStr ConfigOption [ string ] ` gorm:"embedded;embeddedPrefix:iface_dns_str_" ` // the dns server that should be set if the interface is up, comma separated
DnsSearchStr ConfigOption [ string ] ` gorm:"embedded;embeddedPrefix:iface_dns_search_str_" ` // the dns search option string that should be set if the interface is up, will be appended to DnsStr
Mtu ConfigOption [ int ] ` gorm:"embedded;embeddedPrefix:iface_mtu_" ` // the device MTU
FirewallMark ConfigOption [ uint32 ] ` gorm:"embedded;embeddedPrefix:iface_firewall_mark_" ` // a firewall mark
RoutingTable ConfigOption [ string ] ` gorm:"embedded;embeddedPrefix:iface_routing_table_" ` // the routing table
2023-08-04 13:34:18 +02:00
2024-10-15 15:44:47 +02:00
PreUp ConfigOption [ string ] ` gorm:"embedded;embeddedPrefix:iface_pre_up_" ` // action that is executed before the device is up
PostUp ConfigOption [ string ] ` gorm:"embedded;embeddedPrefix:iface_post_up_" ` // action that is executed after the device is up
PreDown ConfigOption [ string ] ` gorm:"embedded;embeddedPrefix:iface_pre_down_" ` // action that is executed before the device is down
PostDown ConfigOption [ string ] ` gorm:"embedded;embeddedPrefix:iface_post_down_" ` // action that is executed after the device is down
2023-08-04 13:34:18 +02:00
}
func ( p * PeerInterfaceConfig ) AddressStr ( ) string {
return CidrsToString ( p . Addresses )
}
type PhysicalPeer struct {
Identifier PeerIdentifier // peer unique identifier
Endpoint string // the endpoint address
AllowedIPs [ ] Cidr // all allowed ip subnets
KeyPair // private/public Key of the peer, for imports it only contains the public key as the private key is not known to the server
PresharedKey PreSharedKey // the pre-shared Key of the peer
PersistentKeepalive int // the persistent keep-alive interval
LastHandshake time . Time
ProtocolVersion int
BytesUpload uint64 // upload bytes are the number of bytes that the remote peer has sent to the server
BytesDownload uint64 // upload bytes are the number of bytes that the remote peer has received from the server
2025-08-10 14:42:02 +02:00
ImportSource string // import source (wgctrl, file, ...)
backendExtras any // additional backend-specific extras, e.g., domain.MikrotikPeerExtras
2023-08-04 13:34:18 +02:00
}
2025-08-10 14:42:02 +02:00
func ( p * PhysicalPeer ) GetPresharedKey ( ) * wgtypes . Key {
2023-08-04 13:34:18 +02:00
if p . PresharedKey == "" {
return nil
}
key , err := wgtypes . ParseKey ( string ( p . PresharedKey ) )
if err != nil {
return nil
}
return & key
}
2025-08-10 14:42:02 +02:00
func ( p * PhysicalPeer ) GetEndpointAddress ( ) * net . UDPAddr {
2023-08-04 13:34:18 +02:00
if p . Endpoint == "" {
return nil
}
addr , err := net . ResolveUDPAddr ( "udp" , p . Endpoint )
if err != nil {
return nil
}
return addr
}
2025-08-10 14:42:02 +02:00
func ( p * PhysicalPeer ) GetPersistentKeepaliveTime ( ) * time . Duration {
2023-08-04 13:34:18 +02:00
if p . PersistentKeepalive == 0 {
return nil
}
keepAliveDuration := time . Duration ( p . PersistentKeepalive ) * time . Second
return & keepAliveDuration
}
2025-08-10 14:42:02 +02:00
func ( p * PhysicalPeer ) GetAllowedIPs ( ) [ ] net . IPNet {
2023-08-04 13:34:18 +02:00
allowedIPs := make ( [ ] net . IPNet , len ( p . AllowedIPs ) )
for i , ip := range p . AllowedIPs {
allowedIPs [ i ] = * ip . IpNet ( )
}
return allowedIPs
}
2025-08-10 14:42:02 +02:00
func ( p * PhysicalPeer ) GetExtras ( ) any {
return p . backendExtras
}
func ( p * PhysicalPeer ) SetExtras ( extras any ) {
switch extras . ( type ) {
case MikrotikPeerExtras : // OK
case LocalPeerExtras : // 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 PfsensePeerExtras : // OK
default : // we only support MikrotikPeerExtras, LocalPeerExtras, and PfsensePeerExtras for now
2025-08-10 14:42:02 +02:00
panic ( fmt . Sprintf ( "unsupported peer backend extras type %T" , extras ) )
}
p . backendExtras = extras
}
2023-08-04 13:34:18 +02:00
func ConvertPhysicalPeer ( pp * PhysicalPeer ) * Peer {
peer := & Peer {
2024-10-15 15:44:47 +02:00
Endpoint : NewConfigOption ( pp . Endpoint , true ) ,
EndpointPublicKey : NewConfigOption ( "" , true ) ,
AllowedIPsStr : NewConfigOption ( "" , true ) ,
2023-08-04 13:34:18 +02:00
ExtraAllowedIPsStr : "" ,
PresharedKey : pp . PresharedKey ,
2024-10-15 15:44:47 +02:00
PersistentKeepalive : NewConfigOption ( pp . PersistentKeepalive , true ) ,
2023-08-04 13:34:18 +02:00
DisplayName : string ( pp . Identifier ) ,
Identifier : pp . Identifier ,
UserIdentifier : "" ,
InterfaceIdentifier : "" ,
Disabled : nil ,
Interface : PeerInterfaceConfig {
KeyPair : pp . KeyPair ,
} ,
}
2025-08-10 14:42:02 +02:00
if pp . GetExtras ( ) == nil {
return peer
}
// enrich the data with controller-specific extras
now := time . Now ( )
switch pp . ImportSource {
case ControllerTypeMikrotik :
extras := pp . GetExtras ( ) . ( MikrotikPeerExtras )
peer . Notes = extras . Comment
peer . DisplayName = extras . Name
if extras . ClientEndpoint != "" { // if the client endpoint is set, we assume that this is a client peer
peer . Endpoint = NewConfigOption ( extras . ClientEndpoint , true )
peer . Interface . Type = InterfaceTypeClient
peer . Interface . Addresses , _ = CidrsFromString ( extras . ClientAddress )
peer . Interface . DnsStr = NewConfigOption ( extras . ClientDns , true )
peer . PersistentKeepalive = NewConfigOption ( extras . ClientKeepalive , true )
} else {
peer . Interface . Type = InterfaceTypeServer
}
if extras . Disabled {
peer . Disabled = & now
peer . DisabledReason = "Disabled by Mikrotik controller"
} else {
peer . Disabled = nil
peer . DisabledReason = ""
}
case ControllerTypeLocal :
extras := pp . GetExtras ( ) . ( LocalPeerExtras )
if extras . Disabled {
peer . Disabled = & now
peer . DisabledReason = "Disabled by Local controller"
} else {
peer . Disabled = nil
peer . DisabledReason = ""
}
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 := pp . GetExtras ( ) . ( PfsensePeerExtras )
peer . Notes = extras . Comment
peer . DisplayName = extras . Name
if extras . ClientEndpoint != "" { // if the client endpoint is set, we assume that this is a client peer
peer . Endpoint = NewConfigOption ( extras . ClientEndpoint , true )
peer . Interface . Type = InterfaceTypeClient
peer . Interface . Addresses , _ = CidrsFromString ( extras . ClientAddress )
peer . Interface . DnsStr = NewConfigOption ( extras . ClientDns , true )
peer . PersistentKeepalive = NewConfigOption ( extras . ClientKeepalive , true )
} else {
peer . Interface . Type = InterfaceTypeServer
}
if extras . Disabled {
peer . Disabled = & now
peer . DisabledReason = "Disabled by pfSense controller"
} else {
peer . Disabled = nil
peer . DisabledReason = ""
}
2025-08-10 14:42:02 +02:00
}
2023-08-04 13:34:18 +02:00
return peer
}
func MergeToPhysicalPeer ( pp * PhysicalPeer , p * Peer ) {
pp . Identifier = p . Identifier
2025-10-03 17:30:14 +02:00
pp . PresharedKey = p . PresharedKey
pp . PublicKey = p . Interface . PublicKey
switch p . Interface . Type {
case InterfaceTypeClient : // this means that the corresponding interface in wgportal is a server interface
allowedIPs := make ( [ ] Cidr , len ( p . Interface . Addresses ) )
for i , ip := range p . Interface . Addresses {
allowedIPs [ i ] = ip . HostAddr ( ) // add the peer's host address to the allowed IPs
}
extraAllowedIPs , _ := CidrsFromString ( p . ExtraAllowedIPsStr )
pp . AllowedIPs = append ( allowedIPs , extraAllowedIPs ... )
case InterfaceTypeServer : // this means that the corresponding interface in wgportal is a client interface
2023-08-04 13:34:18 +02:00
allowedIPs , _ := CidrsFromString ( p . AllowedIPsStr . GetValue ( ) )
extraAllowedIPs , _ := CidrsFromString ( p . ExtraAllowedIPsStr )
pp . AllowedIPs = append ( allowedIPs , extraAllowedIPs ... )
2025-10-03 17:30:14 +02:00
pp . Endpoint = p . Endpoint . GetValue ( )
pp . PersistentKeepalive = p . PersistentKeepalive . GetValue ( )
case InterfaceTypeAny : // this means that the corresponding interface in wgportal has no specific type
2023-10-20 04:53:51 +08:00
allowedIPs := make ( [ ] Cidr , len ( p . Interface . Addresses ) )
for i , ip := range p . Interface . Addresses {
2025-10-03 17:30:14 +02:00
allowedIPs [ i ] = ip . HostAddr ( ) // add the peer's host address to the allowed IPs
2023-10-20 04:53:51 +08:00
}
2023-08-04 13:34:18 +02:00
extraAllowedIPs , _ := CidrsFromString ( p . ExtraAllowedIPsStr )
pp . AllowedIPs = append ( allowedIPs , extraAllowedIPs ... )
2025-10-03 17:30:14 +02:00
pp . Endpoint = p . Endpoint . GetValue ( )
pp . PersistentKeepalive = p . PersistentKeepalive . GetValue ( )
2023-08-04 13:34:18 +02:00
}
2025-08-10 14:42:02 +02:00
switch pp . ImportSource {
case ControllerTypeMikrotik :
extras := MikrotikPeerExtras {
Id : "" ,
Name : p . DisplayName ,
Comment : p . Notes ,
2025-09-09 21:43:16 +02:00
IsResponder : p . Interface . Type == InterfaceTypeClient ,
2025-08-10 14:42:02 +02:00
Disabled : p . IsDisabled ( ) ,
ClientEndpoint : p . Endpoint . GetValue ( ) ,
ClientAddress : CidrsToString ( p . Interface . Addresses ) ,
ClientDns : p . Interface . DnsStr . GetValue ( ) ,
ClientKeepalive : p . PersistentKeepalive . GetValue ( ) ,
}
pp . SetExtras ( extras )
case ControllerTypeLocal :
extras := LocalPeerExtras {
Disabled : p . IsDisabled ( ) ,
}
pp . 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 := PfsensePeerExtras {
Id : "" ,
Name : p . DisplayName ,
Comment : p . Notes ,
Disabled : p . IsDisabled ( ) ,
ClientEndpoint : p . Endpoint . GetValue ( ) ,
ClientAddress : CidrsToString ( p . Interface . Addresses ) ,
ClientDns : p . Interface . DnsStr . GetValue ( ) ,
ClientKeepalive : p . PersistentKeepalive . GetValue ( ) ,
}
pp . SetExtras ( extras )
2025-08-10 14:42:02 +02:00
}
2023-08-04 13:34:18 +02:00
}
type PeerCreationRequest struct {
UserIdentifiers [ ] string
2025-08-09 15:55:29 +02:00
Prefix string
2023-08-04 13:34:18 +02:00
}
2025-09-21 13:02:12 +02:00
// AfterFind is a GORM hook that automatically loads the associated User object
// based on the UserIdentifier field. If the identifier is empty or no user is
// found, the User field is set to nil.
func ( p * Peer ) AfterFind ( tx * gorm . DB ) error {
if p == nil {
return nil
}
if p . UserIdentifier == "" {
p . User = nil
return nil
}
var u User
if err := tx . Where ( "identifier = ?" , p . UserIdentifier ) . First ( & u ) . Error ; err != nil {
if errors . Is ( err , gorm . ErrRecordNotFound ) {
p . User = nil
return nil
}
return err
}
p . User = & u
return nil
}