mirror of
https://github.com/juanfont/headscale.git
synced 2026-01-15 05:33:28 +01:00
Compare commits
8 Commits
v0.25.0-be
...
v0.25.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ade7f2a2b1 | ||
|
|
75c1e77de0 | ||
|
|
83587df738 | ||
|
|
e9bcb7a052 | ||
|
|
d5037c25a6 | ||
|
|
c53ff2ce00 | ||
|
|
b4ac8cd9a3 | ||
|
|
22277d1fc7 |
2
.github/workflows/test-integration.yaml
vendored
2
.github/workflows/test-integration.yaml
vendored
@@ -24,6 +24,7 @@ jobs:
|
||||
- TestPolicyUpdateWhileRunningWithCLIInDatabase
|
||||
- TestAuthKeyLogoutAndReloginSameUser
|
||||
- TestAuthKeyLogoutAndReloginNewUser
|
||||
- TestAuthKeyLogoutAndReloginSameUserExpiredKey
|
||||
- TestOIDCAuthenticationPingAll
|
||||
- TestOIDCExpireNodesBasedOnTokenExpiry
|
||||
- TestOIDC024UserCreation
|
||||
@@ -68,6 +69,7 @@ jobs:
|
||||
- TestEnableDisableAutoApprovedRoute
|
||||
- TestAutoApprovedSubRoute2068
|
||||
- TestSubnetRouteACL
|
||||
- TestEnablingExitRoutes
|
||||
- TestHeadscale
|
||||
- TestCreateTailscale
|
||||
- TestTailscaleNodesJoiningHeadcale
|
||||
|
||||
26
CHANGELOG.md
26
CHANGELOG.md
@@ -1,9 +1,17 @@
|
||||
# CHANGELOG
|
||||
|
||||
## Next
|
||||
## 0.25.1 (2025-02-25)
|
||||
|
||||
### Changes
|
||||
|
||||
## 0.25.0 (2025-02-xx)
|
||||
- Fix issue where registration errors are sent correctly
|
||||
[#2435](https://github.com/juanfont/headscale/pull/2435)
|
||||
- Fix issue where routes passed on registration were not saved
|
||||
[#2444](https://github.com/juanfont/headscale/pull/2444)
|
||||
- Fix issue where registration page was displayed twice
|
||||
[#2445](https://github.com/juanfont/headscale/pull/2445)
|
||||
|
||||
## 0.25.0 (2025-02-11)
|
||||
|
||||
### BREAKING
|
||||
|
||||
@@ -29,6 +37,8 @@
|
||||
[#2393](https://github.com/juanfont/headscale/pull/2393)
|
||||
- Change minimum hostname length to 2
|
||||
[#2393](https://github.com/juanfont/headscale/pull/2393)
|
||||
- Fix migration error caused by nodes having invalid auth keys
|
||||
[#2412](https://github.com/juanfont/headscale/pull/2412)
|
||||
- Pre auth keys belonging to a user are no longer deleted with the user
|
||||
[#2396](https://github.com/juanfont/headscale/pull/2396)
|
||||
- Pre auth keys that are used by a node can no longer be deleted
|
||||
@@ -36,6 +46,16 @@
|
||||
- Rehaul HTTP errors, return better status code and errors to users
|
||||
[#2398](https://github.com/juanfont/headscale/pull/2398)
|
||||
|
||||
## 0.24.3 (2025-02-07)
|
||||
|
||||
### Changes
|
||||
- Fix migration error caused by nodes having invalid auth keys
|
||||
[#2412](https://github.com/juanfont/headscale/pull/2412)
|
||||
- Pre auth keys belonging to a user are no longer deleted with the user
|
||||
[#2396](https://github.com/juanfont/headscale/pull/2396)
|
||||
- Pre auth keys that are used by a node can no longer be deleted
|
||||
[#2396](https://github.com/juanfont/headscale/pull/2396)
|
||||
|
||||
## 0.24.2 (2025-01-30)
|
||||
|
||||
### Changes
|
||||
@@ -407,7 +427,7 @@ part of adopting [#1460](https://github.com/juanfont/headscale/pull/1460).
|
||||
[#1391](https://github.com/juanfont/headscale/pull/1391)
|
||||
- Improvements on Noise implementation
|
||||
[#1379](https://github.com/juanfont/headscale/pull/1379)
|
||||
- Replace node filter logic, ensuring nodes with access can see eachother
|
||||
- Replace node filter logic, ensuring nodes with access can see each other
|
||||
[#1381](https://github.com/juanfont/headscale/pull/1381)
|
||||
- Disable (or delete) both exit routes at the same time
|
||||
[#1428](https://github.com/juanfont/headscale/pull/1428)
|
||||
|
||||
@@ -10,7 +10,7 @@ headscale.
|
||||
| OpenBSD | Yes |
|
||||
| FreeBSD | Yes |
|
||||
| Windows | Yes (see [docs](../usage/connect/windows.md) and `/windows` on your headscale for more information) |
|
||||
| Android | Yes (see [docs](../usage/connect/android.md)) |
|
||||
| Android | Yes (see [docs](../usage/connect/android.md) for more information) |
|
||||
| macOS | Yes (see [docs](../usage/connect/apple.md#macos) and `/apple` on your headscale for more information) |
|
||||
| iOS | Yes (see [docs](../usage/connect/apple.md#ios) and `/apple` on your headscale for more information) |
|
||||
| tvOS | Yes (see [docs](../usage/connect/apple.md#tvos) and `/apple` on your headscale for more information) |
|
||||
|
||||
@@ -43,10 +43,7 @@ func (h *Headscale) handleRegister(
|
||||
}
|
||||
|
||||
if regReq.Followup != "" {
|
||||
// TODO(kradalby): Does this need to return an error of some sort?
|
||||
// Maybe if the registration fails down the line it can be sent
|
||||
// on the channel and returned here?
|
||||
h.waitForFollowup(ctx, regReq)
|
||||
return h.waitForFollowup(ctx, regReq)
|
||||
}
|
||||
|
||||
if regReq.Auth != nil && regReq.Auth.AuthKey != "" {
|
||||
@@ -117,42 +114,51 @@ func (h *Headscale) handleExistingNode(
|
||||
h.nodeNotifier.NotifyWithIgnore(ctx, types.StateUpdateExpire(node.ID, requestExpiry), node.ID)
|
||||
}
|
||||
|
||||
return nodeToRegisterResponse(node), nil
|
||||
}
|
||||
|
||||
func nodeToRegisterResponse(node *types.Node) *tailcfg.RegisterResponse {
|
||||
return &tailcfg.RegisterResponse{
|
||||
// TODO(kradalby): Only send for user-owned nodes
|
||||
// and not tagged nodes when tags is working.
|
||||
User: *node.User.TailscaleUser(),
|
||||
Login: *node.User.TailscaleLogin(),
|
||||
NodeKeyExpired: expired,
|
||||
NodeKeyExpired: node.IsExpired(),
|
||||
|
||||
// Headscale does not implement the concept of machine authorization
|
||||
// so we always return true here.
|
||||
// Revisit this if #2176 gets implemented.
|
||||
MachineAuthorized: true,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Headscale) waitForFollowup(
|
||||
ctx context.Context,
|
||||
regReq tailcfg.RegisterRequest,
|
||||
) {
|
||||
) (*tailcfg.RegisterResponse, error) {
|
||||
fu, err := url.Parse(regReq.Followup)
|
||||
if err != nil {
|
||||
return
|
||||
return nil, NewHTTPError(http.StatusUnauthorized, "invalid followup URL", err)
|
||||
}
|
||||
|
||||
followupReg, err := types.RegistrationIDFromString(strings.ReplaceAll(fu.Path, "/register/", ""))
|
||||
if err != nil {
|
||||
return
|
||||
return nil, NewHTTPError(http.StatusUnauthorized, "invalid registration ID", err)
|
||||
}
|
||||
|
||||
if reg, ok := h.registrationCache.Get(followupReg); ok {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-reg.Registered:
|
||||
return
|
||||
return nil, NewHTTPError(http.StatusUnauthorized, "registration timed out", err)
|
||||
case node := <-reg.Registered:
|
||||
if node == nil {
|
||||
return nil, NewHTTPError(http.StatusUnauthorized, "node not found", nil)
|
||||
}
|
||||
return nodeToRegisterResponse(node), nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, NewHTTPError(http.StatusNotFound, "followup registration not found", nil)
|
||||
}
|
||||
|
||||
// canUsePreAuthKey checks if a pre auth key can be used.
|
||||
@@ -277,7 +283,7 @@ func (h *Headscale) handleRegisterInteractive(
|
||||
Hostinfo: regReq.Hostinfo,
|
||||
LastSeen: ptr.To(time.Now()),
|
||||
},
|
||||
Registered: make(chan struct{}),
|
||||
Registered: make(chan *types.Node),
|
||||
}
|
||||
|
||||
if !regReq.Expiry.IsZero() {
|
||||
|
||||
@@ -103,7 +103,7 @@ func NewHeadscaleDatabase(
|
||||
|
||||
dbConn.Model(&types.Node{}).Where("auth_key_id = ?", 0).Update("auth_key_id", nil)
|
||||
// If the Node table has a column for registered,
|
||||
// find all occourences of "false" and drop them. Then
|
||||
// find all occurrences of "false" and drop them. Then
|
||||
// remove the column.
|
||||
if tx.Migrator().HasColumn(&types.Node{}, "registered") {
|
||||
log.Info().
|
||||
@@ -512,7 +512,7 @@ COMMIT;
|
||||
|
||||
err := tx.AutoMigrate(&types.User{})
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("automigrating types.User: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -527,7 +527,7 @@ COMMIT;
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
err := tx.AutoMigrate(&types.User{})
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("automigrating types.User: %w", err)
|
||||
}
|
||||
|
||||
// Set up indexes and unique constraints outside of GORM, it does not support
|
||||
@@ -575,7 +575,7 @@ COMMIT;
|
||||
|
||||
err := tx.AutoMigrate(&types.Route{})
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("automigrating types.Route: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -589,11 +589,33 @@ COMMIT;
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
err := tx.AutoMigrate(&types.PreAuthKey{})
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("automigrating types.PreAuthKey: %w", err)
|
||||
}
|
||||
err = tx.AutoMigrate(&types.Node{})
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("automigrating types.Node: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Rollback: func(db *gorm.DB) error { return nil },
|
||||
},
|
||||
// Ensure there are no nodes refering to a deleted preauthkey.
|
||||
{
|
||||
ID: "202502070949",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
if tx.Migrator().HasTable(&types.PreAuthKey{}) {
|
||||
err := tx.Exec(`
|
||||
UPDATE nodes
|
||||
SET auth_key_id = NULL
|
||||
WHERE auth_key_id IS NOT NULL
|
||||
AND auth_key_id NOT IN (
|
||||
SELECT id FROM pre_auth_keys
|
||||
);
|
||||
`).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting auth_key to null on nodes with non-existing keys: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -667,7 +689,7 @@ func openDB(cfg types.DatabaseConfig) (*gorm.DB, error) {
|
||||
}
|
||||
|
||||
// The pure Go SQLite library does not handle locking in
|
||||
// the same way as the C based one and we cant use the gorm
|
||||
// the same way as the C based one and we can't use the gorm
|
||||
// connection pool as of 2022/02/23.
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.SetMaxIdleConns(1)
|
||||
@@ -730,7 +752,7 @@ func openDB(cfg types.DatabaseConfig) (*gorm.DB, error) {
|
||||
}
|
||||
|
||||
func runMigrations(cfg types.DatabaseConfig, dbConn *gorm.DB, migrations *gormigrate.Gormigrate) error {
|
||||
// Turn off foreign keys for the duration of the migration if using sqllite to
|
||||
// Turn off foreign keys for the duration of the migration if using sqlite to
|
||||
// prevent data loss due to the way the GORM migrator handles certain schema
|
||||
// changes.
|
||||
if cfg.Type == types.DatabaseSqlite {
|
||||
|
||||
@@ -201,6 +201,26 @@ func TestMigrationsSQLite(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
dbPath: "testdata/failing-node-preauth-constraint.sqlite",
|
||||
wantFunc: func(t *testing.T, h *HSDatabase) {
|
||||
nodes, err := Read(h.DB, func(rx *gorm.DB) (types.Nodes, error) {
|
||||
return ListNodes(rx)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, node := range nodes {
|
||||
assert.Falsef(t, node.MachineKey.IsZero(), "expected non zero machinekey")
|
||||
assert.Contains(t, node.MachineKey.String(), "mkey:")
|
||||
assert.Falsef(t, node.NodeKey.IsZero(), "expected non zero nodekey")
|
||||
assert.Contains(t, node.NodeKey.String(), "nodekey:")
|
||||
assert.Falsef(t, node.DiscoKey.IsZero(), "expected non zero discokey")
|
||||
assert.Contains(t, node.DiscoKey.String(), "discokey:")
|
||||
assert.Nil(t, node.AuthKey)
|
||||
assert.Nil(t, node.AuthKeyID)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -371,7 +371,12 @@ func (hsdb *HSDatabase) HandleNodeFromAuthPath(
|
||||
}
|
||||
|
||||
// Signal to waiting clients that the machine has been registered.
|
||||
select {
|
||||
case reg.Registered <- node:
|
||||
default:
|
||||
}
|
||||
close(reg.Registered)
|
||||
|
||||
newNode = true
|
||||
return node, err
|
||||
} else {
|
||||
@@ -452,6 +457,10 @@ func RegisterNode(tx *gorm.DB, node types.Node, ipv4 *netip.Addr, ipv6 *netip.Ad
|
||||
return nil, fmt.Errorf("failed register(save) node in the database: %w", err)
|
||||
}
|
||||
|
||||
if _, err := SaveNodeRoutes(tx, &node); err != nil {
|
||||
return nil, fmt.Errorf("failed to save node routes: %w", err)
|
||||
}
|
||||
|
||||
log.Trace().
|
||||
Caller().
|
||||
Str("node", node.Hostname).
|
||||
|
||||
@@ -744,6 +744,7 @@ func TestRenameNode(t *testing.T) {
|
||||
Hostname: "test",
|
||||
UserID: user.ID,
|
||||
RegisterMethod: util.RegisterMethodAuthKey,
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
}
|
||||
|
||||
node2 := types.Node{
|
||||
@@ -753,6 +754,7 @@ func TestRenameNode(t *testing.T) {
|
||||
Hostname: "test",
|
||||
UserID: user2.ID,
|
||||
RegisterMethod: util.RegisterMethodAuthKey,
|
||||
Hostinfo: &tailcfg.Hostinfo{},
|
||||
}
|
||||
|
||||
err = db.DB.Save(&node).Error
|
||||
|
||||
BIN
hscontrol/db/testdata/failing-node-preauth-constraint.sqlite
vendored
Normal file
BIN
hscontrol/db/testdata/failing-node-preauth-constraint.sqlite
vendored
Normal file
Binary file not shown.
@@ -866,7 +866,7 @@ func (api headscaleV1APIServer) DebugCreateNode(
|
||||
|
||||
Hostinfo: &hostinfo,
|
||||
},
|
||||
Registered: make(chan struct{}),
|
||||
Registered: make(chan *types.Node),
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
|
||||
@@ -226,6 +226,10 @@ func (ns *noiseServer) NoisePollNetMapHandler(
|
||||
}
|
||||
}
|
||||
|
||||
func regErr(err error) *tailcfg.RegisterResponse {
|
||||
return &tailcfg.RegisterResponse{Error: err.Error()}
|
||||
}
|
||||
|
||||
// NoiseRegistrationHandler handles the actual registration process of a node.
|
||||
func (ns *noiseServer) NoiseRegistrationHandler(
|
||||
writer http.ResponseWriter,
|
||||
@@ -237,52 +241,47 @@ func (ns *noiseServer) NoiseRegistrationHandler(
|
||||
return
|
||||
}
|
||||
|
||||
registerRequest, registerResponse, err := func() (*tailcfg.RegisterRequest, []byte, error) {
|
||||
registerRequest, registerResponse := func() (*tailcfg.RegisterRequest, *tailcfg.RegisterResponse) {
|
||||
var resp *tailcfg.RegisterResponse
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return &tailcfg.RegisterRequest{}, regErr(err)
|
||||
}
|
||||
var registerRequest tailcfg.RegisterRequest
|
||||
if err := json.Unmarshal(body, ®isterRequest); err != nil {
|
||||
return nil, nil, err
|
||||
var regReq tailcfg.RegisterRequest
|
||||
if err := json.Unmarshal(body, ®Req); err != nil {
|
||||
return ®Req, regErr(err)
|
||||
}
|
||||
|
||||
ns.nodeKey = registerRequest.NodeKey
|
||||
ns.nodeKey = regReq.NodeKey
|
||||
|
||||
resp, err := ns.headscale.handleRegister(req.Context(), registerRequest, ns.conn.Peer())
|
||||
// TODO(kradalby): Here we could have two error types, one that is surfaced to the client
|
||||
// and one that returns 500.
|
||||
resp, err = ns.headscale.handleRegister(req.Context(), regReq, ns.conn.Peer())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
var httpErr HTTPError
|
||||
if errors.As(err, &httpErr) {
|
||||
resp = &tailcfg.RegisterResponse{
|
||||
Error: httpErr.Msg,
|
||||
}
|
||||
return ®Req, resp
|
||||
} else {
|
||||
}
|
||||
return ®Req, regErr(err)
|
||||
}
|
||||
|
||||
respBody, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return ®isterRequest, respBody, nil
|
||||
return ®Req, resp
|
||||
}()
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Caller().
|
||||
Err(err).
|
||||
Msg("Error handling registration")
|
||||
http.Error(writer, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// Reject unsupported versions
|
||||
if rejectUnsupported(writer, registerRequest.Version, ns.machineKey, registerRequest.NodeKey) {
|
||||
return
|
||||
}
|
||||
|
||||
respBody, err := json.Marshal(registerResponse)
|
||||
if err != nil {
|
||||
httpError(writer, err)
|
||||
return
|
||||
}
|
||||
|
||||
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
_, err = writer.Write(registerResponse)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Caller().
|
||||
Err(err).
|
||||
Msg("Failed to write response")
|
||||
}
|
||||
writer.Write(respBody)
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ func (n *Notifier) IsConnected(nodeID types.NodeID) bool {
|
||||
}
|
||||
|
||||
// IsLikelyConnected reports if a node is connected to headscale and has a
|
||||
// poll session open, but doesnt lock, so might be wrong.
|
||||
// poll session open, but doesn't lock, so might be wrong.
|
||||
func (n *Notifier) IsLikelyConnected(nodeID types.NodeID) bool {
|
||||
if val, ok := n.connected.Load(nodeID); ok {
|
||||
return val
|
||||
|
||||
@@ -223,7 +223,7 @@ func TestBatcher(t *testing.T) {
|
||||
// so do not run the worker.
|
||||
BatchChangeDelay: time.Hour,
|
||||
|
||||
// Since we do not load the config, we wont get the
|
||||
// Since we do not load the config, we won't get the
|
||||
// default, so set it manually so we dont time out
|
||||
// and have flakes.
|
||||
NotifierSendTimeout: time.Second,
|
||||
|
||||
@@ -61,7 +61,7 @@ func theInternet() *netipx.IPSet {
|
||||
internetBuilder.RemovePrefix(tsaddr.TailscaleULARange())
|
||||
internetBuilder.RemovePrefix(tsaddr.CGNATRange())
|
||||
|
||||
// Delete "cant find DHCP networks"
|
||||
// Delete "can't find DHCP networks"
|
||||
internetBuilder.RemovePrefix(netip.MustParsePrefix("fe80::/10")) // link-local
|
||||
internetBuilder.RemovePrefix(netip.MustParsePrefix("169.254.0.0/16"))
|
||||
|
||||
@@ -251,7 +251,7 @@ func ReduceFilterRules(node *types.Node, rules []tailcfg.FilterRule) []tailcfg.F
|
||||
DEST_LOOP:
|
||||
for _, dest := range rule.DstPorts {
|
||||
expanded, err := util.ParseIPSet(dest.IP, nil)
|
||||
// Fail closed, if we cant parse it, then we should not allow
|
||||
// Fail closed, if we can't parse it, then we should not allow
|
||||
// access.
|
||||
if err != nil {
|
||||
continue DEST_LOOP
|
||||
@@ -934,7 +934,7 @@ func (pol *ACLPolicy) expandIPsFromIPPrefix(
|
||||
build.AddPrefix(prefix)
|
||||
|
||||
// This is suboptimal and quite expensive, but if we only add the prefix, we will miss all the relevant IPv6
|
||||
// addresses for the hosts that belong to tailscale. This doesnt really affect stuff like subnet routers.
|
||||
// addresses for the hosts that belong to tailscale. This doesn't really affect stuff like subnet routers.
|
||||
for _, node := range nodes {
|
||||
for _, ip := range node.IPs() {
|
||||
// log.Trace().
|
||||
|
||||
@@ -156,7 +156,7 @@ func (m *mapSession) serve() {
|
||||
// current configuration.
|
||||
//
|
||||
// If OmitPeers is true, Stream is false, and ReadOnly is false,
|
||||
// then te server will let clients update their endpoints without
|
||||
// then the server will let clients update their endpoints without
|
||||
// breaking existing long-polling (Stream == true) connections.
|
||||
// In this case, the server can omit the entire response; the client
|
||||
// only checks the HTTP response status code.
|
||||
@@ -691,7 +691,7 @@ func hostInfoChanged(old, new *tailcfg.Hostinfo) (bool, bool) {
|
||||
}
|
||||
|
||||
// Services is mostly useful for discovery and not critical,
|
||||
// except for peerapi, which is how nodes talk to eachother.
|
||||
// except for peerapi, which is how nodes talk to each other.
|
||||
// If peerapi was not part of the initial mapresponse, we
|
||||
// need to make sure its sent out later as it is needed for
|
||||
// Taildrop.
|
||||
|
||||
@@ -174,5 +174,5 @@ func (r RegistrationID) String() string {
|
||||
|
||||
type RegisterNode struct {
|
||||
Node Node
|
||||
Registered chan struct{}
|
||||
Registered chan *Node
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ func (node *Node) GivenNameHasBeenChanged() bool {
|
||||
// IsExpired returns whether the node registration has expired.
|
||||
func (node Node) IsExpired() bool {
|
||||
// If Expiry is not set, the client has not indicated that
|
||||
// it wants an expiry time, it is therefor considered
|
||||
// it wants an expiry time, it is therefore considered
|
||||
// to mean "not expired"
|
||||
if node.Expiry == nil || node.Expiry.IsZero() {
|
||||
return false
|
||||
@@ -183,7 +183,7 @@ func (node *Node) CanAccess(filter []tailcfg.FilterRule, node2 *Node) bool {
|
||||
src := node.IPs()
|
||||
allowedIPs := node2.IPs()
|
||||
|
||||
// TODO(kradalby): Regenerate this everytime the filter change, instead of
|
||||
// TODO(kradalby): Regenerate this every time the filter change, instead of
|
||||
// every time we use it.
|
||||
matchers := make([]matcher.Match, len(filter))
|
||||
for i, rule := range filter {
|
||||
|
||||
@@ -86,7 +86,7 @@ func CheckForFQDNRules(name string) error {
|
||||
}
|
||||
if invalidDNSRegex.MatchString(name) {
|
||||
return fmt.Errorf(
|
||||
"DNS segment should only be composed of lowercase ASCII letters numbers, hyphen and dots. %v doesn't comply with theses rules: %w",
|
||||
"DNS segment should only be composed of lowercase ASCII letters numbers, hyphen and dots. %v doesn't comply with these rules: %w",
|
||||
name,
|
||||
ErrInvalidUserName,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
package util
|
||||
|
||||
import "tailscale.com/util/cmpver"
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/util/cmpver"
|
||||
)
|
||||
|
||||
func TailscaleVersionNewerOrEqual(minimum, toCheck string) bool {
|
||||
if cmpver.Compare(minimum, toCheck) <= 0 ||
|
||||
@@ -11,3 +18,31 @@ func TailscaleVersionNewerOrEqual(minimum, toCheck string) bool {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ParseLoginURLFromCLILogin parses the output of the tailscale up command to extract the login URL.
|
||||
// It returns an error if not exactly one URL is found.
|
||||
func ParseLoginURLFromCLILogin(output string) (*url.URL, error) {
|
||||
lines := strings.Split(output, "\n")
|
||||
var urlStr string
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "http://") || strings.HasPrefix(line, "https://") {
|
||||
if urlStr != "" {
|
||||
return nil, fmt.Errorf("multiple URLs found: %s and %s", urlStr, line)
|
||||
}
|
||||
urlStr = line
|
||||
}
|
||||
}
|
||||
|
||||
if urlStr == "" {
|
||||
return nil, errors.New("no URL found")
|
||||
}
|
||||
|
||||
loginURL, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse URL: %w", err)
|
||||
}
|
||||
|
||||
return loginURL, nil
|
||||
}
|
||||
|
||||
@@ -93,3 +93,88 @@ func TestTailscaleVersionNewerOrEqual(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLoginURLFromCLILogin(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
output string
|
||||
wantURL string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "valid https URL",
|
||||
output: `
|
||||
To authenticate, visit:
|
||||
|
||||
https://headscale.example.com/register/3oYCOZYA2zZmGB4PQ7aHBaMi
|
||||
|
||||
Success.`,
|
||||
wantURL: "https://headscale.example.com/register/3oYCOZYA2zZmGB4PQ7aHBaMi",
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "valid http URL",
|
||||
output: `
|
||||
To authenticate, visit:
|
||||
|
||||
http://headscale.example.com/register/3oYCOZYA2zZmGB4PQ7aHBaMi
|
||||
|
||||
Success.`,
|
||||
wantURL: "http://headscale.example.com/register/3oYCOZYA2zZmGB4PQ7aHBaMi",
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "no URL",
|
||||
output: `
|
||||
To authenticate, visit:
|
||||
|
||||
Success.`,
|
||||
wantURL: "",
|
||||
wantErr: "no URL found",
|
||||
},
|
||||
{
|
||||
name: "multiple URLs",
|
||||
output: `
|
||||
To authenticate, visit:
|
||||
|
||||
https://headscale.example.com/register/3oYCOZYA2zZmGB4PQ7aHBaMi
|
||||
|
||||
To authenticate, visit:
|
||||
|
||||
http://headscale.example.com/register/dv1l2k5FackOYl-7-V3mSd_E
|
||||
|
||||
Success.`,
|
||||
wantURL: "",
|
||||
wantErr: "multiple URLs found: https://headscale.example.com/register/3oYCOZYA2zZmGB4PQ7aHBaMi and http://headscale.example.com/register/dv1l2k5FackOYl-7-V3mSd_E",
|
||||
},
|
||||
{
|
||||
name: "invalid URL",
|
||||
output: `
|
||||
To authenticate, visit:
|
||||
|
||||
invalid-url
|
||||
|
||||
Success.`,
|
||||
wantURL: "",
|
||||
wantErr: "no URL found",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotURL, err := ParseLoginURLFromCLILogin(tt.output)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || err.Error() != tt.wantErr {
|
||||
t.Errorf("ParseLoginURLFromCLILogin() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("ParseLoginURLFromCLILogin() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if gotURL.String() != tt.wantURL {
|
||||
t.Errorf("ParseLoginURLFromCLILogin() = %v, want %v", gotURL, tt.wantURL)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ func TestACLHostsInNetMapTable(t *testing.T) {
|
||||
},
|
||||
},
|
||||
// Test that when we have two users, which cannot see
|
||||
// eachother, each node has only the number of pairs from
|
||||
// each other, each node has only the number of pairs from
|
||||
// their own user.
|
||||
"two-isolated-users": {
|
||||
users: map[string]int{
|
||||
|
||||
@@ -228,3 +228,99 @@ func TestAuthKeyLogoutAndReloginNewUser(t *testing.T) {
|
||||
assert.Equal(t, "user1@test.no", status.User[status.Self.UserID].LoginName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthKeyLogoutAndReloginSameUserExpiredKey(t *testing.T) {
|
||||
IntegrationSkip(t)
|
||||
t.Parallel()
|
||||
|
||||
for _, https := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("with-https-%t", https), func(t *testing.T) {
|
||||
scenario, err := NewScenario(dockertestMaxWait())
|
||||
assertNoErr(t, err)
|
||||
defer scenario.ShutdownAssertNoPanics(t)
|
||||
|
||||
spec := map[string]int{
|
||||
"user1": len(MustTestVersions),
|
||||
"user2": len(MustTestVersions),
|
||||
}
|
||||
|
||||
opts := []hsic.Option{hsic.WithTestName("pingallbyip")}
|
||||
if https {
|
||||
opts = append(opts, []hsic.Option{
|
||||
hsic.WithTLS(),
|
||||
}...)
|
||||
}
|
||||
|
||||
err = scenario.CreateHeadscaleEnv(spec, []tsic.Option{}, opts...)
|
||||
assertNoErrHeadscaleEnv(t, err)
|
||||
|
||||
allClients, err := scenario.ListTailscaleClients()
|
||||
assertNoErrListClients(t, err)
|
||||
|
||||
err = scenario.WaitForTailscaleSync()
|
||||
assertNoErrSync(t, err)
|
||||
|
||||
// assertClientsState(t, allClients)
|
||||
|
||||
clientIPs := make(map[TailscaleClient][]netip.Addr)
|
||||
for _, client := range allClients {
|
||||
ips, err := client.IPs()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get IPs for client %s: %s", client.Hostname(), err)
|
||||
}
|
||||
clientIPs[client] = ips
|
||||
}
|
||||
|
||||
headscale, err := scenario.Headscale()
|
||||
assertNoErrGetHeadscale(t, err)
|
||||
|
||||
listNodes, err := headscale.ListNodes()
|
||||
assert.Equal(t, len(listNodes), len(allClients))
|
||||
nodeCountBeforeLogout := len(listNodes)
|
||||
t.Logf("node count before logout: %d", nodeCountBeforeLogout)
|
||||
|
||||
for _, client := range allClients {
|
||||
err := client.Logout()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to logout client %s: %s", client.Hostname(), err)
|
||||
}
|
||||
}
|
||||
|
||||
err = scenario.WaitForTailscaleLogout()
|
||||
assertNoErrLogout(t, err)
|
||||
|
||||
t.Logf("all clients logged out")
|
||||
|
||||
// if the server is not running with HTTPS, we have to wait a bit before
|
||||
// reconnection as the newest Tailscale client has a measure that will only
|
||||
// reconnect over HTTPS if they saw a noise connection previously.
|
||||
// https://github.com/tailscale/tailscale/commit/1eaad7d3deb0815e8932e913ca1a862afa34db38
|
||||
// https://github.com/juanfont/headscale/issues/2164
|
||||
if !https {
|
||||
time.Sleep(5 * time.Minute)
|
||||
}
|
||||
|
||||
for userName := range spec {
|
||||
key, err := scenario.CreatePreAuthKey(userName, true, false)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create pre-auth key for user %s: %s", userName, err)
|
||||
}
|
||||
|
||||
// Expire the key so it can't be used
|
||||
_, err = headscale.Execute(
|
||||
[]string{
|
||||
"headscale",
|
||||
"preauthkeys",
|
||||
"--user",
|
||||
userName,
|
||||
"expire",
|
||||
key.Key,
|
||||
})
|
||||
assertNoErr(t, err)
|
||||
|
||||
err = scenario.RunTailscaleUp(userName, headscale.GetEndpoint(), key.GetKey())
|
||||
assert.ErrorContains(t, err, "authkey expired")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,7 +218,7 @@ func TestOIDCExpireNodesBasedOnTokenExpiry(t *testing.T) {
|
||||
|
||||
// This is not great, but this sadly is a time dependent test, so the
|
||||
// safe thing to do is wait out the whole TTL time before checking if
|
||||
// the clients have logged out. The Wait function cant do it itself
|
||||
// the clients have logged out. The Wait function can't do it itself
|
||||
// as it has an upper bound of 1 min.
|
||||
time.Sleep(shortAccessTTL)
|
||||
|
||||
|
||||
@@ -1827,7 +1827,7 @@ func TestPolicyBrokenConfigCommand(t *testing.T) {
|
||||
{
|
||||
// This is an unknown action, so it will return an error
|
||||
// and the config will not be applied.
|
||||
Action: "acccept",
|
||||
Action: "unknown-action",
|
||||
Sources: []string{"*"},
|
||||
Destinations: []string{"*:*"},
|
||||
},
|
||||
|
||||
@@ -348,7 +348,7 @@ func TestValidateResolvConf(t *testing.T) {
|
||||
"HEADSCALE_DNS_BASE_DOMAIN": "all-of.it",
|
||||
"HEADSCALE_DNS_NAMESERVERS_GLOBAL": `8.8.8.8`,
|
||||
"HEADSCALE_DNS_SEARCH_DOMAINS": "test1.no test2.no",
|
||||
// TODO(kradalby): this currently isnt working, need to fix it
|
||||
// TODO(kradalby): this currently isn't working, need to fix it
|
||||
// "HEADSCALE_DNS_NAMESERVERS_SPLIT": `{foo.bar.com: ["1.1.1.1"]}`,
|
||||
// "HEADSCALE_DNS_EXTRA_RECORDS": `[{ name: "prometheus.myvpn.example.com", type: "A", value: "100.64.0.4" }]`,
|
||||
},
|
||||
|
||||
@@ -56,7 +56,7 @@ func AddContainerToNetwork(
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO(kradalby): This doesnt work reliably, but calling the exact same functions
|
||||
// TODO(kradalby): This doesn't work reliably, but calling the exact same functions
|
||||
// seem to work fine...
|
||||
// if container, ok := pool.ContainerByName("/" + testContainer); ok {
|
||||
// err := container.ConnectToNetwork(network)
|
||||
|
||||
@@ -163,8 +163,8 @@ func New(
|
||||
runOptions.WorkingDir = dsic.workdir
|
||||
}
|
||||
|
||||
// dockertest isnt very good at handling containers that has already
|
||||
// been created, this is an attempt to make sure this container isnt
|
||||
// dockertest isn't very good at handling containers that has already
|
||||
// been created, this is an attempt to make sure this container isn't
|
||||
// present.
|
||||
err = pool.RemoveContainerByName(hostname)
|
||||
if err != nil {
|
||||
|
||||
@@ -31,7 +31,7 @@ func DefaultConfigEnv() map[string]string {
|
||||
"HEADSCALE_DERP_AUTO_UPDATE_ENABLED": "false",
|
||||
"HEADSCALE_DERP_UPDATE_FREQUENCY": "1m",
|
||||
|
||||
// a bunch of tests (ACL/Policy) rely on predicable IP alloc,
|
||||
// a bunch of tests (ACL/Policy) rely on predictable IP alloc,
|
||||
// so ensure the sequential alloc is used by default.
|
||||
"HEADSCALE_PREFIXES_ALLOCATION": string(types.IPAllocationStrategySequential),
|
||||
}
|
||||
|
||||
@@ -366,8 +366,8 @@ func New(
|
||||
}
|
||||
}
|
||||
|
||||
// dockertest isnt very good at handling containers that has already
|
||||
// been created, this is an attempt to make sure this container isnt
|
||||
// dockertest isn't very good at handling containers that has already
|
||||
// been created, this is an attempt to make sure this container isn't
|
||||
// present.
|
||||
err = pool.RemoveContainerByName(hsic.hostname)
|
||||
if err != nil {
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"github.com/juanfont/headscale/integration/hsic"
|
||||
"github.com/juanfont/headscale/integration/tsic"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/types/ipproto"
|
||||
"tailscale.com/types/views"
|
||||
"tailscale.com/wgengine/filter"
|
||||
@@ -1316,3 +1318,123 @@ func TestSubnetRouteACL(t *testing.T) {
|
||||
t.Errorf("Subnet (%s) filter, unexpected result (-want +got):\n%s", subRouter1.Hostname(), diff)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnablingExitRoutes tests enabling exit routes for clients.
|
||||
// Its more or less the same as TestEnablingRoutes, but with the --advertise-exit-node flag
|
||||
// set during login instead of set.
|
||||
func TestEnablingExitRoutes(t *testing.T) {
|
||||
IntegrationSkip(t)
|
||||
t.Parallel()
|
||||
|
||||
user := "user2"
|
||||
|
||||
scenario, err := NewScenario(dockertestMaxWait())
|
||||
assertNoErrf(t, "failed to create scenario: %s", err)
|
||||
defer scenario.ShutdownAssertNoPanics(t)
|
||||
|
||||
spec := map[string]int{
|
||||
user: 2,
|
||||
}
|
||||
|
||||
err = scenario.CreateHeadscaleEnv(spec, []tsic.Option{
|
||||
tsic.WithExtraLoginArgs([]string{"--advertise-exit-node"}),
|
||||
}, hsic.WithTestName("clienableroute"))
|
||||
assertNoErrHeadscaleEnv(t, err)
|
||||
|
||||
allClients, err := scenario.ListTailscaleClients()
|
||||
assertNoErrListClients(t, err)
|
||||
|
||||
err = scenario.WaitForTailscaleSync()
|
||||
assertNoErrSync(t, err)
|
||||
|
||||
headscale, err := scenario.Headscale()
|
||||
assertNoErrGetHeadscale(t, err)
|
||||
|
||||
err = scenario.WaitForTailscaleSync()
|
||||
assertNoErrSync(t, err)
|
||||
|
||||
var routes []*v1.Route
|
||||
err = executeAndUnmarshal(
|
||||
headscale,
|
||||
[]string{
|
||||
"headscale",
|
||||
"routes",
|
||||
"list",
|
||||
"--output",
|
||||
"json",
|
||||
},
|
||||
&routes,
|
||||
)
|
||||
|
||||
assertNoErr(t, err)
|
||||
assert.Len(t, routes, 4)
|
||||
|
||||
for _, route := range routes {
|
||||
assert.True(t, route.GetAdvertised())
|
||||
assert.False(t, route.GetEnabled())
|
||||
assert.False(t, route.GetIsPrimary())
|
||||
}
|
||||
|
||||
// Verify that no routes has been sent to the client,
|
||||
// they are not yet enabled.
|
||||
for _, client := range allClients {
|
||||
status, err := client.Status()
|
||||
assertNoErr(t, err)
|
||||
|
||||
for _, peerKey := range status.Peers() {
|
||||
peerStatus := status.Peer[peerKey]
|
||||
|
||||
assert.Nil(t, peerStatus.PrimaryRoutes)
|
||||
}
|
||||
}
|
||||
|
||||
// Enable all routes
|
||||
for _, route := range routes {
|
||||
_, err = headscale.Execute(
|
||||
[]string{
|
||||
"headscale",
|
||||
"routes",
|
||||
"enable",
|
||||
"--route",
|
||||
strconv.Itoa(int(route.GetId())),
|
||||
})
|
||||
assertNoErr(t, err)
|
||||
}
|
||||
|
||||
var enablingRoutes []*v1.Route
|
||||
err = executeAndUnmarshal(
|
||||
headscale,
|
||||
[]string{
|
||||
"headscale",
|
||||
"routes",
|
||||
"list",
|
||||
"--output",
|
||||
"json",
|
||||
},
|
||||
&enablingRoutes,
|
||||
)
|
||||
assertNoErr(t, err)
|
||||
assert.Len(t, enablingRoutes, 4)
|
||||
|
||||
for _, route := range enablingRoutes {
|
||||
assert.True(t, route.GetAdvertised())
|
||||
assert.True(t, route.GetEnabled())
|
||||
}
|
||||
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
// Verify that the clients can see the new routes
|
||||
for _, client := range allClients {
|
||||
status, err := client.Status()
|
||||
assertNoErr(t, err)
|
||||
|
||||
for _, peerKey := range status.Peers() {
|
||||
peerStatus := status.Peer[peerKey]
|
||||
|
||||
require.NotNil(t, peerStatus.AllowedIPs)
|
||||
assert.Len(t, peerStatus.AllowedIPs.AsSlice(), 4)
|
||||
assert.Contains(t, peerStatus.AllowedIPs.AsSlice(), tsaddr.AllIPv4())
|
||||
assert.Contains(t, peerStatus.AllowedIPs.AsSlice(), tsaddr.AllIPv6())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ var retry = func(times int, sleepInterval time.Duration,
|
||||
}
|
||||
|
||||
// If we get a permission denied error, we can fail immediately
|
||||
// since that is something we wont recover from by retrying.
|
||||
// since that is something we won-t recover from by retrying.
|
||||
if err != nil && isSSHNoAccessStdError(stderr) {
|
||||
return result, stderr, err
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ type TailscaleInContainer struct {
|
||||
withExtraHosts []string
|
||||
workdir string
|
||||
netfilter string
|
||||
extraLoginArgs []string
|
||||
|
||||
// build options, solely for HEAD
|
||||
buildConfig TailscaleInContainerBuildConfig
|
||||
@@ -203,6 +204,14 @@ func WithBuildTag(tag string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithExtraLoginArgs adds additional arguments to the `tailscale up` command
|
||||
// as part of the Login function.
|
||||
func WithExtraLoginArgs(args []string) Option {
|
||||
return func(tsic *TailscaleInContainer) {
|
||||
tsic.extraLoginArgs = args
|
||||
}
|
||||
}
|
||||
|
||||
// New returns a new TailscaleInContainer instance.
|
||||
func New(
|
||||
pool *dockertest.Pool,
|
||||
@@ -263,8 +272,8 @@ func New(
|
||||
tailscaleOptions.WorkingDir = tsic.workdir
|
||||
}
|
||||
|
||||
// dockertest isnt very good at handling containers that has already
|
||||
// been created, this is an attempt to make sure this container isnt
|
||||
// dockertest isn't very good at handling containers that has already
|
||||
// been created, this is an attempt to make sure this container isn't
|
||||
// present.
|
||||
err = pool.RemoveContainerByName(hostname)
|
||||
if err != nil {
|
||||
@@ -436,6 +445,10 @@ func (t *TailscaleInContainer) Login(
|
||||
"--accept-routes=false",
|
||||
}
|
||||
|
||||
if t.extraLoginArgs != nil {
|
||||
command = append(command, t.extraLoginArgs...)
|
||||
}
|
||||
|
||||
if t.withSSH {
|
||||
command = append(command, "--ssh")
|
||||
}
|
||||
@@ -475,6 +488,10 @@ func (t *TailscaleInContainer) LoginWithURL(
|
||||
"--accept-routes=false",
|
||||
}
|
||||
|
||||
if t.extraLoginArgs != nil {
|
||||
command = append(command, t.extraLoginArgs...)
|
||||
}
|
||||
|
||||
stdout, stderr, err := t.Execute(command)
|
||||
if errors.Is(err, errTailscaleNotLoggedIn) {
|
||||
return nil, errTailscaleCannotUpWithoutAuthkey
|
||||
@@ -486,15 +503,7 @@ func (t *TailscaleInContainer) LoginWithURL(
|
||||
}
|
||||
}()
|
||||
|
||||
urlStr := strings.ReplaceAll(stdout+stderr, "\nTo authenticate, visit:\n\n\t", "")
|
||||
urlStr = strings.TrimSpace(urlStr)
|
||||
|
||||
if urlStr == "" {
|
||||
return nil, fmt.Errorf("failed to get login URL: stdout: %s, stderr: %s", stdout, stderr)
|
||||
}
|
||||
|
||||
// parse URL
|
||||
loginURL, err = url.Parse(urlStr)
|
||||
loginURL, err = util.ParseLoginURLFromCLILogin(stdout + stderr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ func assertValidStatus(t *testing.T, client TailscaleClient) {
|
||||
|
||||
assert.Truef(t, status.Self.InNetworkMap, "%q is not in network map", client.Hostname())
|
||||
|
||||
// This isnt really relevant for Self as it wont be in its own socket/wireguard.
|
||||
// This isn't really relevant for Self as it won't be in its own socket/wireguard.
|
||||
// assert.Truef(t, status.Self.InMagicSock, "%q is not tracked by magicsock", client.Hostname())
|
||||
// assert.Truef(t, status.Self.InEngine, "%q is not in in wireguard engine", client.Hostname())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user