mirror of
https://github.com/AdguardTeam/AdGuardHome.git
synced 2024-11-21 20:45:33 +03:00
Pull request 2254: 4923 gopacket DHCP vol.9
Updates #4923. Squashed commit of the following: commit 05322419156d18502f3f937e789df02d78971b30 Author: Eugene Burkov <E.Burkov@AdGuard.COM> Date: Tue Jul 9 18:18:35 2024 +0300 dhcpsvc: imp docs commit 083da3671320f7774db9c5b854e663162da9d214 Author: Eugene Burkov <E.Burkov@AdGuard.COM> Date: Tue Jul 9 15:28:52 2024 +0300 dhcpsvc: imp code, tests commit22e37e587e
Author: Eugene Burkov <E.Burkov@AdGuard.COM> Date: Mon Jul 8 19:31:43 2024 +0300 dhcpsvc: imp tests commit83ec7c54ef
Author: Eugene Burkov <E.Burkov@AdGuard.COM> Date: Mon Jul 8 18:56:21 2024 +0300 dhcpsvc: add db
This commit is contained in:
parent
9a6dd0dc55
commit
e269260fbe
14 changed files with 639 additions and 227 deletions
|
@ -3,6 +3,7 @@ package dhcpsvc
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/AdguardTeam/golibs/errors"
|
"github.com/AdguardTeam/golibs/errors"
|
||||||
|
@ -23,7 +24,8 @@ type Config struct {
|
||||||
// clients' hostnames.
|
// clients' hostnames.
|
||||||
LocalDomainName string
|
LocalDomainName string
|
||||||
|
|
||||||
// TODO(e.burkov): Add DB path.
|
// DBFilePath is the path to the database file containing the DHCP leases.
|
||||||
|
DBFilePath string
|
||||||
|
|
||||||
// ICMPTimeout is the timeout for checking another DHCP server's presence.
|
// ICMPTimeout is the timeout for checking another DHCP server's presence.
|
||||||
ICMPTimeout time.Duration
|
ICMPTimeout time.Duration
|
||||||
|
@ -64,6 +66,12 @@ func (conf *Config) Validate() (err error) {
|
||||||
errs = append(errs, err)
|
errs = append(errs, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This is a best-effort check for the file accessibility. The file will be
|
||||||
|
// checked again when it is opened later.
|
||||||
|
if _, err = os.Stat(conf.DBFilePath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
errs = append(errs, fmt.Errorf("db file path %q: %w", conf.DBFilePath, err))
|
||||||
|
}
|
||||||
|
|
||||||
if len(conf.Interfaces) == 0 {
|
if len(conf.Interfaces) == 0 {
|
||||||
errs = append(errs, errNoInterfaces)
|
errs = append(errs, errNoInterfaces)
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package dhcpsvc_test
|
package dhcpsvc_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/AdguardTeam/AdGuardHome/internal/dhcpsvc"
|
"github.com/AdguardTeam/AdGuardHome/internal/dhcpsvc"
|
||||||
|
@ -8,6 +9,8 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestConfig_Validate(t *testing.T) {
|
func TestConfig_Validate(t *testing.T) {
|
||||||
|
leasesPath := filepath.Join(t.TempDir(), "leases.json")
|
||||||
|
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
name string
|
name string
|
||||||
conf *dhcpsvc.Config
|
conf *dhcpsvc.Config
|
||||||
|
@ -25,6 +28,7 @@ func TestConfig_Validate(t *testing.T) {
|
||||||
conf: &dhcpsvc.Config{
|
conf: &dhcpsvc.Config{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Interfaces: testInterfaceConf,
|
Interfaces: testInterfaceConf,
|
||||||
|
DBFilePath: leasesPath,
|
||||||
},
|
},
|
||||||
wantErrMsg: `bad domain name "": domain name is empty`,
|
wantErrMsg: `bad domain name "": domain name is empty`,
|
||||||
}, {
|
}, {
|
||||||
|
@ -32,6 +36,7 @@ func TestConfig_Validate(t *testing.T) {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
LocalDomainName: testLocalTLD,
|
LocalDomainName: testLocalTLD,
|
||||||
Interfaces: nil,
|
Interfaces: nil,
|
||||||
|
DBFilePath: leasesPath,
|
||||||
},
|
},
|
||||||
name: "no_interfaces",
|
name: "no_interfaces",
|
||||||
wantErrMsg: "no interfaces specified",
|
wantErrMsg: "no interfaces specified",
|
||||||
|
@ -40,6 +45,7 @@ func TestConfig_Validate(t *testing.T) {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
LocalDomainName: testLocalTLD,
|
LocalDomainName: testLocalTLD,
|
||||||
Interfaces: nil,
|
Interfaces: nil,
|
||||||
|
DBFilePath: leasesPath,
|
||||||
},
|
},
|
||||||
name: "no_interfaces",
|
name: "no_interfaces",
|
||||||
wantErrMsg: "no interfaces specified",
|
wantErrMsg: "no interfaces specified",
|
||||||
|
@ -50,6 +56,7 @@ func TestConfig_Validate(t *testing.T) {
|
||||||
Interfaces: map[string]*dhcpsvc.InterfaceConfig{
|
Interfaces: map[string]*dhcpsvc.InterfaceConfig{
|
||||||
"eth0": nil,
|
"eth0": nil,
|
||||||
},
|
},
|
||||||
|
DBFilePath: leasesPath,
|
||||||
},
|
},
|
||||||
name: "nil_interface",
|
name: "nil_interface",
|
||||||
wantErrMsg: `interface "eth0": config is nil`,
|
wantErrMsg: `interface "eth0": config is nil`,
|
||||||
|
@ -63,6 +70,7 @@ func TestConfig_Validate(t *testing.T) {
|
||||||
IPv6: &dhcpsvc.IPv6Config{Enabled: false},
|
IPv6: &dhcpsvc.IPv6Config{Enabled: false},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
DBFilePath: leasesPath,
|
||||||
},
|
},
|
||||||
name: "nil_ipv4",
|
name: "nil_ipv4",
|
||||||
wantErrMsg: `interface "eth0": ipv4: config is nil`,
|
wantErrMsg: `interface "eth0": ipv4: config is nil`,
|
||||||
|
@ -76,6 +84,7 @@ func TestConfig_Validate(t *testing.T) {
|
||||||
IPv6: nil,
|
IPv6: nil,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
DBFilePath: leasesPath,
|
||||||
},
|
},
|
||||||
name: "nil_ipv6",
|
name: "nil_ipv6",
|
||||||
wantErrMsg: `interface "eth0": ipv6: config is nil`,
|
wantErrMsg: `interface "eth0": ipv6: config is nil`,
|
||||||
|
|
192
internal/dhcpsvc/db.go
Normal file
192
internal/dhcpsvc/db.go
Normal file
|
@ -0,0 +1,192 @@
|
||||||
|
package dhcpsvc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AdguardTeam/golibs/errors"
|
||||||
|
"github.com/AdguardTeam/golibs/logutil/slogutil"
|
||||||
|
"github.com/google/renameio/v2/maybe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dataVersion is the current version of the stored DHCP leases structure.
|
||||||
|
const dataVersion = 1
|
||||||
|
|
||||||
|
// databasePerm is the permissions for the database file.
|
||||||
|
const databasePerm fs.FileMode = 0o640
|
||||||
|
|
||||||
|
// dataLeases is the structure of the stored DHCP leases.
|
||||||
|
type dataLeases struct {
|
||||||
|
// Leases is the list containing stored DHCP leases.
|
||||||
|
Leases []*dbLease `json:"leases"`
|
||||||
|
|
||||||
|
// Version is the current version of the structure.
|
||||||
|
Version int `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// dbLease is the structure of stored lease.
|
||||||
|
type dbLease struct {
|
||||||
|
Expiry string `json:"expires"`
|
||||||
|
IP netip.Addr `json:"ip"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
HWAddr string `json:"mac"`
|
||||||
|
IsStatic bool `json:"static"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// compareNames returns the result of comparing the hostnames of dl and other
|
||||||
|
// lexicographically.
|
||||||
|
func (dl *dbLease) compareNames(other *dbLease) (res int) {
|
||||||
|
return strings.Compare(dl.Hostname, other.Hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
// toDBLease converts *Lease to *dbLease.
|
||||||
|
func toDBLease(l *Lease) (dl *dbLease) {
|
||||||
|
var expiryStr string
|
||||||
|
if !l.IsStatic {
|
||||||
|
// The front-end is waiting for RFC 3999 format of the time value. It
|
||||||
|
// also shouldn't got an Expiry field for static leases.
|
||||||
|
//
|
||||||
|
// See https://github.com/AdguardTeam/AdGuardHome/issues/2692.
|
||||||
|
expiryStr = l.Expiry.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dbLease{
|
||||||
|
Expiry: expiryStr,
|
||||||
|
Hostname: l.Hostname,
|
||||||
|
HWAddr: l.HWAddr.String(),
|
||||||
|
IP: l.IP,
|
||||||
|
IsStatic: l.IsStatic,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toInternal converts dl to *Lease.
|
||||||
|
func (dl *dbLease) toInternal() (l *Lease, err error) {
|
||||||
|
mac, err := net.ParseMAC(dl.HWAddr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing hardware address: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expiry := time.Time{}
|
||||||
|
if !dl.IsStatic {
|
||||||
|
expiry, err = time.Parse(time.RFC3339, dl.Expiry)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing expiry time: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Lease{
|
||||||
|
Expiry: expiry,
|
||||||
|
IP: dl.IP,
|
||||||
|
Hostname: dl.Hostname,
|
||||||
|
HWAddr: mac,
|
||||||
|
IsStatic: dl.IsStatic,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dbLoad loads stored leases. It must only be called before the service has
|
||||||
|
// been started.
|
||||||
|
func (srv *DHCPServer) dbLoad(ctx context.Context) (err error) {
|
||||||
|
defer func() { err = errors.Annotate(err, "loading db: %w") }()
|
||||||
|
|
||||||
|
file, err := os.Open(srv.dbFilePath)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("reading db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv.logger.DebugContext(ctx, "no db file found")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dl := &dataLeases{}
|
||||||
|
err = json.NewDecoder(file).Decode(dl)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decoding db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv.resetLeases()
|
||||||
|
srv.addDBLeases(ctx, dl.Leases)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// addDBLeases adds leases to the server.
|
||||||
|
func (srv *DHCPServer) addDBLeases(ctx context.Context, leases []*dbLease) {
|
||||||
|
var v4, v6 uint
|
||||||
|
for i, l := range leases {
|
||||||
|
lease, err := l.toInternal()
|
||||||
|
if err != nil {
|
||||||
|
srv.logger.WarnContext(ctx, "converting lease", "idx", i, slogutil.KeyError, err)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
iface, err := srv.ifaceForAddr(l.IP)
|
||||||
|
if err != nil {
|
||||||
|
srv.logger.WarnContext(ctx, "searching lease iface", "idx", i, slogutil.KeyError, err)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
err = srv.leases.add(lease, iface)
|
||||||
|
if err != nil {
|
||||||
|
srv.logger.WarnContext(ctx, "adding lease", "idx", i, slogutil.KeyError, err)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if l.IP.Is4() {
|
||||||
|
v4++
|
||||||
|
} else {
|
||||||
|
v6++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(e.burkov): Group by interface.
|
||||||
|
srv.logger.InfoContext(ctx, "loaded leases", "v4", v4, "v6", v6, "total", len(leases))
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeDB writes leases to the database file. It expects the
|
||||||
|
// [DHCPServer.leasesMu] to be locked.
|
||||||
|
func (srv *DHCPServer) dbStore(ctx context.Context) (err error) {
|
||||||
|
defer func() { err = errors.Annotate(err, "writing db: %w") }()
|
||||||
|
|
||||||
|
dl := &dataLeases{
|
||||||
|
// Avoid writing null into the database file if there are no leases.
|
||||||
|
Leases: make([]*dbLease, 0, srv.leases.len()),
|
||||||
|
Version: dataVersion,
|
||||||
|
}
|
||||||
|
|
||||||
|
srv.leases.rangeLeases(func(l *Lease) (cont bool) {
|
||||||
|
lease := toDBLease(l)
|
||||||
|
i, _ := slices.BinarySearchFunc(dl.Leases, lease, (*dbLease).compareNames)
|
||||||
|
dl.Leases = slices.Insert(dl.Leases, i, lease)
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
buf, err := json.Marshal(dl)
|
||||||
|
if err != nil {
|
||||||
|
// Don't wrap the error since it's informative enough as is.
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = maybe.WriteFile(srv.dbFilePath, buf, databasePerm)
|
||||||
|
if err != nil {
|
||||||
|
// Don't wrap the error since it's informative enough as is.
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
srv.logger.InfoContext(ctx, "stored leases", "num", len(dl.Leases), "file", srv.dbFilePath)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
4
internal/dhcpsvc/db_internal_test.go
Normal file
4
internal/dhcpsvc/db_internal_test.go
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
package dhcpsvc
|
||||||
|
|
||||||
|
// DatabasePerm is the permissions for the test database file.
|
||||||
|
const DatabasePerm = databasePerm
|
66
internal/dhcpsvc/dhcpsvc_test.go
Normal file
66
internal/dhcpsvc/dhcpsvc_test.go
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
package dhcpsvc_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AdguardTeam/AdGuardHome/internal/dhcpsvc"
|
||||||
|
"github.com/AdguardTeam/golibs/logutil/slogutil"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testLocalTLD is a common local TLD for tests.
|
||||||
|
const testLocalTLD = "local"
|
||||||
|
|
||||||
|
// testTimeout is a common timeout for tests and contexts.
|
||||||
|
const testTimeout time.Duration = 10 * time.Second
|
||||||
|
|
||||||
|
// discardLog is a logger to discard test output.
|
||||||
|
var discardLog = slogutil.NewDiscardLogger()
|
||||||
|
|
||||||
|
// testInterfaceConf is a common set of interface configurations for tests.
|
||||||
|
var testInterfaceConf = map[string]*dhcpsvc.InterfaceConfig{
|
||||||
|
"eth0": {
|
||||||
|
IPv4: &dhcpsvc.IPv4Config{
|
||||||
|
Enabled: true,
|
||||||
|
GatewayIP: netip.MustParseAddr("192.168.0.1"),
|
||||||
|
SubnetMask: netip.MustParseAddr("255.255.255.0"),
|
||||||
|
RangeStart: netip.MustParseAddr("192.168.0.2"),
|
||||||
|
RangeEnd: netip.MustParseAddr("192.168.0.254"),
|
||||||
|
LeaseDuration: 1 * time.Hour,
|
||||||
|
},
|
||||||
|
IPv6: &dhcpsvc.IPv6Config{
|
||||||
|
Enabled: true,
|
||||||
|
RangeStart: netip.MustParseAddr("2001:db8::1"),
|
||||||
|
LeaseDuration: 1 * time.Hour,
|
||||||
|
RAAllowSLAAC: true,
|
||||||
|
RASLAACOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"eth1": {
|
||||||
|
IPv4: &dhcpsvc.IPv4Config{
|
||||||
|
Enabled: true,
|
||||||
|
GatewayIP: netip.MustParseAddr("172.16.0.1"),
|
||||||
|
SubnetMask: netip.MustParseAddr("255.255.255.0"),
|
||||||
|
RangeStart: netip.MustParseAddr("172.16.0.2"),
|
||||||
|
RangeEnd: netip.MustParseAddr("172.16.0.255"),
|
||||||
|
LeaseDuration: 1 * time.Hour,
|
||||||
|
},
|
||||||
|
IPv6: &dhcpsvc.IPv6Config{
|
||||||
|
Enabled: true,
|
||||||
|
RangeStart: netip.MustParseAddr("2001:db9::1"),
|
||||||
|
LeaseDuration: 1 * time.Hour,
|
||||||
|
RAAllowSLAAC: true,
|
||||||
|
RASLAACOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustParseMAC parses a hardware address from s and requires no errors.
|
||||||
|
func mustParseMAC(t require.TestingT, s string) (mac net.HardwareAddr) {
|
||||||
|
mac, err := net.ParseMAC(s)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return mac
|
||||||
|
}
|
|
@ -124,3 +124,18 @@ func (idx *leaseIndex) update(l *Lease, iface *netInterface) (err error) {
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rangeLeases calls f for each lease in idx in an unspecified order until f
|
||||||
|
// returns false.
|
||||||
|
func (idx *leaseIndex) rangeLeases(f func(l *Lease) (cont bool)) {
|
||||||
|
for _, l := range idx.byName {
|
||||||
|
if !f(l) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// len returns the number of leases in idx.
|
||||||
|
func (idx *leaseIndex) len() (l uint) {
|
||||||
|
return uint(len(idx.byAddr))
|
||||||
|
}
|
||||||
|
|
|
@ -27,6 +27,13 @@ type DHCPServer struct {
|
||||||
// hostnames.
|
// hostnames.
|
||||||
localTLD string
|
localTLD string
|
||||||
|
|
||||||
|
// dbFilePath is the path to the database file containing the DHCP leases.
|
||||||
|
//
|
||||||
|
// TODO(e.burkov): Consider extracting the database logic into a separate
|
||||||
|
// interface to prevent packages that only need lease data from depending on
|
||||||
|
// the entire server and to simplify testing.
|
||||||
|
dbFilePath string
|
||||||
|
|
||||||
// leasesMu protects the leases index as well as leases in the interfaces.
|
// leasesMu protects the leases index as well as leases in the interfaces.
|
||||||
leasesMu *sync.RWMutex
|
leasesMu *sync.RWMutex
|
||||||
|
|
||||||
|
@ -93,9 +100,14 @@ func New(ctx context.Context, conf *Config) (srv *DHCPServer, err error) {
|
||||||
interfaces4: ifaces4,
|
interfaces4: ifaces4,
|
||||||
interfaces6: ifaces6,
|
interfaces6: ifaces6,
|
||||||
icmpTimeout: conf.ICMPTimeout,
|
icmpTimeout: conf.ICMPTimeout,
|
||||||
|
dbFilePath: conf.DBFilePath,
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(e.burkov): Load leases.
|
err = srv.dbLoad(ctx)
|
||||||
|
if err != nil {
|
||||||
|
// Don't wrap the error since it's informative enough as is.
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return srv, nil
|
return srv, nil
|
||||||
}
|
}
|
||||||
|
@ -167,9 +179,26 @@ func (srv *DHCPServer) IPByHost(host string) (ip netip.Addr) {
|
||||||
|
|
||||||
// Reset implements the [Interface] interface for *DHCPServer.
|
// Reset implements the [Interface] interface for *DHCPServer.
|
||||||
func (srv *DHCPServer) Reset(ctx context.Context) (err error) {
|
func (srv *DHCPServer) Reset(ctx context.Context) (err error) {
|
||||||
|
defer func() { err = errors.Annotate(err, "resetting leases: %w") }()
|
||||||
|
|
||||||
srv.leasesMu.Lock()
|
srv.leasesMu.Lock()
|
||||||
defer srv.leasesMu.Unlock()
|
defer srv.leasesMu.Unlock()
|
||||||
|
|
||||||
|
srv.resetLeases()
|
||||||
|
err = srv.dbStore(ctx)
|
||||||
|
if err != nil {
|
||||||
|
// Don't wrap the error since there is already an annotation deferred.
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
srv.logger.DebugContext(ctx, "reset leases")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetLeases resets the leases for all network interfaces of the server. It
|
||||||
|
// expects the DHCPServer.leasesMu to be locked.
|
||||||
|
func (srv *DHCPServer) resetLeases() {
|
||||||
for _, iface := range srv.interfaces4 {
|
for _, iface := range srv.interfaces4 {
|
||||||
iface.reset()
|
iface.reset()
|
||||||
}
|
}
|
||||||
|
@ -177,10 +206,6 @@ func (srv *DHCPServer) Reset(ctx context.Context) (err error) {
|
||||||
iface.reset()
|
iface.reset()
|
||||||
}
|
}
|
||||||
srv.leases.clear()
|
srv.leases.clear()
|
||||||
|
|
||||||
srv.logger.DebugContext(ctx, "reset leases")
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddLease implements the [Interface] interface for *DHCPServer.
|
// AddLease implements the [Interface] interface for *DHCPServer.
|
||||||
|
@ -190,7 +215,7 @@ func (srv *DHCPServer) AddLease(ctx context.Context, l *Lease) (err error) {
|
||||||
addr := l.IP
|
addr := l.IP
|
||||||
iface, err := srv.ifaceForAddr(addr)
|
iface, err := srv.ifaceForAddr(addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Don't wrap the error since there is already an annotation deferred.
|
// Don't wrap the error since it's already informative enough as is.
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -203,6 +228,12 @@ func (srv *DHCPServer) AddLease(ctx context.Context, l *Lease) (err error) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = srv.dbStore(ctx)
|
||||||
|
if err != nil {
|
||||||
|
// Don't wrap the error since it's already informative enough as is.
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
iface.logger.DebugContext(
|
iface.logger.DebugContext(
|
||||||
ctx, "added lease",
|
ctx, "added lease",
|
||||||
"hostname", l.Hostname,
|
"hostname", l.Hostname,
|
||||||
|
@ -223,7 +254,7 @@ func (srv *DHCPServer) UpdateStaticLease(ctx context.Context, l *Lease) (err err
|
||||||
addr := l.IP
|
addr := l.IP
|
||||||
iface, err := srv.ifaceForAddr(addr)
|
iface, err := srv.ifaceForAddr(addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Don't wrap the error since there is already an annotation deferred.
|
// Don't wrap the error since it's already informative enough as is.
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -236,6 +267,12 @@ func (srv *DHCPServer) UpdateStaticLease(ctx context.Context, l *Lease) (err err
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = srv.dbStore(ctx)
|
||||||
|
if err != nil {
|
||||||
|
// Don't wrap the error since it's already informative enough as is.
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
iface.logger.DebugContext(
|
iface.logger.DebugContext(
|
||||||
ctx, "updated lease",
|
ctx, "updated lease",
|
||||||
"hostname", l.Hostname,
|
"hostname", l.Hostname,
|
||||||
|
@ -254,7 +291,7 @@ func (srv *DHCPServer) RemoveLease(ctx context.Context, l *Lease) (err error) {
|
||||||
addr := l.IP
|
addr := l.IP
|
||||||
iface, err := srv.ifaceForAddr(addr)
|
iface, err := srv.ifaceForAddr(addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Don't wrap the error since there is already an annotation deferred.
|
// Don't wrap the error since it's already informative enough as is.
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -267,6 +304,12 @@ func (srv *DHCPServer) RemoveLease(ctx context.Context, l *Lease) (err error) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = srv.dbStore(ctx)
|
||||||
|
if err != nil {
|
||||||
|
// Don't wrap the error since it's already informative enough as is.
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
iface.logger.DebugContext(
|
iface.logger.DebugContext(
|
||||||
ctx, "removed lease",
|
ctx, "removed lease",
|
||||||
"hostname", l.Hostname,
|
"hostname", l.Hostname,
|
||||||
|
|
|
@ -1,72 +1,40 @@
|
||||||
package dhcpsvc_test
|
package dhcpsvc_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net"
|
"io/fs"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/AdguardTeam/AdGuardHome/internal/dhcpsvc"
|
"github.com/AdguardTeam/AdGuardHome/internal/dhcpsvc"
|
||||||
"github.com/AdguardTeam/golibs/logutil/slogutil"
|
|
||||||
"github.com/AdguardTeam/golibs/testutil"
|
"github.com/AdguardTeam/golibs/testutil"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// testLocalTLD is a common local TLD for tests.
|
// testdata is a filesystem containing data for tests.
|
||||||
const testLocalTLD = "local"
|
var testdata = os.DirFS("testdata")
|
||||||
|
|
||||||
// testTimeout is a common timeout for tests and contexts.
|
// newTempDB copies the leases database file located in the testdata FS, under
|
||||||
const testTimeout time.Duration = 10 * time.Second
|
// tb.Name()/leases.db, to a temporary directory and returns the path to the
|
||||||
|
// copied file.
|
||||||
|
func newTempDB(tb testing.TB) (dst string) {
|
||||||
|
tb.Helper()
|
||||||
|
|
||||||
// discardLog is a logger to discard test output.
|
const filename = "leases.json"
|
||||||
var discardLog = slogutil.NewDiscardLogger()
|
|
||||||
|
|
||||||
// testInterfaceConf is a common set of interface configurations for tests.
|
data, err := fs.ReadFile(testdata, filepath.Join(tb.Name(), filename))
|
||||||
var testInterfaceConf = map[string]*dhcpsvc.InterfaceConfig{
|
require.NoError(tb, err)
|
||||||
"eth0": {
|
|
||||||
IPv4: &dhcpsvc.IPv4Config{
|
|
||||||
Enabled: true,
|
|
||||||
GatewayIP: netip.MustParseAddr("192.168.0.1"),
|
|
||||||
SubnetMask: netip.MustParseAddr("255.255.255.0"),
|
|
||||||
RangeStart: netip.MustParseAddr("192.168.0.2"),
|
|
||||||
RangeEnd: netip.MustParseAddr("192.168.0.254"),
|
|
||||||
LeaseDuration: 1 * time.Hour,
|
|
||||||
},
|
|
||||||
IPv6: &dhcpsvc.IPv6Config{
|
|
||||||
Enabled: true,
|
|
||||||
RangeStart: netip.MustParseAddr("2001:db8::1"),
|
|
||||||
LeaseDuration: 1 * time.Hour,
|
|
||||||
RAAllowSLAAC: true,
|
|
||||||
RASLAACOnly: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"eth1": {
|
|
||||||
IPv4: &dhcpsvc.IPv4Config{
|
|
||||||
Enabled: true,
|
|
||||||
GatewayIP: netip.MustParseAddr("172.16.0.1"),
|
|
||||||
SubnetMask: netip.MustParseAddr("255.255.255.0"),
|
|
||||||
RangeStart: netip.MustParseAddr("172.16.0.2"),
|
|
||||||
RangeEnd: netip.MustParseAddr("172.16.0.255"),
|
|
||||||
LeaseDuration: 1 * time.Hour,
|
|
||||||
},
|
|
||||||
IPv6: &dhcpsvc.IPv6Config{
|
|
||||||
Enabled: true,
|
|
||||||
RangeStart: netip.MustParseAddr("2001:db9::1"),
|
|
||||||
LeaseDuration: 1 * time.Hour,
|
|
||||||
RAAllowSLAAC: true,
|
|
||||||
RASLAACOnly: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// mustParseMAC parses a hardware address from s and requires no errors.
|
dst = filepath.Join(tb.TempDir(), filename)
|
||||||
func mustParseMAC(t require.TestingT, s string) (mac net.HardwareAddr) {
|
|
||||||
mac, err := net.ParseMAC(s)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
return mac
|
err = os.WriteFile(dst, data, dhcpsvc.DatabasePerm)
|
||||||
|
require.NoError(tb, err)
|
||||||
|
|
||||||
|
return dst
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNew(t *testing.T) {
|
func TestNew(t *testing.T) {
|
||||||
|
@ -103,6 +71,8 @@ func TestNew(t *testing.T) {
|
||||||
RASLAACOnly: true,
|
RASLAACOnly: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
leasesPath := filepath.Join(t.TempDir(), "leases.json")
|
||||||
|
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
conf *dhcpsvc.Config
|
conf *dhcpsvc.Config
|
||||||
name string
|
name string
|
||||||
|
@ -118,6 +88,7 @@ func TestNew(t *testing.T) {
|
||||||
IPv6: validIPv6Conf,
|
IPv6: validIPv6Conf,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
DBFilePath: leasesPath,
|
||||||
},
|
},
|
||||||
name: "valid",
|
name: "valid",
|
||||||
wantErrMsg: "",
|
wantErrMsg: "",
|
||||||
|
@ -132,6 +103,7 @@ func TestNew(t *testing.T) {
|
||||||
IPv6: &dhcpsvc.IPv6Config{Enabled: false},
|
IPv6: &dhcpsvc.IPv6Config{Enabled: false},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
DBFilePath: leasesPath,
|
||||||
},
|
},
|
||||||
name: "disabled_interfaces",
|
name: "disabled_interfaces",
|
||||||
wantErrMsg: "",
|
wantErrMsg: "",
|
||||||
|
@ -146,6 +118,7 @@ func TestNew(t *testing.T) {
|
||||||
IPv6: validIPv6Conf,
|
IPv6: validIPv6Conf,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
DBFilePath: leasesPath,
|
||||||
},
|
},
|
||||||
name: "gateway_within_range",
|
name: "gateway_within_range",
|
||||||
wantErrMsg: `interface "eth0": ipv4: ` +
|
wantErrMsg: `interface "eth0": ipv4: ` +
|
||||||
|
@ -161,6 +134,7 @@ func TestNew(t *testing.T) {
|
||||||
IPv6: validIPv6Conf,
|
IPv6: validIPv6Conf,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
DBFilePath: leasesPath,
|
||||||
},
|
},
|
||||||
name: "bad_start",
|
name: "bad_start",
|
||||||
wantErrMsg: `interface "eth0": ipv4: ` +
|
wantErrMsg: `interface "eth0": ipv4: ` +
|
||||||
|
@ -180,32 +154,36 @@ func TestNew(t *testing.T) {
|
||||||
func TestDHCPServer_AddLease(t *testing.T) {
|
func TestDHCPServer_AddLease(t *testing.T) {
|
||||||
ctx := testutil.ContextWithTimeout(t, testTimeout)
|
ctx := testutil.ContextWithTimeout(t, testTimeout)
|
||||||
|
|
||||||
|
leasesPath := filepath.Join(t.TempDir(), "leases.json")
|
||||||
srv, err := dhcpsvc.New(ctx, &dhcpsvc.Config{
|
srv, err := dhcpsvc.New(ctx, &dhcpsvc.Config{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Logger: discardLog,
|
Logger: discardLog,
|
||||||
LocalDomainName: testLocalTLD,
|
LocalDomainName: testLocalTLD,
|
||||||
Interfaces: testInterfaceConf,
|
Interfaces: testInterfaceConf,
|
||||||
|
DBFilePath: leasesPath,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
host1 = "host1"
|
existHost = "host1"
|
||||||
host2 = "host2"
|
newHost = "host2"
|
||||||
host3 = "host3"
|
ipv6Host = "host3"
|
||||||
)
|
)
|
||||||
|
|
||||||
ip1 := netip.MustParseAddr("192.168.0.2")
|
var (
|
||||||
ip2 := netip.MustParseAddr("192.168.0.3")
|
existIP = netip.MustParseAddr("192.168.0.2")
|
||||||
ip3 := netip.MustParseAddr("2001:db8::2")
|
newIP = netip.MustParseAddr("192.168.0.3")
|
||||||
|
newIPv6 = netip.MustParseAddr("2001:db8::2")
|
||||||
|
|
||||||
mac1 := mustParseMAC(t, "01:02:03:04:05:06")
|
existMAC = mustParseMAC(t, "01:02:03:04:05:06")
|
||||||
mac2 := mustParseMAC(t, "06:05:04:03:02:01")
|
newMAC = mustParseMAC(t, "06:05:04:03:02:01")
|
||||||
mac3 := mustParseMAC(t, "02:03:04:05:06:07")
|
ipv6MAC = mustParseMAC(t, "02:03:04:05:06:07")
|
||||||
|
)
|
||||||
|
|
||||||
require.NoError(t, srv.AddLease(ctx, &dhcpsvc.Lease{
|
require.NoError(t, srv.AddLease(ctx, &dhcpsvc.Lease{
|
||||||
Hostname: host1,
|
Hostname: existHost,
|
||||||
IP: ip1,
|
IP: existIP,
|
||||||
HWAddr: mac1,
|
HWAddr: existMAC,
|
||||||
IsStatic: true,
|
IsStatic: true,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
@ -216,61 +194,61 @@ func TestDHCPServer_AddLease(t *testing.T) {
|
||||||
}{{
|
}{{
|
||||||
name: "outside_range",
|
name: "outside_range",
|
||||||
lease: &dhcpsvc.Lease{
|
lease: &dhcpsvc.Lease{
|
||||||
Hostname: host2,
|
Hostname: newHost,
|
||||||
IP: netip.MustParseAddr("1.2.3.4"),
|
IP: netip.MustParseAddr("1.2.3.4"),
|
||||||
HWAddr: mac2,
|
HWAddr: newMAC,
|
||||||
},
|
},
|
||||||
wantErrMsg: "adding lease: no interface for ip 1.2.3.4",
|
wantErrMsg: "adding lease: no interface for ip 1.2.3.4",
|
||||||
}, {
|
}, {
|
||||||
name: "duplicate_ip",
|
name: "duplicate_ip",
|
||||||
lease: &dhcpsvc.Lease{
|
lease: &dhcpsvc.Lease{
|
||||||
Hostname: host2,
|
Hostname: newHost,
|
||||||
IP: ip1,
|
IP: existIP,
|
||||||
HWAddr: mac2,
|
HWAddr: newMAC,
|
||||||
},
|
},
|
||||||
wantErrMsg: "adding lease: lease for ip " + ip1.String() +
|
wantErrMsg: "adding lease: lease for ip " + existIP.String() +
|
||||||
" already exists",
|
" already exists",
|
||||||
}, {
|
}, {
|
||||||
name: "duplicate_hostname",
|
name: "duplicate_hostname",
|
||||||
lease: &dhcpsvc.Lease{
|
lease: &dhcpsvc.Lease{
|
||||||
Hostname: host1,
|
Hostname: existHost,
|
||||||
IP: ip2,
|
IP: newIP,
|
||||||
HWAddr: mac2,
|
HWAddr: newMAC,
|
||||||
},
|
},
|
||||||
wantErrMsg: "adding lease: lease for hostname " + host1 +
|
wantErrMsg: "adding lease: lease for hostname " + existHost +
|
||||||
" already exists",
|
" already exists",
|
||||||
}, {
|
}, {
|
||||||
name: "duplicate_hostname_case",
|
name: "duplicate_hostname_case",
|
||||||
lease: &dhcpsvc.Lease{
|
lease: &dhcpsvc.Lease{
|
||||||
Hostname: strings.ToUpper(host1),
|
Hostname: strings.ToUpper(existHost),
|
||||||
IP: ip2,
|
IP: newIP,
|
||||||
HWAddr: mac2,
|
HWAddr: newMAC,
|
||||||
},
|
},
|
||||||
wantErrMsg: "adding lease: lease for hostname " +
|
wantErrMsg: "adding lease: lease for hostname " +
|
||||||
strings.ToUpper(host1) + " already exists",
|
strings.ToUpper(existHost) + " already exists",
|
||||||
}, {
|
}, {
|
||||||
name: "duplicate_mac",
|
name: "duplicate_mac",
|
||||||
lease: &dhcpsvc.Lease{
|
lease: &dhcpsvc.Lease{
|
||||||
Hostname: host2,
|
Hostname: newHost,
|
||||||
IP: ip2,
|
IP: newIP,
|
||||||
HWAddr: mac1,
|
HWAddr: existMAC,
|
||||||
},
|
},
|
||||||
wantErrMsg: "adding lease: lease for mac " + mac1.String() +
|
wantErrMsg: "adding lease: lease for mac " + existMAC.String() +
|
||||||
" already exists",
|
" already exists",
|
||||||
}, {
|
}, {
|
||||||
name: "valid",
|
name: "valid",
|
||||||
lease: &dhcpsvc.Lease{
|
lease: &dhcpsvc.Lease{
|
||||||
Hostname: host2,
|
Hostname: newHost,
|
||||||
IP: ip2,
|
IP: newIP,
|
||||||
HWAddr: mac2,
|
HWAddr: newMAC,
|
||||||
},
|
},
|
||||||
wantErrMsg: "",
|
wantErrMsg: "",
|
||||||
}, {
|
}, {
|
||||||
name: "valid_v6",
|
name: "valid_v6",
|
||||||
lease: &dhcpsvc.Lease{
|
lease: &dhcpsvc.Lease{
|
||||||
Hostname: host3,
|
Hostname: ipv6Host,
|
||||||
IP: ip3,
|
IP: newIPv6,
|
||||||
HWAddr: mac3,
|
HWAddr: ipv6MAC,
|
||||||
},
|
},
|
||||||
wantErrMsg: "",
|
wantErrMsg: "",
|
||||||
}}
|
}}
|
||||||
|
@ -280,16 +258,21 @@ func TestDHCPServer_AddLease(t *testing.T) {
|
||||||
testutil.AssertErrorMsg(t, tc.wantErrMsg, srv.AddLease(ctx, tc.lease))
|
testutil.AssertErrorMsg(t, tc.wantErrMsg, srv.AddLease(ctx, tc.lease))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assert.NotEmpty(t, srv.Leases())
|
||||||
|
assert.FileExists(t, leasesPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDHCPServer_index(t *testing.T) {
|
func TestDHCPServer_index(t *testing.T) {
|
||||||
ctx := testutil.ContextWithTimeout(t, testTimeout)
|
ctx := testutil.ContextWithTimeout(t, testTimeout)
|
||||||
|
|
||||||
|
leasesPath := newTempDB(t)
|
||||||
srv, err := dhcpsvc.New(ctx, &dhcpsvc.Config{
|
srv, err := dhcpsvc.New(ctx, &dhcpsvc.Config{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Logger: discardLog,
|
Logger: discardLog,
|
||||||
LocalDomainName: testLocalTLD,
|
LocalDomainName: testLocalTLD,
|
||||||
Interfaces: testInterfaceConf,
|
Interfaces: testInterfaceConf,
|
||||||
|
DBFilePath: leasesPath,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
@ -301,46 +284,23 @@ func TestDHCPServer_index(t *testing.T) {
|
||||||
host5 = "host5"
|
host5 = "host5"
|
||||||
)
|
)
|
||||||
|
|
||||||
ip1 := netip.MustParseAddr("192.168.0.2")
|
var (
|
||||||
ip2 := netip.MustParseAddr("192.168.0.3")
|
ip1 = netip.MustParseAddr("192.168.0.2")
|
||||||
ip3 := netip.MustParseAddr("172.16.0.3")
|
ip2 = netip.MustParseAddr("192.168.0.3")
|
||||||
ip4 := netip.MustParseAddr("172.16.0.4")
|
ip3 = netip.MustParseAddr("172.16.0.3")
|
||||||
|
ip4 = netip.MustParseAddr("172.16.0.4")
|
||||||
|
|
||||||
mac1 := mustParseMAC(t, "01:02:03:04:05:06")
|
mac1 = mustParseMAC(t, "01:02:03:04:05:06")
|
||||||
mac2 := mustParseMAC(t, "06:05:04:03:02:01")
|
mac2 = mustParseMAC(t, "06:05:04:03:02:01")
|
||||||
mac3 := mustParseMAC(t, "02:03:04:05:06:07")
|
mac3 = mustParseMAC(t, "02:03:04:05:06:07")
|
||||||
|
)
|
||||||
leases := []*dhcpsvc.Lease{{
|
|
||||||
Hostname: host1,
|
|
||||||
IP: ip1,
|
|
||||||
HWAddr: mac1,
|
|
||||||
IsStatic: true,
|
|
||||||
}, {
|
|
||||||
Hostname: host2,
|
|
||||||
IP: ip2,
|
|
||||||
HWAddr: mac2,
|
|
||||||
IsStatic: true,
|
|
||||||
}, {
|
|
||||||
Hostname: host3,
|
|
||||||
IP: ip3,
|
|
||||||
HWAddr: mac3,
|
|
||||||
IsStatic: true,
|
|
||||||
}, {
|
|
||||||
Hostname: host4,
|
|
||||||
IP: ip4,
|
|
||||||
HWAddr: mac1,
|
|
||||||
IsStatic: true,
|
|
||||||
}}
|
|
||||||
for _, l := range leases {
|
|
||||||
require.NoError(t, srv.AddLease(ctx, l))
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("ip_idx", func(t *testing.T) {
|
t.Run("ip_idx", func(t *testing.T) {
|
||||||
assert.Equal(t, ip1, srv.IPByHost(host1))
|
assert.Equal(t, ip1, srv.IPByHost(host1))
|
||||||
assert.Equal(t, ip2, srv.IPByHost(host2))
|
assert.Equal(t, ip2, srv.IPByHost(host2))
|
||||||
assert.Equal(t, ip3, srv.IPByHost(host3))
|
assert.Equal(t, ip3, srv.IPByHost(host3))
|
||||||
assert.Equal(t, ip4, srv.IPByHost(host4))
|
assert.Equal(t, ip4, srv.IPByHost(host4))
|
||||||
assert.Equal(t, netip.Addr{}, srv.IPByHost(host5))
|
assert.Zero(t, srv.IPByHost(host5))
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("name_idx", func(t *testing.T) {
|
t.Run("name_idx", func(t *testing.T) {
|
||||||
|
@ -348,7 +308,7 @@ func TestDHCPServer_index(t *testing.T) {
|
||||||
assert.Equal(t, host2, srv.HostByIP(ip2))
|
assert.Equal(t, host2, srv.HostByIP(ip2))
|
||||||
assert.Equal(t, host3, srv.HostByIP(ip3))
|
assert.Equal(t, host3, srv.HostByIP(ip3))
|
||||||
assert.Equal(t, host4, srv.HostByIP(ip4))
|
assert.Equal(t, host4, srv.HostByIP(ip4))
|
||||||
assert.Equal(t, "", srv.HostByIP(netip.Addr{}))
|
assert.Zero(t, srv.HostByIP(netip.Addr{}))
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("mac_idx", func(t *testing.T) {
|
t.Run("mac_idx", func(t *testing.T) {
|
||||||
|
@ -356,18 +316,20 @@ func TestDHCPServer_index(t *testing.T) {
|
||||||
assert.Equal(t, mac2, srv.MACByIP(ip2))
|
assert.Equal(t, mac2, srv.MACByIP(ip2))
|
||||||
assert.Equal(t, mac3, srv.MACByIP(ip3))
|
assert.Equal(t, mac3, srv.MACByIP(ip3))
|
||||||
assert.Equal(t, mac1, srv.MACByIP(ip4))
|
assert.Equal(t, mac1, srv.MACByIP(ip4))
|
||||||
assert.Nil(t, srv.MACByIP(netip.Addr{}))
|
assert.Zero(t, srv.MACByIP(netip.Addr{}))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDHCPServer_UpdateStaticLease(t *testing.T) {
|
func TestDHCPServer_UpdateStaticLease(t *testing.T) {
|
||||||
ctx := testutil.ContextWithTimeout(t, testTimeout)
|
ctx := testutil.ContextWithTimeout(t, testTimeout)
|
||||||
|
|
||||||
|
leasesPath := newTempDB(t)
|
||||||
srv, err := dhcpsvc.New(ctx, &dhcpsvc.Config{
|
srv, err := dhcpsvc.New(ctx, &dhcpsvc.Config{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Logger: discardLog,
|
Logger: discardLog,
|
||||||
LocalDomainName: testLocalTLD,
|
LocalDomainName: testLocalTLD,
|
||||||
Interfaces: testInterfaceConf,
|
Interfaces: testInterfaceConf,
|
||||||
|
DBFilePath: leasesPath,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
@ -380,36 +342,16 @@ func TestDHCPServer_UpdateStaticLease(t *testing.T) {
|
||||||
host6 = "host6"
|
host6 = "host6"
|
||||||
)
|
)
|
||||||
|
|
||||||
ip1 := netip.MustParseAddr("192.168.0.2")
|
var (
|
||||||
ip2 := netip.MustParseAddr("192.168.0.3")
|
ip1 = netip.MustParseAddr("192.168.0.2")
|
||||||
ip3 := netip.MustParseAddr("192.168.0.4")
|
ip2 = netip.MustParseAddr("192.168.0.3")
|
||||||
ip4 := netip.MustParseAddr("2001:db8::2")
|
ip3 = netip.MustParseAddr("192.168.0.4")
|
||||||
ip5 := netip.MustParseAddr("2001:db8::3")
|
ip4 = netip.MustParseAddr("2001:db8::3")
|
||||||
|
|
||||||
mac1 := mustParseMAC(t, "01:02:03:04:05:06")
|
mac1 = mustParseMAC(t, "01:02:03:04:05:06")
|
||||||
mac2 := mustParseMAC(t, "01:02:03:04:05:07")
|
mac2 = mustParseMAC(t, "06:05:04:03:02:01")
|
||||||
mac3 := mustParseMAC(t, "06:05:04:03:02:01")
|
mac3 = mustParseMAC(t, "06:05:04:03:02:02")
|
||||||
mac4 := mustParseMAC(t, "06:05:04:03:02:02")
|
)
|
||||||
|
|
||||||
leases := []*dhcpsvc.Lease{{
|
|
||||||
Hostname: host1,
|
|
||||||
IP: ip1,
|
|
||||||
HWAddr: mac1,
|
|
||||||
IsStatic: true,
|
|
||||||
}, {
|
|
||||||
Hostname: host2,
|
|
||||||
IP: ip2,
|
|
||||||
HWAddr: mac2,
|
|
||||||
IsStatic: true,
|
|
||||||
}, {
|
|
||||||
Hostname: host4,
|
|
||||||
IP: ip4,
|
|
||||||
HWAddr: mac4,
|
|
||||||
IsStatic: true,
|
|
||||||
}}
|
|
||||||
for _, l := range leases {
|
|
||||||
require.NoError(t, srv.AddLease(ctx, l))
|
|
||||||
}
|
|
||||||
|
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
name string
|
name string
|
||||||
|
@ -428,9 +370,9 @@ func TestDHCPServer_UpdateStaticLease(t *testing.T) {
|
||||||
lease: &dhcpsvc.Lease{
|
lease: &dhcpsvc.Lease{
|
||||||
Hostname: host3,
|
Hostname: host3,
|
||||||
IP: ip3,
|
IP: ip3,
|
||||||
HWAddr: mac3,
|
HWAddr: mac2,
|
||||||
},
|
},
|
||||||
wantErrMsg: "updating static lease: no lease for mac " + mac3.String(),
|
wantErrMsg: "updating static lease: no lease for mac " + mac2.String(),
|
||||||
}, {
|
}, {
|
||||||
name: "duplicate_ip",
|
name: "duplicate_ip",
|
||||||
lease: &dhcpsvc.Lease{
|
lease: &dhcpsvc.Lease{
|
||||||
|
@ -470,8 +412,8 @@ func TestDHCPServer_UpdateStaticLease(t *testing.T) {
|
||||||
name: "valid_v6",
|
name: "valid_v6",
|
||||||
lease: &dhcpsvc.Lease{
|
lease: &dhcpsvc.Lease{
|
||||||
Hostname: host6,
|
Hostname: host6,
|
||||||
IP: ip5,
|
IP: ip4,
|
||||||
HWAddr: mac4,
|
HWAddr: mac3,
|
||||||
},
|
},
|
||||||
wantErrMsg: "",
|
wantErrMsg: "",
|
||||||
}}
|
}}
|
||||||
|
@ -481,16 +423,20 @@ func TestDHCPServer_UpdateStaticLease(t *testing.T) {
|
||||||
testutil.AssertErrorMsg(t, tc.wantErrMsg, srv.UpdateStaticLease(ctx, tc.lease))
|
testutil.AssertErrorMsg(t, tc.wantErrMsg, srv.UpdateStaticLease(ctx, tc.lease))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assert.FileExists(t, leasesPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDHCPServer_RemoveLease(t *testing.T) {
|
func TestDHCPServer_RemoveLease(t *testing.T) {
|
||||||
ctx := testutil.ContextWithTimeout(t, testTimeout)
|
ctx := testutil.ContextWithTimeout(t, testTimeout)
|
||||||
|
|
||||||
|
leasesPath := newTempDB(t)
|
||||||
srv, err := dhcpsvc.New(ctx, &dhcpsvc.Config{
|
srv, err := dhcpsvc.New(ctx, &dhcpsvc.Config{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Logger: discardLog,
|
Logger: discardLog,
|
||||||
LocalDomainName: testLocalTLD,
|
LocalDomainName: testLocalTLD,
|
||||||
Interfaces: testInterfaceConf,
|
Interfaces: testInterfaceConf,
|
||||||
|
DBFilePath: leasesPath,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
@ -500,28 +446,15 @@ func TestDHCPServer_RemoveLease(t *testing.T) {
|
||||||
host3 = "host3"
|
host3 = "host3"
|
||||||
)
|
)
|
||||||
|
|
||||||
ip1 := netip.MustParseAddr("192.168.0.2")
|
var (
|
||||||
ip2 := netip.MustParseAddr("192.168.0.3")
|
existIP = netip.MustParseAddr("192.168.0.2")
|
||||||
ip3 := netip.MustParseAddr("2001:db8::2")
|
newIP = netip.MustParseAddr("192.168.0.3")
|
||||||
|
newIPv6 = netip.MustParseAddr("2001:db8::2")
|
||||||
|
|
||||||
mac1 := mustParseMAC(t, "01:02:03:04:05:06")
|
existMAC = mustParseMAC(t, "01:02:03:04:05:06")
|
||||||
mac2 := mustParseMAC(t, "02:03:04:05:06:07")
|
newMAC = mustParseMAC(t, "02:03:04:05:06:07")
|
||||||
mac3 := mustParseMAC(t, "06:05:04:03:02:01")
|
ipv6MAC = mustParseMAC(t, "06:05:04:03:02:01")
|
||||||
|
)
|
||||||
leases := []*dhcpsvc.Lease{{
|
|
||||||
Hostname: host1,
|
|
||||||
IP: ip1,
|
|
||||||
HWAddr: mac1,
|
|
||||||
IsStatic: true,
|
|
||||||
}, {
|
|
||||||
Hostname: host3,
|
|
||||||
IP: ip3,
|
|
||||||
HWAddr: mac3,
|
|
||||||
IsStatic: true,
|
|
||||||
}}
|
|
||||||
for _, l := range leases {
|
|
||||||
require.NoError(t, srv.AddLease(ctx, l))
|
|
||||||
}
|
|
||||||
|
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
name string
|
name string
|
||||||
|
@ -531,40 +464,40 @@ func TestDHCPServer_RemoveLease(t *testing.T) {
|
||||||
name: "not_found_mac",
|
name: "not_found_mac",
|
||||||
lease: &dhcpsvc.Lease{
|
lease: &dhcpsvc.Lease{
|
||||||
Hostname: host1,
|
Hostname: host1,
|
||||||
IP: ip1,
|
IP: existIP,
|
||||||
HWAddr: mac2,
|
HWAddr: newMAC,
|
||||||
},
|
},
|
||||||
wantErrMsg: "removing lease: no lease for mac " + mac2.String(),
|
wantErrMsg: "removing lease: no lease for mac " + newMAC.String(),
|
||||||
}, {
|
}, {
|
||||||
name: "not_found_ip",
|
name: "not_found_ip",
|
||||||
lease: &dhcpsvc.Lease{
|
lease: &dhcpsvc.Lease{
|
||||||
Hostname: host1,
|
Hostname: host1,
|
||||||
IP: ip2,
|
IP: newIP,
|
||||||
HWAddr: mac1,
|
HWAddr: existMAC,
|
||||||
},
|
},
|
||||||
wantErrMsg: "removing lease: no lease for ip " + ip2.String(),
|
wantErrMsg: "removing lease: no lease for ip " + newIP.String(),
|
||||||
}, {
|
}, {
|
||||||
name: "not_found_host",
|
name: "not_found_host",
|
||||||
lease: &dhcpsvc.Lease{
|
lease: &dhcpsvc.Lease{
|
||||||
Hostname: host2,
|
Hostname: host2,
|
||||||
IP: ip1,
|
IP: existIP,
|
||||||
HWAddr: mac1,
|
HWAddr: existMAC,
|
||||||
},
|
},
|
||||||
wantErrMsg: "removing lease: no lease for hostname " + host2,
|
wantErrMsg: "removing lease: no lease for hostname " + host2,
|
||||||
}, {
|
}, {
|
||||||
name: "valid",
|
name: "valid",
|
||||||
lease: &dhcpsvc.Lease{
|
lease: &dhcpsvc.Lease{
|
||||||
Hostname: host1,
|
Hostname: host1,
|
||||||
IP: ip1,
|
IP: existIP,
|
||||||
HWAddr: mac1,
|
HWAddr: existMAC,
|
||||||
},
|
},
|
||||||
wantErrMsg: "",
|
wantErrMsg: "",
|
||||||
}, {
|
}, {
|
||||||
name: "valid_v6",
|
name: "valid_v6",
|
||||||
lease: &dhcpsvc.Lease{
|
lease: &dhcpsvc.Lease{
|
||||||
Hostname: host3,
|
Hostname: host3,
|
||||||
IP: ip3,
|
IP: newIPv6,
|
||||||
HWAddr: mac3,
|
HWAddr: ipv6MAC,
|
||||||
},
|
},
|
||||||
wantErrMsg: "",
|
wantErrMsg: "",
|
||||||
}}
|
}}
|
||||||
|
@ -575,49 +508,64 @@ func TestDHCPServer_RemoveLease(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assert.FileExists(t, leasesPath)
|
||||||
assert.Empty(t, srv.Leases())
|
assert.Empty(t, srv.Leases())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDHCPServer_Reset(t *testing.T) {
|
func TestDHCPServer_Reset(t *testing.T) {
|
||||||
ctx := testutil.ContextWithTimeout(t, testTimeout)
|
leasesPath := newTempDB(t)
|
||||||
|
conf := &dhcpsvc.Config{
|
||||||
srv, err := dhcpsvc.New(ctx, &dhcpsvc.Config{
|
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Logger: discardLog,
|
Logger: discardLog,
|
||||||
LocalDomainName: testLocalTLD,
|
LocalDomainName: testLocalTLD,
|
||||||
Interfaces: testInterfaceConf,
|
Interfaces: testInterfaceConf,
|
||||||
})
|
DBFilePath: leasesPath,
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
leases := []*dhcpsvc.Lease{{
|
|
||||||
Hostname: "host1",
|
|
||||||
IP: netip.MustParseAddr("192.168.0.2"),
|
|
||||||
HWAddr: mustParseMAC(t, "01:02:03:04:05:06"),
|
|
||||||
IsStatic: true,
|
|
||||||
}, {
|
|
||||||
Hostname: "host2",
|
|
||||||
IP: netip.MustParseAddr("192.168.0.3"),
|
|
||||||
HWAddr: mustParseMAC(t, "06:05:04:03:02:01"),
|
|
||||||
IsStatic: true,
|
|
||||||
}, {
|
|
||||||
Hostname: "host3",
|
|
||||||
IP: netip.MustParseAddr("2001:db8::2"),
|
|
||||||
HWAddr: mustParseMAC(t, "02:03:04:05:06:07"),
|
|
||||||
IsStatic: true,
|
|
||||||
}, {
|
|
||||||
Hostname: "host4",
|
|
||||||
IP: netip.MustParseAddr("2001:db8::3"),
|
|
||||||
HWAddr: mustParseMAC(t, "06:05:04:03:02:02"),
|
|
||||||
IsStatic: true,
|
|
||||||
}}
|
|
||||||
|
|
||||||
for _, l := range leases {
|
|
||||||
require.NoError(t, srv.AddLease(ctx, l))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
require.Len(t, srv.Leases(), len(leases))
|
ctx := testutil.ContextWithTimeout(t, testTimeout)
|
||||||
|
srv, err := dhcpsvc.New(ctx, conf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
const leasesNum = 4
|
||||||
|
|
||||||
|
require.Len(t, srv.Leases(), leasesNum)
|
||||||
|
|
||||||
require.NoError(t, srv.Reset(ctx))
|
require.NoError(t, srv.Reset(ctx))
|
||||||
|
|
||||||
|
assert.FileExists(t, leasesPath)
|
||||||
assert.Empty(t, srv.Leases())
|
assert.Empty(t, srv.Leases())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_Leases(t *testing.T) {
|
||||||
|
leasesPath := newTempDB(t)
|
||||||
|
conf := &dhcpsvc.Config{
|
||||||
|
Enabled: true,
|
||||||
|
Logger: discardLog,
|
||||||
|
LocalDomainName: testLocalTLD,
|
||||||
|
Interfaces: testInterfaceConf,
|
||||||
|
DBFilePath: leasesPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := testutil.ContextWithTimeout(t, testTimeout)
|
||||||
|
|
||||||
|
srv, err := dhcpsvc.New(ctx, conf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
expiry, err := time.Parse(time.RFC3339, "2042-01-02T03:04:05Z")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
wantLeases := []*dhcpsvc.Lease{{
|
||||||
|
Expiry: expiry,
|
||||||
|
IP: netip.MustParseAddr("192.168.0.3"),
|
||||||
|
Hostname: "example.host",
|
||||||
|
HWAddr: mustParseMAC(t, "AA:AA:AA:AA:AA:AA"),
|
||||||
|
IsStatic: false,
|
||||||
|
}, {
|
||||||
|
Expiry: time.Time{},
|
||||||
|
IP: netip.MustParseAddr("192.168.0.4"),
|
||||||
|
Hostname: "example.static.host",
|
||||||
|
HWAddr: mustParseMAC(t, "BB:BB:BB:BB:BB:BB"),
|
||||||
|
IsStatic: true,
|
||||||
|
}}
|
||||||
|
assert.Equal(t, wantLeases, srv.Leases())
|
||||||
|
}
|
||||||
|
|
19
internal/dhcpsvc/testdata/TestDHCPServer_RemoveLease/leases.json
vendored
Normal file
19
internal/dhcpsvc/testdata/TestDHCPServer_RemoveLease/leases.json
vendored
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"leases": [
|
||||||
|
{
|
||||||
|
"expires": "",
|
||||||
|
"ip": "192.168.0.2",
|
||||||
|
"hostname": "host1",
|
||||||
|
"mac": "01:02:03:04:05:06",
|
||||||
|
"static": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expires": "",
|
||||||
|
"ip": "2001:db8::2",
|
||||||
|
"hostname": "host3",
|
||||||
|
"mac": "06:05:04:03:02:01",
|
||||||
|
"static": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version": 1
|
||||||
|
}
|
33
internal/dhcpsvc/testdata/TestDHCPServer_Reset/leases.json
vendored
Normal file
33
internal/dhcpsvc/testdata/TestDHCPServer_Reset/leases.json
vendored
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"leases": [
|
||||||
|
{
|
||||||
|
"expires": "",
|
||||||
|
"ip": "192.168.0.2",
|
||||||
|
"hostname": "host1",
|
||||||
|
"mac": "01:02:03:04:05:06",
|
||||||
|
"static": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expires": "",
|
||||||
|
"ip": "192.168.0.3",
|
||||||
|
"hostname": "host2",
|
||||||
|
"mac": "06:05:04:03:02:01",
|
||||||
|
"static": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expires": "",
|
||||||
|
"ip": "2001:db8::2",
|
||||||
|
"hostname": "host3",
|
||||||
|
"mac": "02:03:04:05:06:07",
|
||||||
|
"static": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expires": "",
|
||||||
|
"ip": "2001:db8::3",
|
||||||
|
"hostname": "host4",
|
||||||
|
"mac": "06:05:04:03:02:02",
|
||||||
|
"static": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version": 1
|
||||||
|
}
|
26
internal/dhcpsvc/testdata/TestDHCPServer_UpdateStaticLease/leases.json
vendored
Normal file
26
internal/dhcpsvc/testdata/TestDHCPServer_UpdateStaticLease/leases.json
vendored
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"leases": [
|
||||||
|
{
|
||||||
|
"expires": "",
|
||||||
|
"ip": "192.168.0.2",
|
||||||
|
"hostname": "host1",
|
||||||
|
"mac": "01:02:03:04:05:06",
|
||||||
|
"static": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expires": "",
|
||||||
|
"ip": "192.168.0.3",
|
||||||
|
"hostname": "host2",
|
||||||
|
"mac": "01:02:03:04:05:07",
|
||||||
|
"static": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expires": "",
|
||||||
|
"ip": "2001:db8::2",
|
||||||
|
"hostname": "host4",
|
||||||
|
"mac": "06:05:04:03:02:02",
|
||||||
|
"static": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version": 1
|
||||||
|
}
|
33
internal/dhcpsvc/testdata/TestDHCPServer_index/leases.json
vendored
Normal file
33
internal/dhcpsvc/testdata/TestDHCPServer_index/leases.json
vendored
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"leases": [
|
||||||
|
{
|
||||||
|
"expires": "",
|
||||||
|
"ip": "192.168.0.2",
|
||||||
|
"hostname": "host1",
|
||||||
|
"mac": "01:02:03:04:05:06",
|
||||||
|
"static": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expires": "",
|
||||||
|
"ip": "192.168.0.3",
|
||||||
|
"hostname": "host2",
|
||||||
|
"mac": "06:05:04:03:02:01",
|
||||||
|
"static": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expires": "",
|
||||||
|
"ip": "172.16.0.3",
|
||||||
|
"hostname": "host3",
|
||||||
|
"mac": "02:03:04:05:06:07",
|
||||||
|
"static": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expires": "",
|
||||||
|
"ip": "172.16.0.4",
|
||||||
|
"hostname": "host4",
|
||||||
|
"mac": "01:02:03:04:05:06",
|
||||||
|
"static": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version": 1
|
||||||
|
}
|
15
internal/dhcpsvc/testdata/TestServer_Leases/leases.json
vendored
Normal file
15
internal/dhcpsvc/testdata/TestServer_Leases/leases.json
vendored
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"leases": [{
|
||||||
|
"expires": "2042-01-02T03:04:05Z",
|
||||||
|
"ip": "192.168.0.3",
|
||||||
|
"hostname": "example.host",
|
||||||
|
"mac": "AA:AA:AA:AA:AA:AA",
|
||||||
|
"static": false
|
||||||
|
}, {
|
||||||
|
"ip": "192.168.0.4",
|
||||||
|
"hostname": "example.static.host",
|
||||||
|
"mac": "BB:BB:BB:BB:BB:BB",
|
||||||
|
"static": true
|
||||||
|
}],
|
||||||
|
"version": 1
|
||||||
|
}
|
|
@ -120,6 +120,7 @@ func newNetInterfaceV4(
|
||||||
keyInterface, name,
|
keyInterface, name,
|
||||||
keyFamily, netutil.AddrFamilyIPv4,
|
keyFamily, netutil.AddrFamilyIPv4,
|
||||||
)
|
)
|
||||||
|
|
||||||
if !conf.Enabled {
|
if !conf.Enabled {
|
||||||
l.DebugContext(ctx, "disabled")
|
l.DebugContext(ctx, "disabled")
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue