mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-16 07:16:38 +00:00
[client, management] Feature/ssh fine grained access (#4969)
Add fine-grained SSH access control with authorized users/groups
This commit is contained in:
@@ -488,6 +488,8 @@ components:
|
||||
description: Indicates whether the peer is ephemeral or not
|
||||
type: boolean
|
||||
example: false
|
||||
local_flags:
|
||||
$ref: '#/components/schemas/PeerLocalFlags'
|
||||
required:
|
||||
- city_name
|
||||
- connected
|
||||
@@ -514,6 +516,49 @@ components:
|
||||
- serial_number
|
||||
- extra_dns_labels
|
||||
- ephemeral
|
||||
PeerLocalFlags:
|
||||
type: object
|
||||
properties:
|
||||
rosenpass_enabled:
|
||||
description: Indicates whether Rosenpass is enabled on this peer
|
||||
type: boolean
|
||||
example: true
|
||||
rosenpass_permissive:
|
||||
description: Indicates whether Rosenpass is in permissive mode or not
|
||||
type: boolean
|
||||
example: false
|
||||
server_ssh_allowed:
|
||||
description: Indicates whether SSH access this peer is allowed or not
|
||||
type: boolean
|
||||
example: true
|
||||
disable_client_routes:
|
||||
description: Indicates whether client routes are disabled on this peer or not
|
||||
type: boolean
|
||||
example: false
|
||||
disable_server_routes:
|
||||
description: Indicates whether server routes are disabled on this peer or not
|
||||
type: boolean
|
||||
example: false
|
||||
disable_dns:
|
||||
description: Indicates whether DNS management is disabled on this peer or not
|
||||
type: boolean
|
||||
example: false
|
||||
disable_firewall:
|
||||
description: Indicates whether firewall management is disabled on this peer or not
|
||||
type: boolean
|
||||
example: false
|
||||
block_lan_access:
|
||||
description: Indicates whether LAN access is blocked on this peer when used as a routing peer
|
||||
type: boolean
|
||||
example: false
|
||||
block_inbound:
|
||||
description: Indicates whether inbound traffic is blocked on this peer
|
||||
type: boolean
|
||||
example: false
|
||||
lazy_connection_enabled:
|
||||
description: Indicates whether lazy connection is enabled on this peer
|
||||
type: boolean
|
||||
example: false
|
||||
PeerTemporaryAccessRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -936,7 +981,7 @@ components:
|
||||
protocol:
|
||||
description: Policy rule type of the traffic
|
||||
type: string
|
||||
enum: ["all", "tcp", "udp", "icmp"]
|
||||
enum: ["all", "tcp", "udp", "icmp", "netbird-ssh"]
|
||||
example: "tcp"
|
||||
ports:
|
||||
description: Policy rule affected ports
|
||||
@@ -949,6 +994,14 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RulePortRange'
|
||||
authorized_groups:
|
||||
description: Map of user group ids to a list of local users
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
example: "group1"
|
||||
required:
|
||||
- name
|
||||
- enabled
|
||||
|
||||
@@ -130,10 +130,11 @@ const (
|
||||
|
||||
// Defines values for PolicyRuleProtocol.
|
||||
const (
|
||||
PolicyRuleProtocolAll PolicyRuleProtocol = "all"
|
||||
PolicyRuleProtocolIcmp PolicyRuleProtocol = "icmp"
|
||||
PolicyRuleProtocolTcp PolicyRuleProtocol = "tcp"
|
||||
PolicyRuleProtocolUdp PolicyRuleProtocol = "udp"
|
||||
PolicyRuleProtocolAll PolicyRuleProtocol = "all"
|
||||
PolicyRuleProtocolIcmp PolicyRuleProtocol = "icmp"
|
||||
PolicyRuleProtocolNetbirdSsh PolicyRuleProtocol = "netbird-ssh"
|
||||
PolicyRuleProtocolTcp PolicyRuleProtocol = "tcp"
|
||||
PolicyRuleProtocolUdp PolicyRuleProtocol = "udp"
|
||||
)
|
||||
|
||||
// Defines values for PolicyRuleMinimumAction.
|
||||
@@ -144,10 +145,11 @@ const (
|
||||
|
||||
// Defines values for PolicyRuleMinimumProtocol.
|
||||
const (
|
||||
PolicyRuleMinimumProtocolAll PolicyRuleMinimumProtocol = "all"
|
||||
PolicyRuleMinimumProtocolIcmp PolicyRuleMinimumProtocol = "icmp"
|
||||
PolicyRuleMinimumProtocolTcp PolicyRuleMinimumProtocol = "tcp"
|
||||
PolicyRuleMinimumProtocolUdp PolicyRuleMinimumProtocol = "udp"
|
||||
PolicyRuleMinimumProtocolAll PolicyRuleMinimumProtocol = "all"
|
||||
PolicyRuleMinimumProtocolIcmp PolicyRuleMinimumProtocol = "icmp"
|
||||
PolicyRuleMinimumProtocolNetbirdSsh PolicyRuleMinimumProtocol = "netbird-ssh"
|
||||
PolicyRuleMinimumProtocolTcp PolicyRuleMinimumProtocol = "tcp"
|
||||
PolicyRuleMinimumProtocolUdp PolicyRuleMinimumProtocol = "udp"
|
||||
)
|
||||
|
||||
// Defines values for PolicyRuleUpdateAction.
|
||||
@@ -158,10 +160,11 @@ const (
|
||||
|
||||
// Defines values for PolicyRuleUpdateProtocol.
|
||||
const (
|
||||
PolicyRuleUpdateProtocolAll PolicyRuleUpdateProtocol = "all"
|
||||
PolicyRuleUpdateProtocolIcmp PolicyRuleUpdateProtocol = "icmp"
|
||||
PolicyRuleUpdateProtocolTcp PolicyRuleUpdateProtocol = "tcp"
|
||||
PolicyRuleUpdateProtocolUdp PolicyRuleUpdateProtocol = "udp"
|
||||
PolicyRuleUpdateProtocolAll PolicyRuleUpdateProtocol = "all"
|
||||
PolicyRuleUpdateProtocolIcmp PolicyRuleUpdateProtocol = "icmp"
|
||||
PolicyRuleUpdateProtocolNetbirdSsh PolicyRuleUpdateProtocol = "netbird-ssh"
|
||||
PolicyRuleUpdateProtocolTcp PolicyRuleUpdateProtocol = "tcp"
|
||||
PolicyRuleUpdateProtocolUdp PolicyRuleUpdateProtocol = "udp"
|
||||
)
|
||||
|
||||
// Defines values for ResourceType.
|
||||
@@ -1077,7 +1080,8 @@ type Peer struct {
|
||||
LastLogin time.Time `json:"last_login"`
|
||||
|
||||
// LastSeen Last time peer connected to Netbird's management service
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
LocalFlags *PeerLocalFlags `json:"local_flags,omitempty"`
|
||||
|
||||
// LoginExpirationEnabled Indicates whether peer login expiration has been enabled or not
|
||||
LoginExpirationEnabled bool `json:"login_expiration_enabled"`
|
||||
@@ -1167,7 +1171,8 @@ type PeerBatch struct {
|
||||
LastLogin time.Time `json:"last_login"`
|
||||
|
||||
// LastSeen Last time peer connected to Netbird's management service
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
LocalFlags *PeerLocalFlags `json:"local_flags,omitempty"`
|
||||
|
||||
// LoginExpirationEnabled Indicates whether peer login expiration has been enabled or not
|
||||
LoginExpirationEnabled bool `json:"login_expiration_enabled"`
|
||||
@@ -1197,6 +1202,39 @@ type PeerBatch struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// PeerLocalFlags defines model for PeerLocalFlags.
|
||||
type PeerLocalFlags struct {
|
||||
// BlockInbound Indicates whether inbound traffic is blocked on this peer
|
||||
BlockInbound *bool `json:"block_inbound,omitempty"`
|
||||
|
||||
// BlockLanAccess Indicates whether LAN access is blocked on this peer when used as a routing peer
|
||||
BlockLanAccess *bool `json:"block_lan_access,omitempty"`
|
||||
|
||||
// DisableClientRoutes Indicates whether client routes are disabled on this peer or not
|
||||
DisableClientRoutes *bool `json:"disable_client_routes,omitempty"`
|
||||
|
||||
// DisableDns Indicates whether DNS management is disabled on this peer or not
|
||||
DisableDns *bool `json:"disable_dns,omitempty"`
|
||||
|
||||
// DisableFirewall Indicates whether firewall management is disabled on this peer or not
|
||||
DisableFirewall *bool `json:"disable_firewall,omitempty"`
|
||||
|
||||
// DisableServerRoutes Indicates whether server routes are disabled on this peer or not
|
||||
DisableServerRoutes *bool `json:"disable_server_routes,omitempty"`
|
||||
|
||||
// LazyConnectionEnabled Indicates whether lazy connection is enabled on this peer
|
||||
LazyConnectionEnabled *bool `json:"lazy_connection_enabled,omitempty"`
|
||||
|
||||
// RosenpassEnabled Indicates whether Rosenpass is enabled on this peer
|
||||
RosenpassEnabled *bool `json:"rosenpass_enabled,omitempty"`
|
||||
|
||||
// RosenpassPermissive Indicates whether Rosenpass is in permissive mode or not
|
||||
RosenpassPermissive *bool `json:"rosenpass_permissive,omitempty"`
|
||||
|
||||
// ServerSshAllowed Indicates whether SSH access this peer is allowed or not
|
||||
ServerSshAllowed *bool `json:"server_ssh_allowed,omitempty"`
|
||||
}
|
||||
|
||||
// PeerMinimum defines model for PeerMinimum.
|
||||
type PeerMinimum struct {
|
||||
// Id Peer ID
|
||||
@@ -1349,6 +1387,9 @@ type PolicyRule struct {
|
||||
// Action Policy rule accept or drops packets
|
||||
Action PolicyRuleAction `json:"action"`
|
||||
|
||||
// AuthorizedGroups Map of user group ids to a list of local users
|
||||
AuthorizedGroups *map[string][]string `json:"authorized_groups,omitempty"`
|
||||
|
||||
// Bidirectional Define if the rule is applicable in both directions, sources, and destinations.
|
||||
Bidirectional bool `json:"bidirectional"`
|
||||
|
||||
@@ -1393,6 +1434,9 @@ type PolicyRuleMinimum struct {
|
||||
// Action Policy rule accept or drops packets
|
||||
Action PolicyRuleMinimumAction `json:"action"`
|
||||
|
||||
// AuthorizedGroups Map of user group ids to a list of local users
|
||||
AuthorizedGroups *map[string][]string `json:"authorized_groups,omitempty"`
|
||||
|
||||
// Bidirectional Define if the rule is applicable in both directions, sources, and destinations.
|
||||
Bidirectional bool `json:"bidirectional"`
|
||||
|
||||
@@ -1426,6 +1470,9 @@ type PolicyRuleUpdate struct {
|
||||
// Action Policy rule accept or drops packets
|
||||
Action PolicyRuleUpdateAction `json:"action"`
|
||||
|
||||
// AuthorizedGroups Map of user group ids to a list of local users
|
||||
AuthorizedGroups *map[string][]string `json:"authorized_groups,omitempty"`
|
||||
|
||||
// Bidirectional Define if the rule is applicable in both directions, sources, and destinations.
|
||||
Bidirectional bool `json:"bidirectional"`
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -332,6 +332,24 @@ message NetworkMap {
|
||||
bool routesFirewallRulesIsEmpty = 11;
|
||||
|
||||
repeated ForwardingRule forwardingRules = 12;
|
||||
|
||||
// SSHAuth represents SSH authorization configuration
|
||||
SSHAuth sshAuth = 13;
|
||||
}
|
||||
|
||||
message SSHAuth {
|
||||
// UserIDClaim is the JWT claim to be used to get the users ID
|
||||
string UserIDClaim = 1;
|
||||
|
||||
// AuthorizedUsers is a list of hashed user IDs authorized to access this peer via SSH
|
||||
repeated bytes AuthorizedUsers = 2;
|
||||
|
||||
// MachineUsers is a map of machine user names to their corresponding indexes in the AuthorizedUsers list
|
||||
map<string, MachineUserIndexes> machine_users = 3;
|
||||
}
|
||||
|
||||
message MachineUserIndexes {
|
||||
repeated uint32 indexes = 1;
|
||||
}
|
||||
|
||||
// RemotePeerConfig represents a configuration of a remote peer.
|
||||
|
||||
28
shared/sshauth/userhash.go
Normal file
28
shared/sshauth/userhash.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package sshauth
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
|
||||
"golang.org/x/crypto/blake2b"
|
||||
)
|
||||
|
||||
// UserIDHash represents a hashed user ID (BLAKE2b-128)
|
||||
type UserIDHash [16]byte
|
||||
|
||||
// HashUserID hashes a user ID using BLAKE2b-128 and returns the hash value
|
||||
// This function must produce the same hash on both client and management server
|
||||
func HashUserID(userID string) (UserIDHash, error) {
|
||||
hash, err := blake2b.New(16, nil)
|
||||
if err != nil {
|
||||
return UserIDHash{}, err
|
||||
}
|
||||
hash.Write([]byte(userID))
|
||||
var result UserIDHash
|
||||
copy(result[:], hash.Sum(nil))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// String returns the hexadecimal string representation of the hash
|
||||
func (h UserIDHash) String() string {
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
210
shared/sshauth/userhash_test.go
Normal file
210
shared/sshauth/userhash_test.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package sshauth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHashUserID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
userID string
|
||||
}{
|
||||
{
|
||||
name: "simple user ID",
|
||||
userID: "user@example.com",
|
||||
},
|
||||
{
|
||||
name: "UUID format",
|
||||
userID: "550e8400-e29b-41d4-a716-446655440000",
|
||||
},
|
||||
{
|
||||
name: "numeric ID",
|
||||
userID: "12345",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
userID: "",
|
||||
},
|
||||
{
|
||||
name: "special characters",
|
||||
userID: "user+test@domain.com",
|
||||
},
|
||||
{
|
||||
name: "unicode characters",
|
||||
userID: "用户@example.com",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
hash, err := HashUserID(tt.userID)
|
||||
if err != nil {
|
||||
t.Errorf("HashUserID() error = %v, want nil", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify hash is non-zero for non-empty inputs
|
||||
if tt.userID != "" && hash == [16]byte{} {
|
||||
t.Errorf("HashUserID() returned zero hash for non-empty input")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashUserID_Consistency(t *testing.T) {
|
||||
userID := "test@example.com"
|
||||
|
||||
hash1, err1 := HashUserID(userID)
|
||||
if err1 != nil {
|
||||
t.Fatalf("First HashUserID() error = %v", err1)
|
||||
}
|
||||
|
||||
hash2, err2 := HashUserID(userID)
|
||||
if err2 != nil {
|
||||
t.Fatalf("Second HashUserID() error = %v", err2)
|
||||
}
|
||||
|
||||
if hash1 != hash2 {
|
||||
t.Errorf("HashUserID() is not consistent: got %v and %v for same input", hash1, hash2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashUserID_Uniqueness(t *testing.T) {
|
||||
tests := []struct {
|
||||
userID1 string
|
||||
userID2 string
|
||||
}{
|
||||
{"user1@example.com", "user2@example.com"},
|
||||
{"alice@domain.com", "bob@domain.com"},
|
||||
{"test", "test1"},
|
||||
{"", "a"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
hash1, err1 := HashUserID(tt.userID1)
|
||||
if err1 != nil {
|
||||
t.Fatalf("HashUserID(%s) error = %v", tt.userID1, err1)
|
||||
}
|
||||
|
||||
hash2, err2 := HashUserID(tt.userID2)
|
||||
if err2 != nil {
|
||||
t.Fatalf("HashUserID(%s) error = %v", tt.userID2, err2)
|
||||
}
|
||||
|
||||
if hash1 == hash2 {
|
||||
t.Errorf("HashUserID() collision: %s and %s produced same hash %v", tt.userID1, tt.userID2, hash1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserIDHash_String(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hash UserIDHash
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "zero hash",
|
||||
hash: [16]byte{},
|
||||
expected: "00000000000000000000000000000000",
|
||||
},
|
||||
{
|
||||
name: "small value",
|
||||
hash: [16]byte{15: 0xff},
|
||||
expected: "000000000000000000000000000000ff",
|
||||
},
|
||||
{
|
||||
name: "large value",
|
||||
hash: [16]byte{8: 0xde, 9: 0xad, 10: 0xbe, 11: 0xef, 12: 0xca, 13: 0xfe, 14: 0xba, 15: 0xbe},
|
||||
expected: "0000000000000000deadbeefcafebabe",
|
||||
},
|
||||
{
|
||||
name: "max value",
|
||||
hash: [16]byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff},
|
||||
expected: "ffffffffffffffffffffffffffffffff",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.hash.String()
|
||||
if result != tt.expected {
|
||||
t.Errorf("UserIDHash.String() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserIDHash_String_Length(t *testing.T) {
|
||||
// Test that String() always returns 32 hex characters (16 bytes * 2)
|
||||
userID := "test@example.com"
|
||||
hash, err := HashUserID(userID)
|
||||
if err != nil {
|
||||
t.Fatalf("HashUserID() error = %v", err)
|
||||
}
|
||||
|
||||
result := hash.String()
|
||||
if len(result) != 32 {
|
||||
t.Errorf("UserIDHash.String() length = %d, want 32", len(result))
|
||||
}
|
||||
|
||||
// Verify it's valid hex
|
||||
for i, c := range result {
|
||||
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
|
||||
t.Errorf("UserIDHash.String() contains non-hex character at position %d: %c", i, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashUserID_KnownValues(t *testing.T) {
|
||||
// Test with known BLAKE2b-128 values to ensure correct implementation
|
||||
tests := []struct {
|
||||
name string
|
||||
userID string
|
||||
expected UserIDHash
|
||||
}{
|
||||
{
|
||||
name: "empty string",
|
||||
userID: "",
|
||||
// BLAKE2b-128 of empty string
|
||||
expected: [16]byte{0xca, 0xe6, 0x69, 0x41, 0xd9, 0xef, 0xbd, 0x40, 0x4e, 0x4d, 0x88, 0x75, 0x8e, 0xa6, 0x76, 0x70},
|
||||
},
|
||||
{
|
||||
name: "single character 'a'",
|
||||
userID: "a",
|
||||
// BLAKE2b-128 of "a"
|
||||
expected: [16]byte{0x27, 0xc3, 0x5e, 0x6e, 0x93, 0x73, 0x87, 0x7f, 0x29, 0xe5, 0x62, 0x46, 0x4e, 0x46, 0x49, 0x7e},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
hash, err := HashUserID(tt.userID)
|
||||
if err != nil {
|
||||
t.Errorf("HashUserID() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if hash != tt.expected {
|
||||
t.Errorf("HashUserID(%q) = %x, want %x",
|
||||
tt.userID, hash, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkHashUserID(b *testing.B) {
|
||||
userID := "user@example.com"
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = HashUserID(userID)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUserIDHash_String(b *testing.B) {
|
||||
hash := UserIDHash([16]byte{8: 0xde, 9: 0xad, 10: 0xbe, 11: 0xef, 12: 0xca, 13: 0xfe, 14: 0xba, 15: 0xbe})
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = hash.String()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user