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

View File

@@ -0,0 +1,136 @@
package model
import (
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/domain"
)
type StringConfigOption struct {
Value string `json:"Value"`
Overridable bool `json:"Overridable"`
}
func NewStringConfigOption(value string, overridable bool) StringConfigOption {
return StringConfigOption{
Value: value,
Overridable: overridable,
}
}
func StringConfigOptionFromDomain(opt domain.StringConfigOption) StringConfigOption {
return StringConfigOption{
Value: opt.Value,
Overridable: opt.Overridable,
}
}
func StringConfigOptionToDomain(opt StringConfigOption) domain.StringConfigOption {
return domain.StringConfigOption{
Value: opt.Value,
Overridable: opt.Overridable,
}
}
type StringSliceConfigOption struct {
Value []string `json:"Value"`
Overridable bool `json:"Overridable"`
}
func NewStringSliceConfigOption(value []string, overridable bool) StringSliceConfigOption {
return StringSliceConfigOption{
Value: value,
Overridable: overridable,
}
}
func StringSliceConfigOptionFromDomain(opt domain.StringConfigOption) StringSliceConfigOption {
return StringSliceConfigOption{
Value: internal.SliceString(opt.Value),
Overridable: opt.Overridable,
}
}
func StringSliceConfigOptionToDomain(opt StringSliceConfigOption) domain.StringConfigOption {
return domain.StringConfigOption{
Value: internal.SliceToString(opt.Value),
Overridable: opt.Overridable,
}
}
type IntConfigOption struct {
Value int `json:"Value"`
Overridable bool `json:"Overridable"`
}
func NewIntConfigOption(value int, overridable bool) IntConfigOption {
return IntConfigOption{
Value: value,
Overridable: overridable,
}
}
func IntConfigOptionFromDomain(opt domain.IntConfigOption) IntConfigOption {
return IntConfigOption{
Value: opt.Value,
Overridable: opt.Overridable,
}
}
func IntConfigOptionToDomain(opt IntConfigOption) domain.IntConfigOption {
return domain.IntConfigOption{
Value: opt.Value,
Overridable: opt.Overridable,
}
}
type Int32ConfigOption struct {
Value int32 `json:"Value"`
Overridable bool `json:"Overridable"`
}
func NewInt32ConfigOption(value int32, overridable bool) Int32ConfigOption {
return Int32ConfigOption{
Value: value,
Overridable: overridable,
}
}
func Int32ConfigOptionFromDomain(opt domain.Int32ConfigOption) Int32ConfigOption {
return Int32ConfigOption{
Value: opt.Value,
Overridable: opt.Overridable,
}
}
func Int32ConfigOptionToDomain(opt Int32ConfigOption) domain.Int32ConfigOption {
return domain.Int32ConfigOption{
Value: opt.Value,
Overridable: opt.Overridable,
}
}
type BoolConfigOption struct {
Value bool `json:"Value"`
Overridable bool `json:"Overridable"`
}
func NewBoolConfigOption(value bool, overridable bool) BoolConfigOption {
return BoolConfigOption{
Value: value,
Overridable: overridable,
}
}
func BoolConfigOptionFromDomain(opt domain.BoolConfigOption) BoolConfigOption {
return BoolConfigOption{
Value: opt.Value,
Overridable: opt.Overridable,
}
}
func BoolConfigOptionToDomain(opt BoolConfigOption) domain.BoolConfigOption {
return domain.BoolConfigOption{
Value: opt.Value,
Overridable: opt.Overridable,
}
}

View File

@@ -0,0 +1,12 @@
package model
type Error struct {
Code int `json:"Code"`
Message string `json:"Message"`
}
type Settings struct {
MailLinkOnly bool `json:"MailLinkOnly"`
PersistentConfigSupported bool `json:"PersistentConfigSupported"`
SelfProvisioning bool `json:"SelfProvisioning"`
}

View File

@@ -0,0 +1,41 @@
package model
import "github.com/h44z/wg-portal/internal/domain"
type LoginProviderInfo struct {
Identifier string `json:"Identifier" example:"google"`
Name string `json:"Name" example:"Login with Google"`
ProviderUrl string `json:"ProviderUrl" example:"/auth/google/login"`
CallbackUrl string `json:"CallbackUrl" example:"/auth/google/callback"`
}
func NewLoginProviderInfo(src *domain.LoginProviderInfo) *LoginProviderInfo {
return &LoginProviderInfo{
Identifier: src.Identifier,
Name: src.Name,
ProviderUrl: src.ProviderUrl,
CallbackUrl: src.CallbackUrl,
}
}
func NewLoginProviderInfos(src []domain.LoginProviderInfo) []LoginProviderInfo {
accessories := make([]LoginProviderInfo, len(src))
for i := range src {
accessories[i] = *NewLoginProviderInfo(&src[i])
}
return accessories
}
type SessionInfo struct {
LoggedIn bool `json:"LoggedIn"`
IsAdmin bool `json:"IsAdmin,omitempty"`
UserIdentifier *string `json:"UserIdentifier,omitempty"`
UserFirstname *string `json:"UserFirstname,omitempty"`
UserLastname *string `json:"UserLastname,omitempty"`
UserEmail *string `json:"UserEmail,omitempty"`
}
type OauthInitiationResponse struct {
RedirectUrl string
State string
}

View File

@@ -0,0 +1,166 @@
package model
import (
"github.com/h44z/wg-portal/internal"
"time"
"github.com/h44z/wg-portal/internal/domain"
)
type Interface struct {
Identifier string `json:"Identifier" example:"wg0"` // device name, for example: wg0
DisplayName string `json:"DisplayName"` // a nice display name/ description for the interface
Mode string `json:"Mode" example:"server"` // the interface type, either 'server', 'client' or 'any'
PrivateKey string `json:"PrivateKey" example:"abcdef=="` // private Key of the server interface
PublicKey string `json:"PublicKey" example:"abcdef=="` // public Key of the server interface
Disabled bool `json:"Disabled"` // flag that specifies if the interface is enabled (up) or not (down)
DisabledReason string `json:"DisabledReason"` // the reason why the interface has been disabled
SaveConfig bool `json:"SaveConfig"` // automatically persist config changes to the wgX.conf file
ListenPort int `json:"ListenPort"` // the listening port, for example: 51820
Addresses []string `json:"Addresses"` // the interface ip addresses
Dns []string `json:"Dns"` // the dns server that should be set if the interface is up, comma separated
DnsSearch []string `json:"DnsSearch"` // the dns search option string that should be set if the interface is up, will be appended to DnsStr
Mtu int `json:"Mtu"` // the device MTU
FirewallMark int32 `json:"FirewallMark"` // a firewall mark
RoutingTable string `json:"RoutingTable"` // the routing table
PreUp string `json:"PreUp"` // action that is executed before the device is up
PostUp string `json:"PostUp"` // action that is executed after the device is up
PreDown string `json:"PreDown"` // action that is executed before the device is down
PostDown string `json:"PostDown"` // action that is executed after the device is down
PeerDefNetwork []string `json:"PeerDefNetwork"` // the default subnets from which peers will get their IP addresses, comma seperated
PeerDefDns []string `json:"PeerDefDns"` // the default dns server for the peer
PeerDefDnsSearch []string `json:"PeerDefDnsSearch"` // the default dns search options for the peer
PeerDefEndpoint string `json:"PeerDefEndpoint"` // the default endpoint for the peer
PeerDefAllowedIPs []string `json:"PeerDefAllowedIPs"` // the default allowed IP string for the peer
PeerDefMtu int `json:"PeerDefMtu"` // the default device MTU
PeerDefPersistentKeepalive int `json:"PeerDefPersistentKeepalive"` // the default persistent keep-alive Value
PeerDefFirewallMark int32 `json:"PeerDefFirewallMark"` // default firewall mark
PeerDefRoutingTable string `json:"PeerDefRoutingTable"` // the default routing table
PeerDefPreUp string `json:"PeerDefPreUp"` // default action that is executed before the device is up
PeerDefPostUp string `json:"PeerDefPostUp"` // default action that is executed after the device is up
PeerDefPreDown string `json:"PeerDefPreDown"` // default action that is executed before the device is down
PeerDefPostDown string `json:"PeerDefPostDown"` // default action that is executed after the device is down
// Calculated values
EnabledPeers int `json:"EnabledPeers"`
TotalPeers int `json:"TotalPeers"`
}
func NewInterface(src *domain.Interface, peers []domain.Peer) *Interface {
iface := &Interface{
Identifier: string(src.Identifier),
DisplayName: src.DisplayName,
Mode: string(src.Type),
PrivateKey: src.PrivateKey,
PublicKey: src.PublicKey,
Disabled: src.IsDisabled(),
DisabledReason: src.DisabledReason,
SaveConfig: src.SaveConfig,
ListenPort: src.ListenPort,
Addresses: domain.CidrsToStringSlice(src.Addresses),
Dns: internal.SliceString(src.DnsStr),
DnsSearch: internal.SliceString(src.DnsSearchStr),
Mtu: src.Mtu,
FirewallMark: src.FirewallMark,
RoutingTable: src.RoutingTable,
PreUp: src.PreUp,
PostUp: src.PostUp,
PreDown: src.PreDown,
PostDown: src.PostDown,
PeerDefNetwork: internal.SliceString(src.PeerDefNetworkStr),
PeerDefDns: internal.SliceString(src.PeerDefDnsStr),
PeerDefDnsSearch: internal.SliceString(src.PeerDefDnsSearchStr),
PeerDefEndpoint: src.PeerDefEndpoint,
PeerDefAllowedIPs: internal.SliceString(src.PeerDefAllowedIPsStr),
PeerDefMtu: src.PeerDefMtu,
PeerDefPersistentKeepalive: src.PeerDefPersistentKeepalive,
PeerDefFirewallMark: src.PeerDefFirewallMark,
PeerDefRoutingTable: src.PeerDefRoutingTable,
PeerDefPreUp: src.PeerDefPreUp,
PeerDefPostUp: src.PeerDefPostUp,
PeerDefPreDown: src.PeerDefPreDown,
PeerDefPostDown: src.PeerDefPostDown,
EnabledPeers: 0,
TotalPeers: 0,
}
if len(peers) > 0 {
iface.TotalPeers = len(peers)
activePeers := 0
for _, peer := range peers {
if !peer.IsDisabled() {
activePeers++
}
}
iface.EnabledPeers = activePeers
}
return iface
}
func NewInterfaces(src []domain.Interface, srcPeers [][]domain.Peer) []Interface {
results := make([]Interface, len(src))
for i := range src {
results[i] = *NewInterface(&src[i], srcPeers[i])
}
return results
}
func NewDomainInterface(src *Interface) *domain.Interface {
now := time.Now()
cidrs, _ := domain.CidrsFromArray(src.Addresses)
res := &domain.Interface{
BaseModel: domain.BaseModel{},
Identifier: domain.InterfaceIdentifier(src.Identifier),
KeyPair: domain.KeyPair{
PrivateKey: src.PrivateKey,
PublicKey: src.PublicKey,
},
ListenPort: src.ListenPort,
Addresses: cidrs,
DnsStr: internal.SliceToString(src.Dns),
DnsSearchStr: internal.SliceToString(src.DnsSearch),
Mtu: src.Mtu,
FirewallMark: src.FirewallMark,
RoutingTable: src.RoutingTable,
PreUp: src.PreUp,
PostUp: src.PostUp,
PreDown: src.PreDown,
PostDown: src.PostDown,
SaveConfig: src.SaveConfig,
DisplayName: src.DisplayName,
Type: domain.InterfaceType(src.Mode),
DriverType: "", // currently unused
Disabled: nil, // set below
DisabledReason: src.DisabledReason,
PeerDefNetworkStr: internal.SliceToString(src.PeerDefNetwork),
PeerDefDnsStr: internal.SliceToString(src.PeerDefDns),
PeerDefDnsSearchStr: internal.SliceToString(src.PeerDefDnsSearch),
PeerDefEndpoint: src.PeerDefEndpoint,
PeerDefAllowedIPsStr: internal.SliceToString(src.PeerDefAllowedIPs),
PeerDefMtu: src.PeerDefMtu,
PeerDefPersistentKeepalive: src.PeerDefPersistentKeepalive,
PeerDefFirewallMark: src.PeerDefFirewallMark,
PeerDefRoutingTable: src.PeerDefRoutingTable,
PeerDefPreUp: src.PeerDefPreUp,
PeerDefPostUp: src.PeerDefPostUp,
PeerDefPreDown: src.PeerDefPreDown,
PeerDefPostDown: src.PeerDefPostDown,
}
if src.Disabled {
res.Disabled = &now
}
return res
}

View File

@@ -0,0 +1,224 @@
package model
import (
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/domain"
"time"
)
const ExpiryDateTimeLayout = "\"2006-01-02\""
type ExpiryDate struct {
*time.Time
}
// UnmarshalJSON will unmarshal using 2006-01-02 layout
func (d *ExpiryDate) UnmarshalJSON(b []byte) error {
if len(b) == 0 || string(b) == "null" || string(b) == "\"\"" {
return nil
}
parsed, err := time.Parse(ExpiryDateTimeLayout, string(b))
if err != nil {
return err
}
if !parsed.IsZero() {
d.Time = &parsed
}
return nil
}
// MarshalJSON will marshal using 2006-01-02 layout
func (d *ExpiryDate) MarshalJSON() ([]byte, error) {
if d == nil || d.Time == nil {
return []byte("null"), nil
}
s := d.Format(ExpiryDateTimeLayout)
return []byte(s), nil
}
type Peer struct {
Identifier string `json:"Identifier" example:"super_nice_peer"` // peer unique identifier
DisplayName string `json:"DisplayName"` // a nice display name/ description for the peer
UserIdentifier string `json:"UserIdentifier"` // the owner
InterfaceIdentifier string `json:"InterfaceIdentifier"` // the interface id
Disabled bool `json:"Disabled"` // flag that specifies if the peer is enabled (up) or not (down)
DisabledReason string `json:"DisabledReason"` // the reason why the peer has been disabled
ExpiresAt ExpiryDate `json:"ExpiresAt,omitempty"` // expiry dates for peers
Notes string `json:"Notes"` // a note field for peers
Endpoint StringConfigOption `json:"Endpoint"` // the endpoint address
EndpointPublicKey StringConfigOption `json:"EndpointPublicKey"` // the endpoint public key
AllowedIPs StringSliceConfigOption `json:"AllowedIPs"` // all allowed ip subnets, comma seperated
ExtraAllowedIPs []string `json:"ExtraAllowedIPs"` // all allowed ip subnets on the server side, comma seperated
PresharedKey string `json:"PresharedKey"` // the pre-shared Key of the peer
PersistentKeepalive IntConfigOption `json:"PersistentKeepalive"` // the persistent keep-alive interval
PrivateKey string `json:"PrivateKey" example:"abcdef=="` // private Key of the server peer
PublicKey string `json:"PublicKey" example:"abcdef=="` // public Key of the server peer
Mode string // the peer interface type (server, client, any)
Addresses []string `json:"Addresses"` // the interface ip addresses
CheckAliveAddress string `json:"CheckAliveAddress"` // optional ip address or DNS name that is used for ping checks
Dns StringSliceConfigOption `json:"Dns"` // the dns server that should be set if the interface is up, comma separated
DnsSearch StringSliceConfigOption `json:"DnsSearch"` // the dns search option string that should be set if the interface is up, will be appended to DnsStr
Mtu IntConfigOption `json:"Mtu"` // the device MTU
FirewallMark Int32ConfigOption `json:"FirewallMark"` // a firewall mark
RoutingTable StringConfigOption `json:"RoutingTable"` // the routing table
PreUp StringConfigOption `json:"PreUp"` // action that is executed before the device is up
PostUp StringConfigOption `json:"PostUp"` // action that is executed after the device is up
PreDown StringConfigOption `json:"PreDown"` // action that is executed before the device is down
PostDown StringConfigOption `json:"PostDown"` // action that is executed after the device is down
}
func NewPeer(src *domain.Peer) *Peer {
return &Peer{
Identifier: string(src.Identifier),
DisplayName: src.DisplayName,
UserIdentifier: string(src.UserIdentifier),
InterfaceIdentifier: string(src.InterfaceIdentifier),
Disabled: src.IsDisabled(),
DisabledReason: src.DisabledReason,
ExpiresAt: ExpiryDate{src.ExpiresAt},
Notes: src.Notes,
Endpoint: StringConfigOptionFromDomain(src.Endpoint),
EndpointPublicKey: StringConfigOptionFromDomain(src.EndpointPublicKey),
AllowedIPs: StringSliceConfigOptionFromDomain(src.AllowedIPsStr),
ExtraAllowedIPs: internal.SliceString(src.ExtraAllowedIPsStr),
PresharedKey: string(src.PresharedKey),
PersistentKeepalive: IntConfigOptionFromDomain(src.PersistentKeepalive),
PrivateKey: src.Interface.PrivateKey,
PublicKey: src.Interface.PublicKey,
Mode: string(src.Interface.Type),
Addresses: domain.CidrsToStringSlice(src.Interface.Addresses),
CheckAliveAddress: src.Interface.CheckAliveAddress,
Dns: StringSliceConfigOptionFromDomain(src.Interface.DnsStr),
DnsSearch: StringSliceConfigOptionFromDomain(src.Interface.DnsSearchStr),
Mtu: IntConfigOptionFromDomain(src.Interface.Mtu),
FirewallMark: Int32ConfigOptionFromDomain(src.Interface.FirewallMark),
RoutingTable: StringConfigOptionFromDomain(src.Interface.RoutingTable),
PreUp: StringConfigOptionFromDomain(src.Interface.PreUp),
PostUp: StringConfigOptionFromDomain(src.Interface.PostUp),
PreDown: StringConfigOptionFromDomain(src.Interface.PreDown),
PostDown: StringConfigOptionFromDomain(src.Interface.PostDown),
}
}
func NewPeers(src []domain.Peer) []Peer {
results := make([]Peer, len(src))
for i := range src {
results[i] = *NewPeer(&src[i])
}
return results
}
func NewDomainPeer(src *Peer) *domain.Peer {
now := time.Now()
cidrs, _ := domain.CidrsFromArray(src.Addresses)
res := &domain.Peer{
BaseModel: domain.BaseModel{},
Endpoint: StringConfigOptionToDomain(src.Endpoint),
EndpointPublicKey: StringConfigOptionToDomain(src.EndpointPublicKey),
AllowedIPsStr: StringSliceConfigOptionToDomain(src.AllowedIPs),
ExtraAllowedIPsStr: internal.SliceToString(src.ExtraAllowedIPs),
PresharedKey: domain.PreSharedKey(src.PresharedKey),
PersistentKeepalive: IntConfigOptionToDomain(src.PersistentKeepalive),
DisplayName: src.DisplayName,
Identifier: domain.PeerIdentifier(src.Identifier),
UserIdentifier: domain.UserIdentifier(src.UserIdentifier),
InterfaceIdentifier: domain.InterfaceIdentifier(src.InterfaceIdentifier),
Disabled: nil, // set below
DisabledReason: src.DisabledReason,
ExpiresAt: src.ExpiresAt.Time,
Notes: src.Notes,
Interface: domain.PeerInterfaceConfig{
KeyPair: domain.KeyPair{
PrivateKey: src.PrivateKey,
PublicKey: src.PublicKey,
},
Type: domain.InterfaceType(src.Mode),
Addresses: cidrs,
CheckAliveAddress: src.CheckAliveAddress,
DnsStr: StringSliceConfigOptionToDomain(src.Dns),
DnsSearchStr: StringSliceConfigOptionToDomain(src.DnsSearch),
Mtu: IntConfigOptionToDomain(src.Mtu),
FirewallMark: Int32ConfigOptionToDomain(src.FirewallMark),
RoutingTable: StringConfigOptionToDomain(src.RoutingTable),
PreUp: StringConfigOptionToDomain(src.PreUp),
PostUp: StringConfigOptionToDomain(src.PostUp),
PreDown: StringConfigOptionToDomain(src.PreDown),
PostDown: StringConfigOptionToDomain(src.PostDown),
},
}
if src.Disabled {
res.Disabled = &now
}
return res
}
type MultiPeerRequest struct {
Identifiers []string `json:"Identifiers"`
Suffix string `json:"Suffix"`
}
func NewDomainPeerCreationRequest(src *MultiPeerRequest) *domain.PeerCreationRequest {
return &domain.PeerCreationRequest{
UserIdentifiers: src.Identifiers,
Suffix: src.Suffix,
}
}
type PeerMailRequest struct {
Identifiers []string `json:"Identifiers"`
LinkOnly bool `json:"LinkOnly"`
}
type PeerStats struct {
Enabled bool `json:"Enabled" example:"true"` // peer stats tracking enabled
Stats map[string]PeerStatData `json:"Stats"` // stats, map key = Peer identifier
}
func NewPeerStats(enabled bool, src []domain.PeerStatus) *PeerStats {
stats := make(map[string]PeerStatData, len(src))
for _, srcStat := range src {
stats[string(srcStat.PeerId)] = PeerStatData{
IsConnected: srcStat.IsConnected(),
IsPingable: srcStat.IsPingable,
LastPing: srcStat.LastPing,
BytesReceived: srcStat.BytesReceived,
BytesTransmitted: srcStat.BytesTransmitted,
LastHandshake: srcStat.LastHandshake,
EndpointAddress: srcStat.Endpoint,
LastSessionStart: srcStat.LastSessionStart,
}
}
return &PeerStats{
Enabled: enabled,
Stats: stats,
}
}
type PeerStatData struct {
IsConnected bool `json:"IsConnected"`
IsPingable bool `json:"IsPingable"`
LastPing *time.Time `json:"LastPing"`
BytesReceived uint64 `json:"BytesReceived"`
BytesTransmitted uint64 `json:"BytesTransmitted"`
LastHandshake *time.Time `json:"LastHandshake"`
EndpointAddress string `json:"EndpointAddress"`
LastSessionStart *time.Time `json:"LastSessionStart"`
}

View File

@@ -0,0 +1,94 @@
package model
import (
"time"
"github.com/h44z/wg-portal/internal/domain"
)
type User struct {
Identifier string `json:"Identifier"`
Email string `json:"Email"`
Source string `json:"Source"`
ProviderName string `json:"ProviderName"`
IsAdmin bool `json:"IsAdmin"`
Firstname string `json:"Firstname"`
Lastname string `json:"Lastname"`
Phone string `json:"Phone"`
Department string `json:"Department"`
Notes string `json:"Notes"`
Password string `json:"Password,omitempty"`
Disabled bool `json:"Disabled"` // if this field is set, the user is disabled
DisabledReason string `json:"DisabledReason"` // the reason why the user has been disabled
Locked bool `json:"Locked"` // if this field is set, the user is locked
LockedReason string `json:"LockedReason"` // the reason why the user has been locked
// Calculated
PeerCount int `json:"PeerCount"`
}
func NewUser(src *domain.User) *User {
return &User{
Identifier: string(src.Identifier),
Email: src.Email,
Source: string(src.Source),
ProviderName: src.ProviderName,
IsAdmin: src.IsAdmin,
Firstname: src.Firstname,
Lastname: src.Lastname,
Phone: src.Phone,
Department: src.Department,
Notes: src.Notes,
Password: "", // never fill password
Disabled: src.IsDisabled(),
DisabledReason: src.DisabledReason,
Locked: src.IsLocked(),
LockedReason: src.LockedReason,
PeerCount: src.LinkedPeerCount,
}
}
func NewUsers(src []domain.User) []User {
results := make([]User, len(src))
for i := range src {
results[i] = *NewUser(&src[i])
}
return results
}
func NewDomainUser(src *User) *domain.User {
now := time.Now()
res := &domain.User{
Identifier: domain.UserIdentifier(src.Identifier),
Email: src.Email,
Source: domain.UserSource(src.Source),
ProviderName: src.ProviderName,
IsAdmin: src.IsAdmin,
Firstname: src.Firstname,
Lastname: src.Lastname,
Phone: src.Phone,
Department: src.Department,
Notes: src.Notes,
Password: domain.PrivateString(src.Password),
Disabled: nil, // set below
DisabledReason: src.DisabledReason,
Locked: nil, // set below
LockedReason: src.LockedReason,
LinkedPeerCount: src.PeerCount,
}
if src.Disabled {
res.Disabled = &now
}
if src.Locked {
res.Locked = &now
}
return res
}