relaynode itself is not long for this world, deprecated in favour of tailscale/tailscaled. But now that the control server supports central distribution of packet filters, let's actually take advantage of it in a final, backward compatible release of relaynode.main
parent
77907a76a3
commit
57bbafde84
@ -1,63 +0,0 @@ |
||||
{ |
||||
// Declare static groups of users beyond those in the identity service |
||||
"Groups": { |
||||
"group:eng": ["u1@example.com", "u2@example.com"] |
||||
}, |
||||
|
||||
// Declare convenient hostname aliases to use in place of IP addresses |
||||
"Hosts": { |
||||
"h222": "100.2.2.2" |
||||
}, |
||||
|
||||
// Access control list |
||||
"ACLs": [ |
||||
{ |
||||
"Action": "accept", |
||||
// Match any of several users |
||||
"Users": ["a@example.com", "b@example.com"], |
||||
// Match any port on h222, and port 22 of 10.1.2.3 |
||||
"Ports": ["h222:*", "10.1.2.3:22"] |
||||
}, |
||||
{ |
||||
"Action": "accept", |
||||
// Match any user at all |
||||
"Users": ["*"], |
||||
// Match port 80 on one machine, ports 53 and 5353 on a second one, |
||||
// and ports 8000 through 8080 (a port range) on a third one. |
||||
"Ports": ["h222:80", "10.8.8.8:53,5353", "10.2.3.4:8000-8080"] |
||||
}, |
||||
{ |
||||
"Action": "accept", |
||||
// Match all users in the "Admin" role (network administrators) |
||||
"Users": ["role:Admin", "group:eng"], |
||||
// Allow access to port 22 on all servers |
||||
"Ports": ["*:22"] |
||||
}, |
||||
{ |
||||
"Action": "accept", |
||||
"Users": ["role:User"], |
||||
// Match only windows and linux workstations (not implemented yet) |
||||
"OS": ["windows", "linux"], |
||||
// Only desktop machines are allowed to access this server |
||||
"Ports": ["10.1.1.1:443"] |
||||
}, |
||||
{ |
||||
"Action": "accept", |
||||
"Users": ["*"], |
||||
// Match machines which have never been authorized, or which expired. |
||||
// (not implemented yet) |
||||
"MachineAuth": ["unauthorized", "expired"], |
||||
// Logged-in users on unauthorized machines can access the email server. |
||||
// Open the TLS ports for SMTP, IMAP, and HTTP. |
||||
"Ports": ["10.1.2.3:465", "10.1.2.3:993", "10.1.2.3:443"] |
||||
}, |
||||
|
||||
// Match absolutely everything. Comment out this section if you want |
||||
// the above ACLs to apply. |
||||
{ "Action": "accept", "Users": ["*"], "Ports": ["*:*"] }, |
||||
|
||||
// Leave this line here so that every rule can end in a comma. |
||||
// It has no effect since it has no matching rules. |
||||
{"Action": "accept"} |
||||
] |
||||
} |
||||
@ -1,228 +0,0 @@ |
||||
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package policy |
||||
|
||||
import ( |
||||
"bytes" |
||||
"errors" |
||||
"fmt" |
||||
"net" |
||||
"strconv" |
||||
"strings" |
||||
|
||||
"github.com/tailscale/hujson" |
||||
"tailscale.com/wgengine/filter" |
||||
) |
||||
|
||||
type IP = filter.IP |
||||
|
||||
const IPAny = filter.IPAny |
||||
|
||||
type row struct { |
||||
Action string |
||||
Users []string |
||||
Ports []string |
||||
} |
||||
|
||||
type Policy struct { |
||||
ACLs []row |
||||
Groups map[string][]string |
||||
Hosts map[string]IP |
||||
} |
||||
|
||||
func lineAndColumn(b []byte, ofs int64) (line, col int) { |
||||
line = 1 |
||||
for _, c := range b[:ofs] { |
||||
if c == '\n' { |
||||
col = 1 |
||||
line++ |
||||
} else { |
||||
col++ |
||||
} |
||||
} |
||||
return line, col |
||||
} |
||||
|
||||
func betterUnmarshal(b []byte, obj interface{}) error { |
||||
bio := bytes.NewReader(b) |
||||
d := hujson.NewDecoder(bio) |
||||
d.DisallowUnknownFields() |
||||
err := d.Decode(obj) |
||||
if err != nil { |
||||
switch ee := err.(type) { |
||||
case *hujson.SyntaxError: |
||||
row, col := lineAndColumn(b, ee.Offset) |
||||
return fmt.Errorf("line %d col %d: %v", row, col, ee) |
||||
default: |
||||
return fmt.Errorf("parser: %v", err) |
||||
} |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func Parse(acljson string) (*Policy, error) { |
||||
p := &Policy{} |
||||
err := betterUnmarshal([]byte(acljson), p) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
// Check syntax with an empty usermap to start with.
|
||||
// The caller might not have a valid usermap at startup, but we still
|
||||
// want to check that the acljson doesn't have any syntax errors
|
||||
// as early as possible. When the usermap updates later, it won't
|
||||
// add any new syntax errors.
|
||||
//
|
||||
// TODO(apenwarr): change unmarshal code to detect syntax errors above.
|
||||
// Right now some of the sub-objects aren't parsed until .Expand().
|
||||
emptyUserMap := make(map[string][]IP) |
||||
_, err = p.Expand(emptyUserMap) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return p, nil |
||||
} |
||||
|
||||
func parseHostPortRange(hostport string) (host string, ports []filter.PortRange, err error) { |
||||
hl := strings.Split(hostport, ":") |
||||
if len(hl) != 2 { |
||||
return "", nil, errors.New("hostport must have exactly one colon(:)") |
||||
} |
||||
host = hl[0] |
||||
portlist := hl[1] |
||||
|
||||
if portlist == "*" { |
||||
// Special case: permit hostname:* as a port wildcard.
|
||||
ports = append(ports, filter.PortRangeAny) |
||||
return host, ports, nil |
||||
} |
||||
|
||||
pl := strings.Split(portlist, ",") |
||||
for _, pp := range pl { |
||||
if len(pp) == 0 { |
||||
return "", nil, fmt.Errorf("invalid port list: %#v", portlist) |
||||
} |
||||
|
||||
pr := strings.Split(pp, "-") |
||||
if len(pr) > 2 { |
||||
return "", nil, fmt.Errorf("port range %#v: too many dashes(-)", pp) |
||||
} |
||||
|
||||
var first, last uint64 |
||||
first, err := strconv.ParseUint(pr[0], 10, 16) |
||||
if err != nil { |
||||
return "", nil, fmt.Errorf("port range %#v: invalid first integer", pp) |
||||
} |
||||
|
||||
if len(pr) >= 2 { |
||||
last, err = strconv.ParseUint(pr[1], 10, 16) |
||||
if err != nil { |
||||
return "", nil, fmt.Errorf("port range %#v: invalid last integer", pp) |
||||
} |
||||
} else { |
||||
last = first |
||||
} |
||||
|
||||
if first == 0 { |
||||
return "", nil, fmt.Errorf("port range %#v: first port must be >0, or use '*' for wildcard", pp) |
||||
} |
||||
|
||||
if first > last { |
||||
return "", nil, fmt.Errorf("port range %#v: first port must be >= last port", pp) |
||||
} |
||||
|
||||
ports = append(ports, filter.PortRange{uint16(first), uint16(last)}) |
||||
} |
||||
|
||||
return host, ports, nil |
||||
} |
||||
|
||||
func (p *Policy) Expand(usermap map[string][]IP) (filter.Matches, error) { |
||||
lcusermap := make(map[string][]IP) |
||||
for k, v := range usermap { |
||||
k = strings.ToLower(k) |
||||
lcusermap[k] = v |
||||
} |
||||
|
||||
for k, userlist := range p.Groups { |
||||
k = strings.ToLower(k) |
||||
if !strings.HasPrefix(k, "group:") { |
||||
return nil, fmt.Errorf("group[%#v]: group names must start with 'group:'", k) |
||||
} |
||||
for _, u := range userlist { |
||||
uips := lcusermap[u] |
||||
lcusermap[k] = append(lcusermap[k], uips...) |
||||
} |
||||
} |
||||
|
||||
hosts := p.Hosts |
||||
|
||||
var out filter.Matches |
||||
for _, acl := range p.ACLs { |
||||
if acl.Action != "accept" { |
||||
return nil, fmt.Errorf("action=%#v is not supported", acl.Action) |
||||
} |
||||
|
||||
var srcs []IP |
||||
for _, user := range acl.Users { |
||||
user = strings.ToLower(user) |
||||
if user == "*" { |
||||
srcs = append(srcs, IPAny) |
||||
continue |
||||
} else if strings.Contains(user, "@") || |
||||
strings.HasPrefix(user, "role:") || |
||||
strings.HasPrefix(user, "group:") { |
||||
// fine if the requested user doesn't exist.
|
||||
// we don't want to crash ACL parsing just
|
||||
// because a previously authed user gets
|
||||
// deleted. We'll silently ignore it and
|
||||
// no firewall rules are needed.
|
||||
// TODO(apenwarr): maybe print a warning?
|
||||
for _, ip := range lcusermap[user] { |
||||
if ip != IPAny { |
||||
srcs = append(srcs, ip) |
||||
} |
||||
} |
||||
} else { |
||||
return nil, fmt.Errorf("wgengine/filter: invalid username: %q: needs '@domain' or 'group:' or 'role:'", user) |
||||
} |
||||
} |
||||
|
||||
var dsts []filter.IPPortRange |
||||
for _, hostport := range acl.Ports { |
||||
host, ports, err := parseHostPortRange(hostport) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("ports=%#v: %v", hostport, err) |
||||
} |
||||
ip := net.ParseIP(host) |
||||
ipv, ok := hosts[host] |
||||
if ok { |
||||
// matches an alias; ipv is now valid
|
||||
} else if ip != nil && ip.IsUnspecified() { |
||||
// For clarity, reject 0.0.0.0 as an input
|
||||
return nil, fmt.Errorf("ports=%#v: to allow all IP addresses, use *:port, not 0.0.0.0:port", hostport) |
||||
} else if ip == nil && host == "*" { |
||||
// User explicitly requested wildcard dst ip
|
||||
ipv = IPAny |
||||
} else { |
||||
if ip != nil { |
||||
ip = ip.To4() |
||||
} |
||||
if ip == nil || len(ip) != 4 { |
||||
return nil, fmt.Errorf("ports=%#v: %#v: invalid IPv4 address", hostport, host) |
||||
} |
||||
ipv = filter.NewIP(ip) |
||||
} |
||||
|
||||
for _, pr := range ports { |
||||
dsts = append(dsts, filter.IPPortRange{ipv, pr}) |
||||
} |
||||
} |
||||
|
||||
out = append(out, filter.Match{DstPorts: dsts, SrcIPs: srcs}) |
||||
} |
||||
return out, nil |
||||
} |
||||
@ -1,156 +0,0 @@ |
||||
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package policy |
||||
|
||||
import ( |
||||
"testing" |
||||
|
||||
"github.com/google/go-cmp/cmp" |
||||
"tailscale.com/wgengine/filter" |
||||
) |
||||
|
||||
type PortRange = filter.PortRange |
||||
type IPPortRange = filter.IPPortRange |
||||
|
||||
var syntax_errors = []string{ |
||||
`{ "ACLs": []! }`, |
||||
|
||||
`{ "ACLs": [ |
||||
{"Action": "accept", "Users": [], "xPorts": ["100.122.98.50:22"]} |
||||
]}`, |
||||
|
||||
`{ "ACLs": [ |
||||
{"Action": "drop", "Users": [], "Ports": ["100.122.98.50:22"]} |
||||
]}`, |
||||
|
||||
`{ "ACLs": [ |
||||
{"Users": [], "Ports": ["100.122.98.50:22"]} |
||||
]}`, |
||||
|
||||
`{ "ACLs": [ |
||||
{"Action": "accept", "Users": [], "Ports": ["1.2.3.4"]} |
||||
]}`, |
||||
|
||||
`{ "ACLs": [ |
||||
{"Action": "accept", "Users": [], "Ports": ["1.2.3.4:0"]} |
||||
]}`, |
||||
|
||||
`{ "ACLs": [ |
||||
{"Action": "accept", "Users": [], "Ports": ["0.0.0.0:12"]} |
||||
]}`, |
||||
|
||||
`{ "ACLs": [ |
||||
{"Action": "accept", "Users": [], "Ports": ["*:0"]} |
||||
]}`, |
||||
|
||||
`{ "ACLs": [ |
||||
{"Action": "accept", "Users": [], "Ports": ["1.2.3.4:5:6"]} |
||||
]}`, |
||||
|
||||
`{ "ACLs": [ |
||||
{"Action": "accept", "Users": [], "Ports": ["1.2.3.4.5:12"]} |
||||
]}`, |
||||
|
||||
`{ "ACLs": [ |
||||
{"Action": "accept", "Users": [], "Ports": ["1.2.3.4::12"]} |
||||
]}`, |
||||
|
||||
`{ "ACLs": [ |
||||
{"Action": "accept", "Users": [], "Ports": ["1.2.3.4"]} |
||||
]}`, |
||||
|
||||
`{ "ACLs": [ |
||||
{"Action": "accept", "Users": [], "Ports": ["1.2.3.4:0-0"]} |
||||
]}`, |
||||
|
||||
`{ "ACLs": [ |
||||
{"Action": "accept", "Users": [], "Ports": ["1.2.3.4:1-10,2-"]} |
||||
]}`, |
||||
|
||||
`{ "ACLs": [ |
||||
{"Action": "accept", "Users": [], "Ports": ["1.2.3.4:1-10,*"]} |
||||
]}`, |
||||
|
||||
`{ "ACLs": [ |
||||
{"Action": "accept", "Users": [], "Ports": ["1.2.3.4,5.6.7.8:1-10"]} |
||||
]}`, |
||||
|
||||
`{ "Hosts": {"mailserver": "not-an-ip"} }`, |
||||
|
||||
`{ "Hosts": {"mailserver": "1.2.3.4:55"} }`, |
||||
|
||||
`{ "xGroups": { |
||||
"bob": ["user1", "user2"] |
||||
}}`, |
||||
} |
||||
|
||||
func TestSyntaxErrors(t *testing.T) { |
||||
for _, s := range syntax_errors { |
||||
_, err := Parse(s) |
||||
if err == nil { |
||||
t.Fatalf("Parse passed when it shouldn't. json:\n---\n%v\n---", s) |
||||
} |
||||
} |
||||
} |
||||
|
||||
func ippr(ip IP, start, end uint16) []IPPortRange { |
||||
return []IPPortRange{ |
||||
IPPortRange{ip, PortRange{start, end}}, |
||||
} |
||||
} |
||||
|
||||
func TestPolicy(t *testing.T) { |
||||
// Check ACL table parsing
|
||||
|
||||
usermap := map[string][]IP{ |
||||
"A@b.com": []IP{0x08010101, 0x08020202}, |
||||
"role:admin": []IP{0x02020202}, |
||||
"user1@org": []IP{0x99010101, 0x99010102}, |
||||
// user2 is intentionally missing
|
||||
"user3@org": []IP{0x99030303}, |
||||
"user4@org": []IP{}, |
||||
} |
||||
want := filter.Matches{ |
||||
{SrcIPs: []IP{0x08010101, 0x08020202}, DstPorts: []IPPortRange{ |
||||
IPPortRange{0x01020304, PortRange{22, 22}}, |
||||
IPPortRange{0x05060708, PortRange{23, 24}}, |
||||
IPPortRange{0x05060708, PortRange{27, 28}}, |
||||
}}, |
||||
{SrcIPs: []IP{0x02020202}, DstPorts: ippr(0x08010101, 22, 22)}, |
||||
{SrcIPs: []IP{0}, DstPorts: []IPPortRange{ |
||||
IPPortRange{0x647a6232, PortRange{0, 65535}}, |
||||
IPPortRange{0, PortRange{443, 443}}, |
||||
}}, |
||||
{SrcIPs: []IP{0x99010101, 0x99010102, 0x99030303}, DstPorts: ippr(0x01020304, 999, 999)}, |
||||
} |
||||
|
||||
p, err := Parse(` |
||||
{ |
||||
// Test comment
|
||||
"Hosts": { |
||||
"h1": "1.2.3.4", /* test comment */ |
||||
"h2": "5.6.7.8" |
||||
}, |
||||
"Groups": { |
||||
"group:eng": ["user1@org", "user2@org", "user3@org", "user4@org"] |
||||
}, |
||||
"ACLs": [ |
||||
{"Action": "accept", "Users": ["a@b.com"], "Ports": ["h1:22", "h2:23-24,27-28"]}, |
||||
{"Action": "accept", "Users": ["role:Admin"], "Ports": ["8.1.1.1:22"]}, |
||||
{"Action": "accept", "Users": ["*"], "Ports": ["100.122.98.50:*", "*:443"]}, |
||||
{"Action": "accept", "Users": ["group:eng"], "Ports": ["h1:999"]}, |
||||
]} |
||||
`) |
||||
if err != nil { |
||||
t.Fatalf("Parse failed: %v", err) |
||||
} |
||||
matches, err := p.Expand(usermap) |
||||
if err != nil { |
||||
t.Fatalf("Expand failed: %v", err) |
||||
} |
||||
if diff := cmp.Diff(want, matches); diff != "" { |
||||
t.Fatalf("Expand mismatch (-want +got):\n%s", diff) |
||||
} |
||||
} |
||||
Loading…
Reference in new issue