cmd/relaynode: drop local --acl-file in favour of central packet filter.

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.
This commit is contained in:
Avery Pennarun 2020-02-19 23:59:51 -05:00
parent 77907a76a3
commit 57bbafde84
12 changed files with 11 additions and 518 deletions

2
.gitignore vendored
View File

@ -1,6 +1,6 @@
# Binaries for programs and plugins
*~
*.exe
*.exe~
*.dll
*.so
*.dylib

View File

@ -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"}
]
}

View File

@ -1,4 +1,3 @@
relaynode /usr/sbin
tailscale-login /usr/sbin
taillogin /usr/sbin
acl.json /etc/tailscale

View File

@ -5,7 +5,7 @@ ConditionPathExists=/var/lib/tailscale/relay.conf
[Service]
EnvironmentFile=/etc/default/tailscale-relay
ExecStart=/usr/sbin/relaynode --config=/var/lib/tailscale/relay.conf --tun=wg0 $PORT $ACL_FILE $FLAGS
ExecStart=/usr/sbin/relaynode --config=/var/lib/tailscale/relay.conf --tun=wg0 $PORT $FLAGS
Restart=on-failure
[Install]

View File

@ -11,6 +11,7 @@ arch=$(dpkg --print-architecture)
)
cp -a "$S/$dir/debian" "$dir/debtmp/"
rm -f "$dir/debtmp/debian/$package.debhelper.log"
rm -f "$dir/${package}_${version}_${arch}.deb"
(
cd "$dir/debtmp" &&
debian/rules build &&

View File

@ -8,7 +8,6 @@ mkdir "$outdir"
touch $outdir/.stamp
sfiles="
tailscale-login
acl.json
debian/*.service
*.defaults
"

View File

@ -10,5 +10,6 @@ rpmbase=$HOME/rpmbuild
mkdir -p "$rpmbase/SOURCES/"
cp "$dir/$pkg.tar.gz" "$rpmbase/SOURCES/"
rm -f "$rpmbase/RPMS/$machine/$pkg-$pkgver.$machine.rpm"
rpmbuild -bb "$dir/$pkg.spec"
mv "$rpmbase/RPMS/$machine/$pkg-$pkgver.$machine.rpm" $3

View File

@ -29,7 +29,6 @@
"github.com/tailscale/wireguard-go/wgcfg"
"tailscale.com/atomicfile"
"tailscale.com/control/controlclient"
"tailscale.com/control/policy"
"tailscale.com/logpolicy"
"tailscale.com/version"
"tailscale.com/wgengine"
@ -52,7 +51,6 @@ func main() {
rroutes := getopt.BoolLong("remote-routes", 'R', "allow routing subnets to remote nodes")
droutes := getopt.BoolLong("default-routes", 'D', "allow default route on remote node")
routes := getopt.StringLong("routes", 0, "", "list of IP ranges this node can relay")
aclfile := getopt.StringLong("acl-file", 0, "", "restrict traffic relaying according to json ACL file")
debug := getopt.StringLong("debug", 0, "", "Address of debug server")
getopt.Parse()
if len(getopt.Args()) > 0 {
@ -83,18 +81,11 @@ func main() {
}
e = wgengine.NewWatchdog(e)
var lastacljson string
var p *policy.Policy
if *aclfile == "" {
e.SetFilter(nil)
} else {
lastacljson = readOrFatal(*aclfile)
p = installFilterOrFatal(e, *aclfile, lastacljson, nil)
}
// Default filter blocks everything, until Start() is called.
e.SetFilter(filter.NewAllowNone())
var lastNetMap *controlclient.NetworkMap
var lastUserMap map[string][]filter.IP
statusFunc := func(new controlclient.Status) {
if new.URL != "" {
fmt.Fprintf(os.Stderr, "To authenticate, visit:\n\n\t%s\n\n", new.URL)
@ -122,6 +113,9 @@ func main() {
return
}
log.Printf("packet filter: %v\n", m.PacketFilter)
e.SetFilter(filter.New(m.PacketFilter))
wgcfg, err := m.WGCfg(uflags, m.DNS)
if err != nil {
log.Fatalf("Error getting wg config: %v\n", err)
@ -130,14 +124,6 @@ func main() {
if err != nil {
log.Fatalf("Error reconfiguring engine: %v\n", err)
}
lastUserMap = m.UserMap()
if p != nil {
matches, err := p.Expand(lastUserMap)
if err != nil {
log.Fatalf("Error expanding ACLs: %v\n", err)
}
e.SetFilter(filter.New(matches))
}
}
}
@ -203,31 +189,8 @@ func main() {
signal.Notify(sigCh, os.Interrupt)
signal.Notify(sigCh, syscall.SIGTERM)
t := time.NewTicker(5 * time.Second)
loop:
for {
select {
case <-t.C:
// For the sake of curiosity, request a status
// update periodically.
e.RequestStatus()
// check if aclfile has changed.
// TODO(apenwarr): use fsnotify instead of polling?
if *aclfile != "" {
json := readOrFatal(*aclfile)
if json != lastacljson {
logf("ACL file (%v) changed. Reloading filter.\n", *aclfile)
lastacljson = json
p = installFilterOrFatal(e, *aclfile, json, lastUserMap)
}
}
case <-sigCh:
<-sigCh
logf("signal received, exiting")
t.Stop()
break loop
}
}
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
@ -267,21 +230,6 @@ func readOrFatal(filename string) string {
return string(b)
}
func installFilterOrFatal(e wgengine.Engine, filename, acljson string, usermap map[string][]filter.IP) *policy.Policy {
p, err := policy.Parse(acljson)
if err != nil {
log.Fatalf("%v: json filter: %v\n", filename, err)
}
matches, err := p.Expand(usermap)
if err != nil {
log.Fatalf("%v: json filter: %v\n", filename, err)
}
e.SetFilter(filter.New(matches))
return p
}
func runDebugServer(addr string) {
mux := http.NewServeMux()
mux.HandleFunc("/debug/pprof/", pprof.Index)

View File

@ -4,11 +4,5 @@
# settings.
PORT="--port=41641"
# Comment out this line to allow all traffic to be relayed.
# Or edit the given file to allow specific traffic.
# The example file is unlikely to match any users on your network, so it
# will block all incoming traffic by default.
ACL_FILE="--acl-file=/etc/tailscale/acl.json"
# Extra flags you might want to pass to relaynode.
FLAGS=""

View File

@ -28,14 +28,12 @@ mkdir -p $D/usr/sbin $D/lib/systemd/system $D/etc/default $D/etc/tailscale
cp taillogin tailscale-login relaynode $D/usr/sbin
cp tailscale-relay.service $D/lib/systemd/system/
cp tailscale-relay.defaults $D/etc/default/tailscale-relay
cp acl.json $D/etc/tailscale/acl.json
%clean
%files
%defattr(-,root,root)
%config(noreplace) /etc/default/tailscale-relay
%config(noreplace) /etc/tailscale/acl.json
/lib/systemd/system/tailscale-relay.service
/usr/sbin/taillogin
/usr/sbin/tailscale-login

View File

@ -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
}

View File

@ -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)
}
}