Migrate ACLs syntax to new Tailscale format

Implements #617.

Tailscale has changed the format of their ACLs to use a more firewall-y terms ("users" & "ports" -> "src" & "dst"). They have also started using all-lowercase tags. This PR applies these changes.
This commit is contained in:
Juan Font Alonso 2022-06-08 13:40:15 +02:00
parent 8fed47a2be
commit 3e353004b8
12 changed files with 126 additions and 124 deletions

22
acls.go
View file

@ -123,11 +123,11 @@ func (h *Headscale) generateACLRules() ([]tailcfg.FilterRule, error) {
} }
srcIPs := []string{} srcIPs := []string{}
for innerIndex, user := range acl.Users { for innerIndex, src := range acl.Sources {
srcs, err := h.generateACLPolicySrcIP(machines, *h.aclPolicy, user) srcs, err := h.generateACLPolicySrcIP(machines, *h.aclPolicy, src)
if err != nil { if err != nil {
log.Error(). log.Error().
Msgf("Error parsing ACL %d, User %d", index, innerIndex) Msgf("Error parsing ACL %d, Source %d", index, innerIndex)
return nil, err return nil, err
} }
@ -135,11 +135,11 @@ func (h *Headscale) generateACLRules() ([]tailcfg.FilterRule, error) {
} }
destPorts := []tailcfg.NetPortRange{} destPorts := []tailcfg.NetPortRange{}
for innerIndex, ports := range acl.Ports { for innerIndex, dest := range acl.Destinations {
dests, err := h.generateACLPolicyDestPorts(machines, *h.aclPolicy, ports) dests, err := h.generateACLPolicyDest(machines, *h.aclPolicy, dest)
if err != nil { if err != nil {
log.Error(). log.Error().
Msgf("Error parsing ACL %d, Port %d", index, innerIndex) Msgf("Error parsing ACL %d, Destination %d", index, innerIndex)
return nil, err return nil, err
} }
@ -158,17 +158,17 @@ func (h *Headscale) generateACLRules() ([]tailcfg.FilterRule, error) {
func (h *Headscale) generateACLPolicySrcIP( func (h *Headscale) generateACLPolicySrcIP(
machines []Machine, machines []Machine,
aclPolicy ACLPolicy, aclPolicy ACLPolicy,
u string, src string,
) ([]string, error) { ) ([]string, error) {
return expandAlias(machines, aclPolicy, u, h.cfg.OIDC.StripEmaildomain) return expandAlias(machines, aclPolicy, src, h.cfg.OIDC.StripEmaildomain)
} }
func (h *Headscale) generateACLPolicyDestPorts( func (h *Headscale) generateACLPolicyDest(
machines []Machine, machines []Machine,
aclPolicy ACLPolicy, aclPolicy ACLPolicy,
d string, dest string,
) ([]tailcfg.NetPortRange, error) { ) ([]tailcfg.NetPortRange, error) {
tokens := strings.Split(d, ":") tokens := strings.Split(dest, ":")
if len(tokens) < expectedTokenItems || len(tokens) > 3 { if len(tokens) < expectedTokenItems || len(tokens) > 3 {
return nil, errInvalidPortFormat return nil, errInvalidPortFormat
} }

View file

@ -62,7 +62,7 @@ func (s *Suite) TestBasicRule(c *check.C) {
func (s *Suite) TestInvalidAction(c *check.C) { func (s *Suite) TestInvalidAction(c *check.C) {
app.aclPolicy = &ACLPolicy{ app.aclPolicy = &ACLPolicy{
ACLs: []ACL{ ACLs: []ACL{
{Action: "invalidAction", Users: []string{"*"}, Ports: []string{"*:*"}}, {Action: "invalidAction", Sources: []string{"*"}, Destinations: []string{"*:*"}},
}, },
} }
err := app.UpdateACLRules() err := app.UpdateACLRules()
@ -70,14 +70,14 @@ func (s *Suite) TestInvalidAction(c *check.C) {
} }
func (s *Suite) TestInvalidGroupInGroup(c *check.C) { func (s *Suite) TestInvalidGroupInGroup(c *check.C) {
// this ACL is wrong because the group in users sections doesn't exist // this ACL is wrong because the group in Sources sections doesn't exist
app.aclPolicy = &ACLPolicy{ app.aclPolicy = &ACLPolicy{
Groups: Groups{ Groups: Groups{
"group:test": []string{"foo"}, "group:test": []string{"foo"},
"group:error": []string{"foo", "group:test"}, "group:error": []string{"foo", "group:test"},
}, },
ACLs: []ACL{ ACLs: []ACL{
{Action: "accept", Users: []string{"group:error"}, Ports: []string{"*:*"}}, {Action: "accept", Sources: []string{"group:error"}, Destinations: []string{"*:*"}},
}, },
} }
err := app.UpdateACLRules() err := app.UpdateACLRules()
@ -88,7 +88,7 @@ func (s *Suite) TestInvalidTagOwners(c *check.C) {
// this ACL is wrong because no tagOwners own the requested tag for the server // this ACL is wrong because no tagOwners own the requested tag for the server
app.aclPolicy = &ACLPolicy{ app.aclPolicy = &ACLPolicy{
ACLs: []ACL{ ACLs: []ACL{
{Action: "accept", Users: []string{"tag:foo"}, Ports: []string{"*:*"}}, {Action: "accept", Sources: []string{"tag:foo"}, Destinations: []string{"*:*"}},
}, },
} }
err := app.UpdateACLRules() err := app.UpdateACLRules()
@ -97,8 +97,8 @@ func (s *Suite) TestInvalidTagOwners(c *check.C) {
// this test should validate that we can expand a group in a TagOWner section and // this test should validate that we can expand a group in a TagOWner section and
// match properly the IP's of the related hosts. The owner is valid and the tag is also valid. // match properly the IP's of the related hosts. The owner is valid and the tag is also valid.
// the tag is matched in the Users section. // the tag is matched in the Sources section.
func (s *Suite) TestValidExpandTagOwnersInUsers(c *check.C) { func (s *Suite) TestValidExpandTagOwnersInSources(c *check.C) {
namespace, err := app.CreateNamespace("user1") namespace, err := app.CreateNamespace("user1")
c.Assert(err, check.IsNil) c.Assert(err, check.IsNil)
@ -131,7 +131,7 @@ func (s *Suite) TestValidExpandTagOwnersInUsers(c *check.C) {
Groups: Groups{"group:test": []string{"user1", "user2"}}, Groups: Groups{"group:test": []string{"user1", "user2"}},
TagOwners: TagOwners{"tag:test": []string{"user3", "group:test"}}, TagOwners: TagOwners{"tag:test": []string{"user3", "group:test"}},
ACLs: []ACL{ ACLs: []ACL{
{Action: "accept", Users: []string{"tag:test"}, Ports: []string{"*:*"}}, {Action: "accept", Sources: []string{"tag:test"}, Destinations: []string{"*:*"}},
}, },
} }
err = app.UpdateACLRules() err = app.UpdateACLRules()
@ -143,7 +143,7 @@ func (s *Suite) TestValidExpandTagOwnersInUsers(c *check.C) {
// this test should validate that we can expand a group in a TagOWner section and // this test should validate that we can expand a group in a TagOWner section and
// match properly the IP's of the related hosts. The owner is valid and the tag is also valid. // match properly the IP's of the related hosts. The owner is valid and the tag is also valid.
// the tag is matched in the Ports section. // the tag is matched in the Destinations section.
func (s *Suite) TestValidExpandTagOwnersInPorts(c *check.C) { func (s *Suite) TestValidExpandTagOwnersInPorts(c *check.C) {
namespace, err := app.CreateNamespace("user1") namespace, err := app.CreateNamespace("user1")
c.Assert(err, check.IsNil) c.Assert(err, check.IsNil)
@ -177,7 +177,7 @@ func (s *Suite) TestValidExpandTagOwnersInPorts(c *check.C) {
Groups: Groups{"group:test": []string{"user1", "user2"}}, Groups: Groups{"group:test": []string{"user1", "user2"}},
TagOwners: TagOwners{"tag:test": []string{"user3", "group:test"}}, TagOwners: TagOwners{"tag:test": []string{"user3", "group:test"}},
ACLs: []ACL{ ACLs: []ACL{
{Action: "accept", Users: []string{"*"}, Ports: []string{"tag:test:*"}}, {Action: "accept", Sources: []string{"*"}, Destinations: []string{"tag:test:*"}},
}, },
} }
err = app.UpdateACLRules() err = app.UpdateACLRules()
@ -222,7 +222,7 @@ func (s *Suite) TestInvalidTagValidNamespace(c *check.C) {
app.aclPolicy = &ACLPolicy{ app.aclPolicy = &ACLPolicy{
TagOwners: TagOwners{"tag:test": []string{"user1"}}, TagOwners: TagOwners{"tag:test": []string{"user1"}},
ACLs: []ACL{ ACLs: []ACL{
{Action: "accept", Users: []string{"user1"}, Ports: []string{"*:*"}}, {Action: "accept", Sources: []string{"user1"}, Destinations: []string{"*:*"}},
}, },
} }
err = app.UpdateACLRules() err = app.UpdateACLRules()
@ -287,9 +287,9 @@ func (s *Suite) TestValidTagInvalidNamespace(c *check.C) {
TagOwners: TagOwners{"tag:webapp": []string{"user1"}}, TagOwners: TagOwners{"tag:webapp": []string{"user1"}},
ACLs: []ACL{ ACLs: []ACL{
{ {
Action: "accept", Action: "accept",
Users: []string{"user1"}, Sources: []string{"user1"},
Ports: []string{"tag:webapp:80,443"}, Destinations: []string{"tag:webapp:80,443"},
}, },
}, },
} }
@ -645,7 +645,7 @@ func Test_expandPorts(t *testing.T) {
wantErr: false, wantErr: false,
}, },
{ {
name: "two ports", name: "two Destinations",
args: args{portsStr: "80,443"}, args: args{portsStr: "80,443"},
want: &[]tailcfg.PortRange{ want: &[]tailcfg.PortRange{
{First: 80, Last: 80}, {First: 80, Last: 80},

View file

@ -11,18 +11,19 @@ import (
// ACLPolicy represents a Tailscale ACL Policy. // ACLPolicy represents a Tailscale ACL Policy.
type ACLPolicy struct { type ACLPolicy struct {
Groups Groups `json:"Groups" yaml:"Groups"` Groups Groups `json:"groups" yaml:"groups"`
Hosts Hosts `json:"Hosts" yaml:"Hosts"` Hosts Hosts `json:"hosts" yaml:"hosts"`
TagOwners TagOwners `json:"TagOwners" yaml:"TagOwners"` TagOwners TagOwners `json:"tagOwners" yaml:"tagOwners"`
ACLs []ACL `json:"ACLs" yaml:"ACLs"` ACLs []ACL `json:"acls" yaml:"acls"`
Tests []ACLTest `json:"Tests" yaml:"Tests"` Tests []ACLTest `json:"tests" yaml:"tests"`
} }
// ACL is a basic rule for the ACL Policy. // ACL is a basic rule for the ACL Policy.
type ACL struct { type ACL struct {
Action string `json:"Action" yaml:"Action"` Action string `json:"action" yaml:"action"`
Users []string `json:"Users" yaml:"Users"` Protocol string `json:"protocol" yaml:"protocol"`
Ports []string `json:"Ports" yaml:"Ports"` Sources []string `json:"src" yaml:"src"`
Destinations []string `json:"dst" yaml:"dst"`
} }
// Groups references a series of alias in the ACL rules. // Groups references a series of alias in the ACL rules.
@ -36,9 +37,9 @@ type TagOwners map[string][]string
// ACLTest is not implemented, but should be use to check if a certain rule is allowed. // ACLTest is not implemented, but should be use to check if a certain rule is allowed.
type ACLTest struct { type ACLTest struct {
User string `json:"User" yaml:"User"` Source string `json:"src" yaml:"src"`
Allow []string `json:"Allow" yaml:"Allow"` Accept []string `json:"accept" yaml:"accept"`
Deny []string `json:"Deny,omitempty" yaml:"Deny,omitempty"` Deny []string `json:"deny,omitempty" yaml:"deny,omitempty"`
} }
// UnmarshalJSON allows to parse the Hosts directly into netaddr objects. // UnmarshalJSON allows to parse the Hosts directly into netaddr objects.

View file

@ -188,8 +188,8 @@ func (s *Suite) TestGetACLFilteredPeers(c *check.C) {
Hosts: map[string]netaddr.IPPrefix{}, Hosts: map[string]netaddr.IPPrefix{},
TagOwners: map[string][]string{}, TagOwners: map[string][]string{},
ACLs: []ACL{ ACLs: []ACL{
{Action: "accept", Users: []string{"admin"}, Ports: []string{"*:*"}}, {Action: "accept", Sources: []string{"admin"}, Destinations: []string{"*:*"}},
{Action: "accept", Users: []string{"test"}, Ports: []string{"test:*"}}, {Action: "accept", Sources: []string{"test"}, Destinations: []string{"test:*"}},
}, },
Tests: []ACLTest{}, Tests: []ACLTest{},
} }

View file

@ -1,6 +1,6 @@
{ {
// Declare static groups of users beyond those in the identity service. // Declare static groups of users beyond those in the identity service.
"Groups": { "groups": {
"group:example": [ "group:example": [
"user1@example.com", "user1@example.com",
"user2@example.com", "user2@example.com",
@ -11,12 +11,12 @@
], ],
}, },
// Declare hostname aliases to use in place of IP addresses or subnets. // Declare hostname aliases to use in place of IP addresses or subnets.
"Hosts": { "hosts": {
"example-host-1": "100.100.100.100", "example-host-1": "100.100.100.100",
"example-host-2": "100.100.101.100/24", "example-host-2": "100.100.101.100/24",
}, },
// Define who is allowed to use which tags. // Define who is allowed to use which tags.
"TagOwners": { "tagOwners": {
// Everyone in the montreal-admins or global-admins group are // Everyone in the montreal-admins or global-admins group are
// allowed to tag servers as montreal-webserver. // allowed to tag servers as montreal-webserver.
"tag:montreal-webserver": [ "tag:montreal-webserver": [
@ -29,17 +29,18 @@
], ],
}, },
// Access control lists. // Access control lists.
"ACLs": [ "acls": [
// Engineering users, plus the president, can access port 22 (ssh) // Engineering users, plus the president, can access port 22 (ssh)
// and port 3389 (remote desktop protocol) on all servers, and all // and port 3389 (remote desktop protocol) on all servers, and all
// ports on git-server or ci-server. // ports on git-server or ci-server.
{ {
"Action": "accept", "action": "accept",
"Users": [ "protocol": "tcp",
"src": [
"group:example2", "group:example2",
"192.168.1.0/24" "192.168.1.0/24"
], ],
"Ports": [ "dst": [
"*:22,3389", "*:22,3389",
"git-server:*", "git-server:*",
"ci-server:*" "ci-server:*"
@ -48,22 +49,22 @@
// Allow engineer users to access any port on a device tagged with // Allow engineer users to access any port on a device tagged with
// tag:production. // tag:production.
{ {
"Action": "accept", "action": "accept",
"Users": [ "src": [
"group:example" "group:example"
], ],
"Ports": [ "dst": [
"tag:production:*" "tag:production:*"
], ],
}, },
// Allow servers in the my-subnet host and 192.168.1.0/24 to access hosts // Allow servers in the my-subnet host and 192.168.1.0/24 to access hosts
// on both networks. // on both networks.
{ {
"Action": "accept", "action": "accept",
"Users": [ "src": [
"example-host-2", "example-host-2",
], ],
"Ports": [ "dst": [
"example-host-1:*", "example-host-1:*",
"192.168.1.0/24:*" "192.168.1.0/24:*"
], ],
@ -72,22 +73,22 @@
// Comment out this section if you want to define specific ACL // Comment out this section if you want to define specific ACL
// restrictions above. // restrictions above.
{ {
"Action": "accept", "action": "accept",
"Users": [ "src": [
"*" "*"
], ],
"Ports": [ "dst": [
"*:*" "*:*"
], ],
}, },
// All users in Montreal are allowed to access the Montreal web // All users in Montreal are allowed to access the Montreal web
// servers. // servers.
{ {
"Action": "accept", "action": "accept",
"Users": [ "src": [
"example-host-1" "example-host-1"
], ],
"Ports": [ "dst": [
"tag:montreal-webserver:80,443" "tag:montreal-webserver:80,443"
], ],
}, },
@ -96,30 +97,30 @@
// In contrast, this doesn't grant API servers the right to initiate // In contrast, this doesn't grant API servers the right to initiate
// any connections. // any connections.
{ {
"Action": "accept", "action": "accept",
"Users": [ "src": [
"tag:montreal-webserver" "tag:montreal-webserver"
], ],
"Ports": [ "dst": [
"tag:api-server:443" "tag:api-server:443"
], ],
}, },
], ],
// Declare tests to check functionality of ACL rules // Declare tests to check functionality of ACL rules
"Tests": [ "tests": [
{ {
"User": "user1@example.com", "src": "user1@example.com",
"Allow": [ "accept": [
"example-host-1:22", "example-host-1:22",
"example-host-2:80" "example-host-2:80"
], ],
"Deny": [ "deny": [
"exapmle-host-2:100" "exapmle-host-2:100"
], ],
}, },
{ {
"User": "user2@example.com", "src": "user2@example.com",
"Allow": [ "accept": [
"100.60.3.4:22" "100.60.3.4:22"
], ],
}, },

View file

@ -3,19 +3,19 @@
{ {
"Hosts": { "hosts": {
"host-1": "100.100.100.100", "host-1": "100.100.100.100",
"subnet-1": "100.100.101.100/24", "subnet-1": "100.100.101.100/24",
}, },
"ACLs": [ "acls": [
{ {
"Action": "accept", "action": "accept",
"Users": [ "src": [
"subnet-1", "subnet-1",
"192.168.1.0/24" "192.168.1.0/24"
], ],
"Ports": [ "dst": [
"*:22,3389", "*:22,3389",
"host-1:*", "host-1:*",
], ],

View file

@ -1,24 +1,24 @@
// This ACL is used to test group expansion // This ACL is used to test group expansion
{ {
"Groups": { "groups": {
"group:example": [ "group:example": [
"testnamespace", "testnamespace",
], ],
}, },
"Hosts": { "hosts": {
"host-1": "100.100.100.100", "host-1": "100.100.100.100",
"subnet-1": "100.100.101.100/24", "subnet-1": "100.100.101.100/24",
}, },
"ACLs": [ "acls": [
{ {
"Action": "accept", "action": "accept",
"Users": [ "src": [
"group:example", "group:example",
], ],
"Ports": [ "dst": [
"host-1:*", "host-1:*",
], ],
}, },

View file

@ -1,18 +1,18 @@
// This ACL is used to test namespace expansion // This ACL is used to test namespace expansion
{ {
"Hosts": { "hosts": {
"host-1": "100.100.100.100", "host-1": "100.100.100.100",
"subnet-1": "100.100.101.100/24", "subnet-1": "100.100.101.100/24",
}, },
"ACLs": [ "acls": [
{ {
"Action": "accept", "action": "accept",
"Users": [ "src": [
"testnamespace", "testnamespace",
], ],
"Ports": [ "dst": [
"host-1:*", "host-1:*",
], ],
}, },

View file

@ -1,18 +1,18 @@
// This ACL is used to test the port range expansion // This ACL is used to test the port range expansion
{ {
"Hosts": { "hosts": {
"host-1": "100.100.100.100", "host-1": "100.100.100.100",
"subnet-1": "100.100.101.100/24", "subnet-1": "100.100.101.100/24",
}, },
"ACLs": [ "acls": [
{ {
"Action": "accept", "action": "accept",
"Users": [ "src": [
"subnet-1", "subnet-1",
], ],
"Ports": [ "dst": [
"host-1:5400-5500", "host-1:5400-5500",
], ],
}, },

View file

@ -1,18 +1,18 @@
// This ACL is used to test wildcards // This ACL is used to test wildcards
{ {
"Hosts": { "hosts": {
"host-1": "100.100.100.100", "host-1": "100.100.100.100",
"subnet-1": "100.100.101.100/24", "subnet-1": "100.100.101.100/24",
}, },
"ACLs": [ "acls": [
{ {
"Action": "accept", "Action": "accept",
"Users": [ "src": [
"*", "*",
], ],
"Ports": [ "dst": [
"host-1:*", "host-1:*",
], ],
}, },

View file

@ -1,10 +1,10 @@
--- ---
Hosts: hosts:
host-1: 100.100.100.100/32 host-1: 100.100.100.100/32
subnet-1: 100.100.101.100/24 subnet-1: 100.100.101.100/24
ACLs: acls:
- Action: accept - action: accept
Users: src:
- "*" - "*"
Ports: dst:
- host-1:* - host-1:*

View file

@ -1,18 +1,18 @@
{ {
// Declare static groups of users beyond those in the identity service. // Declare static groups of users beyond those in the identity service.
"Groups": { "groups": {
"group:example": [ "group:example": [
"user1@example.com", "user1@example.com",
"user2@example.com", "user2@example.com",
], ],
}, },
// Declare hostname aliases to use in place of IP addresses or subnets. // Declare hostname aliases to use in place of IP addresses or subnets.
"Hosts": { "hosts": {
"example-host-1": "100.100.100.100", "example-host-1": "100.100.100.100",
"example-host-2": "100.100.101.100/24", "example-host-2": "100.100.101.100/24",
}, },
// Define who is allowed to use which tags. // Define who is allowed to use which tags.
"TagOwners": { "tagOwners": {
// Everyone in the montreal-admins or global-admins group are // Everyone in the montreal-admins or global-admins group are
// allowed to tag servers as montreal-webserver. // allowed to tag servers as montreal-webserver.
"tag:montreal-webserver": [ "tag:montreal-webserver": [
@ -26,17 +26,17 @@
], ],
}, },
// Access control lists. // Access control lists.
"ACLs": [ "acls": [
// Engineering users, plus the president, can access port 22 (ssh) // Engineering users, plus the president, can access port 22 (ssh)
// and port 3389 (remote desktop protocol) on all servers, and all // and port 3389 (remote desktop protocol) on all servers, and all
// ports on git-server or ci-server. // ports on git-server or ci-server.
{ {
"Action": "accept", "action": "accept",
"Users": [ "src": [
"group:engineering", "group:engineering",
"president@example.com" "president@example.com"
], ],
"Ports": [ "dst": [
"*:22,3389", "*:22,3389",
"git-server:*", "git-server:*",
"ci-server:*" "ci-server:*"
@ -45,23 +45,23 @@
// Allow engineer users to access any port on a device tagged with // Allow engineer users to access any port on a device tagged with
// tag:production. // tag:production.
{ {
"Action": "accept", "action": "accept",
"Users": [ "src": [
"group:engineers" "group:engineers"
], ],
"Ports": [ "dst": [
"tag:production:*" "tag:production:*"
], ],
}, },
// Allow servers in the my-subnet host and 192.168.1.0/24 to access hosts // Allow servers in the my-subnet host and 192.168.1.0/24 to access hosts
// on both networks. // on both networks.
{ {
"Action": "accept", "action": "accept",
"Users": [ "src": [
"my-subnet", "my-subnet",
"192.168.1.0/24" "192.168.1.0/24"
], ],
"Ports": [ "dst": [
"my-subnet:*", "my-subnet:*",
"192.168.1.0/24:*" "192.168.1.0/24:*"
], ],
@ -70,22 +70,22 @@
// Comment out this section if you want to define specific ACL // Comment out this section if you want to define specific ACL
// restrictions above. // restrictions above.
{ {
"Action": "accept", "action": "accept",
"Users": [ "src": [
"*" "*"
], ],
"Ports": [ "dst": [
"*:*" "*:*"
], ],
}, },
// All users in Montreal are allowed to access the Montreal web // All users in Montreal are allowed to access the Montreal web
// servers. // servers.
{ {
"Action": "accept", "action": "accept",
"Users": [ "src": [
"group:montreal-users" "group:montreal-users"
], ],
"Ports": [ "dst": [
"tag:montreal-webserver:80,443" "tag:montreal-webserver:80,443"
], ],
}, },
@ -94,30 +94,30 @@
// In contrast, this doesn't grant API servers the right to initiate // In contrast, this doesn't grant API servers the right to initiate
// any connections. // any connections.
{ {
"Action": "accept", "action": "accept",
"Users": [ "src": [
"tag:montreal-webserver" "tag:montreal-webserver"
], ],
"Ports": [ "dst": [
"tag:api-server:443" "tag:api-server:443"
], ],
}, },
], ],
// Declare tests to check functionality of ACL rules // Declare tests to check functionality of ACL rules
"Tests": [ "tests": [
{ {
"User": "user1@example.com", "src": "user1@example.com",
"Allow": [ "accept": [
"example-host-1:22", "example-host-1:22",
"example-host-2:80" "example-host-2:80"
], ],
"Deny": [ "deny": [
"exapmle-host-2:100" "exapmle-host-2:100"
], ],
}, },
{ {
"User": "user2@example.com", "src": "user2@example.com",
"Allow": [ "accept": [
"100.60.3.4:22" "100.60.3.4:22"
], ],
}, },