V2 alpha - initial version (#172)

Initial alpha codebase for version 2 of WireGuard Portal.
This version is considered unstable and incomplete (for example, no public REST API)! 
Use with care!


Fixes/Implements the following issues:
 - OAuth support #154, #1 
 - New Web UI with internationalisation support #98, #107, #89, #62
 - Postgres Support #49 
 - Improved Email handling #47, #119 
 - DNS Search Domain support #46 
 - Bugfixes #94, #48 

---------

Co-authored-by: Fabian Wechselberger <wechselbergerf@hotmail.com>
This commit is contained in:
h44z
2023-08-04 13:34:18 +02:00
committed by GitHub
parent b3a5f2ac60
commit 8b820a5adf
788 changed files with 46139 additions and 11281 deletions

20
internal/domain/audit.go Normal file
View File

@@ -0,0 +1,20 @@
package domain
import "time"
type AuditSeverityLevel string
const AuditSeverityLevelLow AuditSeverityLevel = "low"
const AuditSeverityLevelMedium AuditSeverityLevel = "medium"
const AuditSeverityLevelHigh AuditSeverityLevel = "high"
type AuditEntry struct {
UniqueId uint64 `gorm:"primaryKey;autoIncrement:true;column:id"`
CreatedAt time.Time `gorm:"column:created_at;index:idx_au_created"`
Severity AuditSeverityLevel `gorm:"column:severity;index:idx_au_severity"`
Origin string `gorm:"column:origin"` // origin: for example user auth, stats, ...
Message string `gorm:"column:message"`
}

51
internal/domain/auth.go Normal file
View File

@@ -0,0 +1,51 @@
package domain
import (
"context"
"golang.org/x/oauth2"
)
type LoginProvider string
type LoginProviderInfo struct {
Identifier string
Name string
ProviderUrl string
CallbackUrl string
}
type AuthenticatorUserInfo struct {
Identifier UserIdentifier
Email string
Firstname string
Lastname string
Phone string
Department string
IsAdmin bool
}
type AuthenticatorType string
const (
AuthenticatorTypeOAuth AuthenticatorType = "oauth"
AuthenticatorTypeOidc AuthenticatorType = "oidc"
)
type OauthAuthenticator interface {
GetName() string
GetType() AuthenticatorType
AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string
Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error)
GetUserInfo(ctx context.Context, token *oauth2.Token, nonce string) (map[string]interface{}, error)
ParseUserInfo(raw map[string]interface{}) (*AuthenticatorUserInfo, error)
RegistrationEnabled() bool
}
type LdapAuthenticator interface {
GetName() string
PlaintextAuthentication(userId UserIdentifier, plainPassword string) error
GetUserInfo(ctx context.Context, username UserIdentifier) (map[string]interface{}, error)
ParseUserInfo(raw map[string]interface{}) (*AuthenticatorUserInfo, error)
RegistrationEnabled() bool
}

37
internal/domain/base.go Normal file
View File

@@ -0,0 +1,37 @@
package domain
import (
"time"
)
type BaseModel struct {
CreatedBy string
UpdatedBy string
CreatedAt time.Time
UpdatedAt time.Time
}
type PrivateString string
func (PrivateString) MarshalJSON() ([]byte, error) {
return []byte(`""`), nil
}
func (PrivateString) String() string {
return ""
}
const (
DisabledReasonExpired = "expired"
DisabledReasonDeleted = "deleted"
DisabledReasonUserEdit = "user edit action"
DisabledReasonUserCreate = "user create action"
DisabledReasonAdminEdit = "admin edit action"
DisabledReasonAdminCreate = "admin create action"
DisabledReasonApiEdit = "api edit action"
DisabledReasonApiCreate = "api create action"
DisabledReasonLdapMissing = "missing in ldap"
DisabledReasonUserMissing = "missing user"
DisabledReasonMigrationDummy = "migration dummy user"
DisabledReasonInterfaceMissing = "missing WireGuard interface"
)

View File

@@ -0,0 +1,74 @@
package domain
import (
"context"
"fmt"
"github.com/gin-gonic/gin"
)
const CtxUserInfo = "userInfo"
const (
CtxSystemAdminId = "_WG_SYS_ADMIN_"
CtxUnknownUserId = "_WG_SYS_UNKNOWN_"
)
type ContextUserInfo struct {
Id UserIdentifier
IsAdmin bool
}
func (u *ContextUserInfo) String() string {
return fmt.Sprintf("%s|%t", u.Id, u.IsAdmin)
}
func (u *ContextUserInfo) UserId() string {
return string(u.Id)
}
func DefaultContextUserInfo() *ContextUserInfo {
return &ContextUserInfo{
Id: CtxUnknownUserId,
IsAdmin: false,
}
}
func SystemAdminContextUserInfo() *ContextUserInfo {
return &ContextUserInfo{
Id: CtxSystemAdminId,
IsAdmin: true,
}
}
func SetUserInfoFromGin(c *gin.Context) context.Context {
ginUserInfo, exists := c.Get(CtxUserInfo)
info := DefaultContextUserInfo()
if exists {
if ginInfo, ok := ginUserInfo.(*ContextUserInfo); ok {
info = ginInfo
}
}
ctx := SetUserInfo(c.Request.Context(), info)
return ctx
}
func SetUserInfo(ctx context.Context, info *ContextUserInfo) context.Context {
ctx = context.WithValue(ctx, CtxUserInfo, info)
return ctx
}
func GetUserInfo(ctx context.Context) *ContextUserInfo {
rawInfo := ctx.Value(CtxUserInfo)
if rawInfo == nil {
return DefaultContextUserInfo()
}
if info, ok := rawInfo.(*ContextUserInfo); ok {
return info
}
return DefaultContextUserInfo()
}

67
internal/domain/crypto.go Normal file
View File

@@ -0,0 +1,67 @@
package domain
import (
"encoding/base64"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
type KeyPair struct {
PrivateKey string
PublicKey string
}
func (p KeyPair) GetPrivateKeyBytes() []byte {
data, _ := base64.StdEncoding.DecodeString(p.PrivateKey)
return data
}
func (p KeyPair) GetPublicKeyBytes() []byte {
data, _ := base64.StdEncoding.DecodeString(p.PublicKey)
return data
}
func (p KeyPair) GetPrivateKey() wgtypes.Key {
key, _ := wgtypes.ParseKey(p.PrivateKey)
return key
}
func (p KeyPair) GetPublicKey() wgtypes.Key {
key, _ := wgtypes.ParseKey(p.PublicKey)
return key
}
type PreSharedKey string
func NewFreshKeypair() (KeyPair, error) {
privateKey, err := wgtypes.GeneratePrivateKey()
if err != nil {
return KeyPair{}, err
}
return KeyPair{
PrivateKey: privateKey.String(),
PublicKey: privateKey.PublicKey().String(),
}, nil
}
func NewPreSharedKey() (PreSharedKey, error) {
preSharedKey, err := wgtypes.GenerateKey()
if err != nil {
return "", err
}
return PreSharedKey(preSharedKey.String()), nil
}
func KeyBytesToString(key []byte) string {
return base64.StdEncoding.EncodeToString(key)
}
func PublicKeyFromPrivateKey(key string) string {
privKey, err := wgtypes.ParseKey(key)
if err != nil {
return ""
}
return privKey.PublicKey().String()
}

View File

@@ -0,0 +1,6 @@
package domain
import "errors"
var ErrNotFound = errors.New("record not found")
var ErrNotUnique = errors.New("record not unique")

View File

@@ -0,0 +1,246 @@
package domain
import (
"fmt"
"github.com/h44z/wg-portal/internal"
"github.com/sirupsen/logrus"
"math"
"regexp"
"strconv"
"strings"
"time"
)
const (
InterfaceTypeServer InterfaceType = "server"
InterfaceTypeClient InterfaceType = "client"
InterfaceTypeAny InterfaceType = "any"
)
type InterfaceIdentifier string
type InterfaceType string
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
FirewallMark int32 // a firewall mark
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
DisplayName string // a nice display name/ description for the interface
Type InterfaceType // the interface type, either InterfaceTypeServer or InterfaceTypeClient
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
// 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
PeerDefFirewallMark int32 // default firewall mark
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
}
func (i *Interface) IsValid() bool {
return true // TODO: implement check
}
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 {
reg := regexp.MustCompile("[^a-zA-Z0-9-_]+")
filename := fmt.Sprintf("%s", internal.TruncateString(string(i.Identifier), 8))
filename = reg.ReplaceAllString(filename, "")
filename += ".conf"
return filename
}
func (i *Interface) GetAllowedIPs(peers []Peer) []Cidr {
var allowedCidrs []Cidr
for _, peer := range peers {
allowedCidrs = append(allowedCidrs, peer.Interface.Addresses...)
if peer.ExtraAllowedIPsStr != "" {
extraIPs, err := CidrsFromString(peer.ExtraAllowedIPsStr)
if err == nil {
allowedCidrs = append(allowedCidrs, extraIPs...)
}
}
}
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 {
routingTableStr := strings.ToLower(i.RoutingTable)
switch {
case routingTableStr == "":
return 0
case routingTableStr == "off":
return -1
case strings.HasPrefix(routingTableStr, "0x"):
numberStr := strings.ReplaceAll(routingTableStr, "0x", "")
routingTable, err := strconv.ParseUint(numberStr, 16, 64)
if err != nil {
logrus.Errorf("invalid hex routing table %s: %v", routingTableStr, err)
return -1
}
if routingTable > math.MaxInt32 {
logrus.Errorf("invalid routing table %s, too big", routingTableStr)
return -1
}
return int(routingTable)
default:
routingTable, err := strconv.Atoi(routingTableStr)
if err != nil {
logrus.Errorf("invalid routing table %s: %v", routingTableStr, err)
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
Mtu int // the device MTU
FirewallMark int32 // a firewall mark
DeviceUp bool // device status
ImportSource string // import source (wgctrl, file, ...)
DeviceType string // device type (Linux kernel, userspace, ...)
BytesUpload uint64
BytesDownload uint64
}
func ConvertPhysicalInterface(pi *PhysicalInterface) *Interface {
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,
PeerDefNetworkStr: "",
PeerDefDnsStr: "",
PeerDefDnsSearchStr: "",
PeerDefEndpoint: "",
PeerDefAllowedIPsStr: "",
PeerDefMtu: pi.Mtu,
PeerDefPersistentKeepalive: 0,
PeerDefFirewallMark: 0,
PeerDefRoutingTable: "",
PeerDefPreUp: "",
PeerDefPostUp: "",
PeerDefPreDown: "",
PeerDefPostDown: "",
}
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
}
type RoutingTableInfo struct {
FwMark int
Table int
}
func (r RoutingTableInfo) String() string {
return fmt.Sprintf("%d -> %d", r.FwMark, r.Table)
}
func (r RoutingTableInfo) ManagementEnabled() bool {
if r.Table == -1 {
return false
}
return true
}
func (r RoutingTableInfo) GetRoutingTable() int {
if r.Table <= 0 {
return r.FwMark // use the dynamic routing table which has the same number as the firewall mark
}
return r.Table
}

201
internal/domain/ip.go Normal file
View File

@@ -0,0 +1,201 @@
package domain
import (
"github.com/vishvananda/netlink"
"net"
"net/netip"
"strings"
)
type Cidr struct {
Cidr string `gorm:"primaryKey;column:cidr"` // Sqlite/GORM does not support composite primary keys...
Addr string `gorm:"column:addr"`
NetLength int `gorm:"column:net_len"`
}
func (c Cidr) Prefix() netip.Prefix {
return netip.PrefixFrom(netip.MustParseAddr(c.Addr), c.NetLength)
}
func (c Cidr) String() string {
return c.Prefix().String()
}
func (c Cidr) IsValid() bool {
return c.Prefix().IsValid()
}
func CidrFromString(str string) (Cidr, error) {
prefix, err := netip.ParsePrefix(strings.TrimSpace(str))
if err != nil {
return Cidr{}, err
}
return CidrFromPrefix(prefix), nil
}
func CidrsFromString(str string) ([]Cidr, error) {
strParts := strings.Split(str, ",")
cidrs := make([]Cidr, len(strParts))
for i, cidrStr := range strParts {
cidr, err := CidrFromString(cidrStr)
if err != nil {
return nil, err
}
cidrs[i] = cidr
}
return cidrs, nil
}
func CidrsMust(cidrs []Cidr, err error) []Cidr {
if err != nil {
panic(err)
}
return cidrs
}
func CidrsFromArray(strs []string) ([]Cidr, error) {
cidrs := make([]Cidr, len(strs))
for i, cidrStr := range strs {
cidr, err := CidrFromString(cidrStr)
if err != nil {
return nil, err
}
cidrs[i] = cidr
}
return cidrs, nil
}
func CidrFromPrefix(prefix netip.Prefix) Cidr {
return Cidr{
Cidr: prefix.String(),
Addr: prefix.Addr().String(),
NetLength: prefix.Bits(),
}
}
func CidrFromIpNet(ipNet net.IPNet) Cidr {
prefix, _ := CidrFromString(ipNet.String())
return prefix
}
func CidrFromNetlinkAddr(addr netlink.Addr) Cidr {
prefix, _ := CidrFromString(addr.IPNet.String())
return prefix
}
func (c Cidr) IpNet() *net.IPNet {
ip, cidr, _ := net.ParseCIDR(c.String())
cidr.IP = ip
return cidr
}
func (c Cidr) NetlinkAddr() *netlink.Addr {
return &netlink.Addr{
IPNet: c.IpNet(),
}
}
func (c Cidr) IsV4() bool {
return c.Prefix().Addr().Is4()
}
// BroadcastAddr returns the last address in the given network (for IPv6), or the broadcast address.
func (c Cidr) BroadcastAddr() Cidr {
prefix := c.Prefix()
if !prefix.IsValid() {
return Cidr{}
}
a16 := prefix.Addr().As16()
var off uint8
var bits uint8 = 128
if prefix.Addr().Is4() {
off = 12
bits = 32
}
for b := uint8(prefix.Bits()); b < bits; b++ {
byteNum, bitInByte := b/8, 7-(b%8)
a16[off+byteNum] |= 1 << uint(bitInByte)
}
if prefix.Addr().Is4() {
addr := netip.AddrFrom16(a16).Unmap()
return Cidr{
Cidr: netip.PrefixFrom(addr, prefix.Bits()).String(),
Addr: addr.String(),
NetLength: prefix.Bits(),
}
} else {
addr := netip.AddrFrom16(a16) // doesn't unmap
return Cidr{
Cidr: netip.PrefixFrom(addr, prefix.Bits()).String(),
Addr: addr.String(), // doesn't unmap
NetLength: prefix.Bits(),
}
}
}
// NetworkAddr returns the network address in the given prefix.
func (c Cidr) NetworkAddr() Cidr {
prefix := c.Prefix()
if !prefix.IsValid() {
return Cidr{}
}
return CidrFromPrefix(prefix.Masked())
}
func (c Cidr) FirstAddr() Cidr {
prefix := c.Prefix()
firstAddr := prefix.Masked().Addr().Next()
return Cidr{
Cidr: netip.PrefixFrom(firstAddr, c.NetLength).String(),
Addr: firstAddr.String(),
NetLength: prefix.Bits(),
}
}
func (c Cidr) NextAddr() Cidr {
prefix := c.Prefix()
nextAddr := prefix.Addr().Next()
return Cidr{
Cidr: netip.PrefixFrom(nextAddr, c.NetLength).String(),
Addr: nextAddr.String(),
NetLength: prefix.Bits(),
}
}
func (c Cidr) HostAddr() Cidr {
return Cidr{
Cidr: netip.PrefixFrom(c.Prefix().Addr(), c.Prefix().Addr().BitLen()).String(),
Addr: c.Addr,
NetLength: c.Prefix().Addr().BitLen(),
}
}
func (c Cidr) NextSubnet() Cidr {
prefix := c.Prefix()
nextAddr := c.BroadcastAddr().Prefix().Addr().Next()
return Cidr{
Cidr: netip.PrefixFrom(nextAddr, c.NetLength).String(),
Addr: nextAddr.String(),
NetLength: prefix.Bits(),
}
}
func CidrsToString(slice []Cidr) string {
return strings.Join(CidrsToStringSlice(slice), ",")
}
func CidrsToStringSlice(slice []Cidr) []string {
cidrs := make([]string, len(slice))
for i, cidr := range slice {
cidrs[i] = cidr.String()
}
return cidrs
}

204
internal/domain/ip_test.go Normal file
View File

@@ -0,0 +1,204 @@
package domain
import (
"net/netip"
"reflect"
"testing"
)
func TestCidrFromString(t *testing.T) {
type args struct {
str string
}
tests := []struct {
name string
args args
want Cidr
wantErr bool
}{
{
name: "IPv4",
args: args{str: "1.2.3.4/24"},
want: CidrFromPrefix(netip.MustParsePrefix("1.2.3.4/24")),
wantErr: false,
},
{
name: "IPv4 Network",
args: args{str: "1.2.3.0/24"},
want: CidrFromPrefix(netip.MustParsePrefix("1.2.3.0/24")),
wantErr: false,
},
{
name: "IPv4 error",
args: args{str: "1.1/24"},
want: Cidr{},
wantErr: true,
},
{
name: "IPv6 short",
args: args{str: "fe00:1234::1/64"},
want: CidrFromPrefix(netip.MustParsePrefix("fe00:1234::1/64")),
wantErr: false,
},
{
name: "IPv6",
args: args{str: "2A02:810A:900:333E:3B74:D237:E076:8B36/128"},
want: CidrFromPrefix(netip.MustParsePrefix("2A02:810A:900:333E:3B74:D237:E076:8B36/128")),
wantErr: false,
},
{
name: "IPv6 Network",
args: args{str: "fe00::/56"},
want: CidrFromPrefix(netip.MustParsePrefix("fe00::/56")),
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := CidrFromString(tt.args.str)
if (err != nil) != tt.wantErr {
t.Errorf("CidrFromString() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("CidrFromString() got = %v, want %v", got, tt.want)
}
})
}
}
func TestCidr_BroadcastAddr(t *testing.T) {
type fields struct {
Prefix netip.Prefix
}
tests := []struct {
name string
fields fields
want Cidr
}{
{
name: "V4",
fields: fields{Prefix: netip.MustParsePrefix("1.2.3.4/24")},
want: CidrFromPrefix(netip.MustParsePrefix("1.2.3.255/24")),
},
{
name: "V6",
fields: fields{Prefix: netip.MustParsePrefix("fe00:d3ad:b33f:c0d3::/64")},
want: CidrFromPrefix(netip.MustParsePrefix("fe00:d3ad:b33f:c0d3:ffff:ffff:ffff:ffff/64")),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := CidrFromPrefix(tt.fields.Prefix)
if got := c.BroadcastAddr(); !reflect.DeepEqual(got, tt.want) {
t.Errorf("BroadcastAddr() = %v, want %v", got, tt.want)
}
})
}
}
func TestCidr_NetworkAddr(t *testing.T) {
type fields struct {
Prefix netip.Prefix
}
tests := []struct {
name string
fields fields
want Cidr
}{
{
name: "V4",
fields: fields{Prefix: netip.MustParsePrefix("1.2.3.4/24")},
want: CidrFromPrefix(netip.MustParsePrefix("1.2.3.0/24")),
},
{
name: "V6",
fields: fields{Prefix: netip.MustParsePrefix("fe00:d3ad:b33f:c0d3::1234/64")},
want: CidrFromPrefix(netip.MustParsePrefix("fe00:d3ad:b33f:c0d3::/64")),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := CidrFromPrefix(tt.fields.Prefix)
if got := c.NetworkAddr(); !reflect.DeepEqual(got, tt.want) {
t.Errorf("NetworkAddr() = %v, want %v", got, tt.want)
}
})
}
}
func TestCidr_NextAddr(t *testing.T) {
type fields struct {
Prefix netip.Prefix
}
tests := []struct {
name string
fields fields
want Cidr
}{
{
name: "V4 normal",
fields: fields{Prefix: netip.MustParsePrefix("1.2.3.4/24")},
want: CidrFromPrefix(netip.MustParsePrefix("1.2.3.5/24")),
},
{
name: "V4 broadcast",
fields: fields{Prefix: netip.MustParsePrefix("1.2.3.254/24")},
want: CidrFromPrefix(netip.MustParsePrefix("1.2.3.255/24")),
},
{
name: "V4 overflow",
fields: fields{Prefix: netip.MustParsePrefix("1.2.3.255/24")},
want: CidrFromPrefix(netip.MustParsePrefix("1.2.4.0/24")),
},
{
name: "V6 normal",
fields: fields{Prefix: netip.MustParsePrefix("fe00::1/64")},
want: CidrFromPrefix(netip.MustParsePrefix("fe00::2/64")),
},
{
name: "V6 overflow",
fields: fields{Prefix: netip.MustParsePrefix("fe00::ffff:ffff:ffff:ffff/64")},
want: CidrFromPrefix(netip.MustParsePrefix("fe00:0:0:1::/64")),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := CidrFromPrefix(tt.fields.Prefix)
if got := c.NextAddr(); !reflect.DeepEqual(got, tt.want) {
t.Errorf("NextAddr() = %v, want %v", got, tt.want)
}
})
}
}
func TestCidr_NextSubnet(t *testing.T) {
type fields struct {
Prefix netip.Prefix
}
tests := []struct {
name string
fields fields
want Cidr
}{
{
name: "V4",
fields: fields{Prefix: netip.MustParsePrefix("1.2.3.4/24")},
want: CidrFromPrefix(netip.MustParsePrefix("1.2.4.0/24")),
},
{
name: "V4 bigger subnet",
fields: fields{Prefix: netip.MustParsePrefix("1.2.3.4/16")},
want: CidrFromPrefix(netip.MustParsePrefix("1.3.0.0/16")),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := CidrFromPrefix(tt.fields.Prefix)
if got := c.NextSubnet(); !reflect.DeepEqual(got, tt.want) {
t.Errorf("NextSubnet() = %v, want %v", got, tt.want)
}
})
}
}

18
internal/domain/mail.go Normal file
View File

@@ -0,0 +1,18 @@
package domain
import "io"
type MailOptions struct {
ReplyTo string // defaults to the sender
HtmlBody string // if html body is empty, a text-only email will be sent
Cc []string
Bcc []string
Attachments []MailAttachment
}
type MailAttachment struct {
Name string
ContentType string
Data io.Reader
Embedded bool
}

113
internal/domain/options.go Normal file
View File

@@ -0,0 +1,113 @@
package domain
type StringConfigOption struct {
Value string `gorm:"column:v"`
Overridable bool `gorm:"column:o"`
}
func (o StringConfigOption) GetValue() string {
return o.Value
}
func (o *StringConfigOption) SetValue(value string) {
o.Value = value
}
func (o *StringConfigOption) TrySetValue(value string) bool {
if o.Overridable {
o.Value = value
return true
}
return false
}
func NewStringConfigOption(value string, overridable bool) StringConfigOption {
return StringConfigOption{
Value: value,
Overridable: overridable,
}
}
type IntConfigOption struct {
Value int `gorm:"column:v"`
Overridable bool `gorm:"column:o"`
}
func (o IntConfigOption) GetValue() int {
return o.Value
}
func (o *IntConfigOption) SetValue(value int) {
o.Value = value
}
func (o *IntConfigOption) TrySetValue(value int) bool {
if o.Overridable {
o.Value = value
return true
}
return false
}
func NewIntConfigOption(value int, overridable bool) IntConfigOption {
return IntConfigOption{
Value: value,
Overridable: overridable,
}
}
type Int32ConfigOption struct {
Value int32 `gorm:"column:v"`
Overridable bool `gorm:"column:o"`
}
func (o Int32ConfigOption) GetValue() int32 {
return o.Value
}
func (o *Int32ConfigOption) SetValue(value int32) {
o.Value = value
}
func (o *Int32ConfigOption) TrySetValue(value int32) bool {
if o.Overridable {
o.Value = value
return true
}
return false
}
func NewInt32ConfigOption(value int32, overridable bool) Int32ConfigOption {
return Int32ConfigOption{
Value: value,
Overridable: overridable,
}
}
type BoolConfigOption struct {
Value bool `gorm:"column:v"`
Overridable bool `gorm:"column:o"`
}
func (o BoolConfigOption) GetValue() bool {
return o.Value
}
func (o *BoolConfigOption) SetValue(value bool) {
o.Value = value
}
func (o *BoolConfigOption) TrySetValue(value bool) bool {
if o.Overridable {
o.Value = value
return true
}
return false
}
func NewBoolConfigOption(value bool, overridable bool) BoolConfigOption {
return BoolConfigOption{
Value: value,
Overridable: overridable,
}
}

243
internal/domain/peer.go Normal file
View File

@@ -0,0 +1,243 @@
package domain
import (
"fmt"
"github.com/h44z/wg-portal/internal"
"net"
"regexp"
"strings"
"time"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
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)
Endpoint StringConfigOption `gorm:"embedded;embeddedPrefix:endpoint_"` // the endpoint address
EndpointPublicKey StringConfigOption `gorm:"embedded;embeddedPrefix:endpoint_pubkey_"` // the endpoint public key
AllowedIPsStr StringConfigOption `gorm:"embedded;embeddedPrefix:allowed_ips_str_"` // all allowed ip subnets, comma seperated
ExtraAllowedIPsStr string // all allowed ip subnets on the server side, comma seperated
PresharedKey PreSharedKey // the pre-shared Key of the peer
PersistentKeepalive IntConfigOption `gorm:"embedded;embeddedPrefix:persistent_keep_alive_"` // the persistent keep-alive interval
// WG Portal specific
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
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
// 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 := ""
reg := regexp.MustCompile("[^a-zA-Z0-9-_]+")
if p.DisplayName != "" {
filename = p.DisplayName
filename = strings.ReplaceAll(filename, " ", "_")
filename = reg.ReplaceAllString(filename, "")
filename = internal.TruncateString(filename, 16)
filename += ".conf"
} else {
filename = fmt.Sprintf("wg_%s", internal.TruncateString(string(p.Identifier), 8))
filename = reg.ReplaceAllString(filename, "")
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)
}
type PeerInterfaceConfig struct {
KeyPair // private/public Key of the peer
Type InterfaceType `gorm:"column:iface_type"` // the interface type (server, client, any)
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 StringConfigOption `gorm:"embedded;embeddedPrefix:iface_dns_str_"` // the dns server that should be set if the interface is up, comma separated
DnsSearchStr StringConfigOption `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 IntConfigOption `gorm:"embedded;embeddedPrefix:iface_mtu_"` // the device MTU
FirewallMark Int32ConfigOption `gorm:"embedded;embeddedPrefix:iface_firewall_mark_"` // a firewall mark
RoutingTable StringConfigOption `gorm:"embedded;embeddedPrefix:iface_routing_table_"` // the routing table
PreUp StringConfigOption `gorm:"embedded;embeddedPrefix:iface_pre_up_"` // action that is executed before the device is up
PostUp StringConfigOption `gorm:"embedded;embeddedPrefix:iface_post_up_"` // action that is executed after the device is up
PreDown StringConfigOption `gorm:"embedded;embeddedPrefix:iface_pre_down_"` // action that is executed before the device is down
PostDown StringConfigOption `gorm:"embedded;embeddedPrefix:iface_post_down_"` // action that is executed after the device is down
}
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
}
func (p PhysicalPeer) GetPresharedKey() *wgtypes.Key {
if p.PresharedKey == "" {
return nil
}
key, err := wgtypes.ParseKey(string(p.PresharedKey))
if err != nil {
return nil
}
return &key
}
func (p PhysicalPeer) GetEndpointAddress() *net.UDPAddr {
if p.Endpoint == "" {
return nil
}
addr, err := net.ResolveUDPAddr("udp", p.Endpoint)
if err != nil {
return nil
}
return addr
}
func (p PhysicalPeer) GetPersistentKeepaliveTime() *time.Duration {
if p.PersistentKeepalive == 0 {
return nil
}
keepAliveDuration := time.Duration(p.PersistentKeepalive) * time.Second
return &keepAliveDuration
}
func (p PhysicalPeer) GetAllowedIPs() []net.IPNet {
allowedIPs := make([]net.IPNet, len(p.AllowedIPs))
for i, ip := range p.AllowedIPs {
allowedIPs[i] = *ip.IpNet()
}
return allowedIPs
}
func ConvertPhysicalPeer(pp *PhysicalPeer) *Peer {
peer := &Peer{
Endpoint: StringConfigOption{Value: pp.Endpoint, Overridable: true},
EndpointPublicKey: StringConfigOption{Value: "", Overridable: true},
AllowedIPsStr: StringConfigOption{Value: "", Overridable: true},
ExtraAllowedIPsStr: "",
PresharedKey: pp.PresharedKey,
PersistentKeepalive: IntConfigOption{Value: pp.PersistentKeepalive, Overridable: true},
DisplayName: string(pp.Identifier),
Identifier: pp.Identifier,
UserIdentifier: "",
InterfaceIdentifier: "",
Disabled: nil,
Interface: PeerInterfaceConfig{
KeyPair: pp.KeyPair,
},
}
return peer
}
func MergeToPhysicalPeer(pp *PhysicalPeer, p *Peer) {
pp.Identifier = p.Identifier
pp.Endpoint = p.Endpoint.GetValue()
if p.Interface.Type == InterfaceTypeServer {
allowedIPs, _ := CidrsFromString(p.AllowedIPsStr.GetValue())
extraAllowedIPs, _ := CidrsFromString(p.ExtraAllowedIPsStr)
pp.AllowedIPs = append(allowedIPs, extraAllowedIPs...)
} else {
allowedIPs := p.Interface.Addresses
extraAllowedIPs, _ := CidrsFromString(p.ExtraAllowedIPsStr)
pp.AllowedIPs = append(allowedIPs, extraAllowedIPs...)
}
pp.PresharedKey = p.PresharedKey
pp.PublicKey = p.Interface.PublicKey
pp.PersistentKeepalive = p.PersistentKeepalive.GetValue()
}
type PeerCreationRequest struct {
UserIdentifiers []string
Suffix string
}

View File

@@ -0,0 +1,37 @@
package domain
import "time"
type PeerStatus struct {
PeerId PeerIdentifier `gorm:"primaryKey;column:identifier"`
UpdatedAt time.Time `gorm:"column:updated_at"`
IsPingable bool `gorm:"column:pingable"`
LastPing *time.Time `gorm:"column:last_ping"`
BytesReceived uint64 `gorm:"column:received"`
BytesTransmitted uint64 `gorm:"column:transmitted"`
LastHandshake *time.Time `gorm:"column:last_handshake"`
Endpoint string `gorm:"column:endpoint"`
LastSessionStart *time.Time `gorm:"column:last_session_start"`
}
func (s PeerStatus) IsConnected() bool {
oldestHandshakeTime := time.Now().Add(-2 * time.Minute) // if a handshake is older than 2 minutes, the peer is no longer connected
handshakeValid := false
if s.LastHandshake != nil {
handshakeValid = !s.LastHandshake.Before(oldestHandshakeTime)
}
return s.IsPingable || handshakeValid
}
type InterfaceStatus struct {
InterfaceId InterfaceIdentifier `gorm:"primaryKey;column:identifier"`
UpdatedAt time.Time `gorm:"column:updated_at"`
BytesReceived uint64 `gorm:"column:received"`
BytesTransmitted uint64 `gorm:"column:transmitted"`
}

139
internal/domain/user.go Normal file
View File

@@ -0,0 +1,139 @@
package domain
import (
"errors"
"time"
"golang.org/x/crypto/bcrypt"
)
const (
UserSourceLdap UserSource = "ldap" // LDAP / ActiveDirectory
UserSourceDatabase UserSource = "db" // sqlite / mysql database
UserSourceOauth UserSource = "oauth" // oauth / open id connect
)
type UserIdentifier string
type UserSource string
// User is the user model that gets linked to peer entries, by default an empty user model with only the email address is created
type User struct {
BaseModel
// required fields
Identifier UserIdentifier `gorm:"primaryKey;column:identifier"`
Email string `form:"email" binding:"required,email"`
Source UserSource
ProviderName string
IsAdmin bool
// optional fields
Firstname string `form:"firstname" binding:"omitempty"`
Lastname string `form:"lastname" binding:"omitempty"`
Phone string `form:"phone" binding:"omitempty"`
Department string `form:"department" binding:"omitempty"`
Notes string `form:"notes" binding:"omitempty"`
// optional, integrated password authentication
Password PrivateString `form:"password" binding:"omitempty"`
Disabled *time.Time `gorm:"index;column:disabled"` // if this field is set, the user is disabled (WireGuard peers are disabled as well)
DisabledReason string // the reason why the user has been disabled
Locked *time.Time `gorm:"index;column:locked"` // if this field is set, the user is locked and can no longer login (WireGuard peers still can connect)
LockedReason string // the reason why the user has been locked
LinkedPeerCount int `gorm:"-"`
}
// IsDisabled returns true if the user is disabled. In such a case,
// no login is possible and WireGuard peers associated with the user are disabled.
func (u *User) IsDisabled() bool {
return u.Disabled != nil
}
// IsLocked returns true if the user is locked. In such a case, no login is possible, WireGuard connections still work.
func (u *User) IsLocked() bool {
return u.Locked != nil
}
func (u *User) CanChangePassword() error {
if u.Source == UserSourceDatabase {
return nil
}
return errors.New("password change only allowed for database source")
}
func (u *User) EditAllowed(new *User) error {
if u.Source == UserSourceDatabase {
return nil
}
// for users which are not database users, only the notes field and the disabled flag can be updated
updateOk := true
updateOk = updateOk && u.Identifier == new.Identifier
updateOk = updateOk && u.Source == new.Source
updateOk = updateOk && u.IsAdmin == new.IsAdmin
updateOk = updateOk && u.Email == new.Email
updateOk = updateOk && u.Firstname == new.Firstname
updateOk = updateOk && u.Lastname == new.Lastname
updateOk = updateOk && u.Phone == new.Phone
updateOk = updateOk && u.Department == new.Department
if !updateOk {
return errors.New("edit only allowed for database source")
}
return nil
}
func (u *User) DeleteAllowed() error {
if u.Source == UserSourceDatabase {
return nil
}
return errors.New("delete only allowed for database source")
}
func (u *User) CheckPassword(password string) error {
if u.Source != UserSourceDatabase {
return errors.New("invalid user source")
}
if u.IsDisabled() {
return errors.New("user disabled")
}
if u.Password == "" {
return errors.New("empty user password")
}
if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)); err != nil {
return errors.New("wrong password")
}
return nil
}
func (u *User) HashPassword() error {
if u.Password == "" {
return nil // nothing to hash
}
if _, err := bcrypt.Cost([]byte(u.Password)); err == nil {
return nil // password already hashed
}
hash, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
if err != nil {
return err
}
u.Password = PrivateString(hash)
return nil
}
func (u *User) CopyCalculatedAttributes(src *User) {
u.BaseModel = src.BaseModel
u.LinkedPeerCount = src.LinkedPeerCount
}