mirror of
https://github.com/juanfont/headscale.git
synced 2024-11-30 02:43:05 +00:00
481 lines
10 KiB
Go
481 lines
10 KiB
Go
package headscale
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
"github.com/tailscale/hujson"
|
|
"gopkg.in/yaml.v3"
|
|
"inet.af/netaddr"
|
|
"tailscale.com/tailcfg"
|
|
)
|
|
|
|
const (
|
|
errEmptyPolicy = Error("empty policy")
|
|
errInvalidAction = Error("invalid action")
|
|
errInvalidGroup = Error("invalid group")
|
|
errInvalidTag = Error("invalid tag")
|
|
errInvalidPortFormat = Error("invalid port format")
|
|
)
|
|
|
|
const (
|
|
Base8 = 8
|
|
Base10 = 10
|
|
BitSize16 = 16
|
|
BitSize32 = 32
|
|
BitSize64 = 64
|
|
portRangeBegin = 0
|
|
portRangeEnd = 65535
|
|
expectedTokenItems = 2
|
|
)
|
|
|
|
// LoadACLPolicy loads the ACL policy from the specify path, and generates the ACL rules.
|
|
func (h *Headscale) LoadACLPolicy(path string) error {
|
|
log.Debug().
|
|
Str("func", "LoadACLPolicy").
|
|
Str("path", path).
|
|
Msg("Loading ACL policy from path")
|
|
|
|
policyFile, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer policyFile.Close()
|
|
|
|
var policy ACLPolicy
|
|
policyBytes, err := io.ReadAll(policyFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
switch filepath.Ext(path) {
|
|
case ".yml", ".yaml":
|
|
log.Debug().
|
|
Str("path", path).
|
|
Bytes("file", policyBytes).
|
|
Msg("Loading ACLs from YAML")
|
|
|
|
err := yaml.Unmarshal(policyBytes, &policy)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
log.Trace().
|
|
Interface("policy", policy).
|
|
Msg("Loaded policy from YAML")
|
|
|
|
default:
|
|
ast, err := hujson.Parse(policyBytes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ast.Standardize()
|
|
policyBytes = ast.Pack()
|
|
err = json.Unmarshal(policyBytes, &policy)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if policy.IsZero() {
|
|
return errEmptyPolicy
|
|
}
|
|
|
|
h.aclPolicy = &policy
|
|
|
|
return h.UpdateACLRules()
|
|
}
|
|
|
|
func (h *Headscale) UpdateACLRules() error {
|
|
rules, err := h.generateACLRules()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
log.Trace().Interface("ACL", rules).Msg("ACL rules generated")
|
|
h.aclRules = rules
|
|
|
|
return nil
|
|
}
|
|
|
|
func (h *Headscale) generateACLRules() ([]tailcfg.FilterRule, error) {
|
|
rules := []tailcfg.FilterRule{}
|
|
|
|
if h.aclPolicy == nil {
|
|
return nil, errEmptyPolicy
|
|
}
|
|
|
|
machines, err := h.ListMachines()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for index, acl := range h.aclPolicy.ACLs {
|
|
if acl.Action != "accept" {
|
|
return nil, errInvalidAction
|
|
}
|
|
|
|
srcIPs := []string{}
|
|
for innerIndex, user := range acl.Users {
|
|
srcs, err := h.generateACLPolicySrcIP(machines, *h.aclPolicy, user)
|
|
if err != nil {
|
|
log.Error().
|
|
Msgf("Error parsing ACL %d, User %d", index, innerIndex)
|
|
|
|
return nil, err
|
|
}
|
|
srcIPs = append(srcIPs, srcs...)
|
|
}
|
|
|
|
destPorts := []tailcfg.NetPortRange{}
|
|
for innerIndex, ports := range acl.Ports {
|
|
dests, err := h.generateACLPolicyDestPorts(machines, *h.aclPolicy, ports)
|
|
if err != nil {
|
|
log.Error().
|
|
Msgf("Error parsing ACL %d, Port %d", index, innerIndex)
|
|
|
|
return nil, err
|
|
}
|
|
destPorts = append(destPorts, dests...)
|
|
}
|
|
|
|
rules = append(rules, tailcfg.FilterRule{
|
|
SrcIPs: srcIPs,
|
|
DstPorts: destPorts,
|
|
})
|
|
}
|
|
|
|
return rules, nil
|
|
}
|
|
|
|
func (h *Headscale) generateACLPolicySrcIP(
|
|
machines []Machine,
|
|
aclPolicy ACLPolicy,
|
|
u string,
|
|
) ([]string, error) {
|
|
return expandAlias(machines, aclPolicy, u, h.cfg.OIDC.StripEmaildomain)
|
|
}
|
|
|
|
func (h *Headscale) generateACLPolicyDestPorts(
|
|
machines []Machine,
|
|
aclPolicy ACLPolicy,
|
|
d string,
|
|
) ([]tailcfg.NetPortRange, error) {
|
|
tokens := strings.Split(d, ":")
|
|
if len(tokens) < expectedTokenItems || len(tokens) > 3 {
|
|
return nil, errInvalidPortFormat
|
|
}
|
|
|
|
var alias string
|
|
// We can have here stuff like:
|
|
// git-server:*
|
|
// 192.168.1.0/24:22
|
|
// tag:montreal-webserver:80,443
|
|
// tag:api-server:443
|
|
// example-host-1:*
|
|
if len(tokens) == expectedTokenItems {
|
|
alias = tokens[0]
|
|
} else {
|
|
alias = fmt.Sprintf("%s:%s", tokens[0], tokens[1])
|
|
}
|
|
|
|
expanded, err := expandAlias(
|
|
machines,
|
|
aclPolicy,
|
|
alias,
|
|
h.cfg.OIDC.StripEmaildomain,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ports, err := expandPorts(tokens[len(tokens)-1])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
dests := []tailcfg.NetPortRange{}
|
|
for _, d := range expanded {
|
|
for _, p := range *ports {
|
|
pr := tailcfg.NetPortRange{
|
|
IP: d,
|
|
Ports: p,
|
|
}
|
|
dests = append(dests, pr)
|
|
}
|
|
}
|
|
|
|
return dests, nil
|
|
}
|
|
|
|
// expandalias has an input of either
|
|
// - a namespace
|
|
// - a group
|
|
// - a tag
|
|
// and transform these in IPAddresses.
|
|
func expandAlias(
|
|
machines []Machine,
|
|
aclPolicy ACLPolicy,
|
|
alias string,
|
|
stripEmailDomain bool,
|
|
) ([]string, error) {
|
|
ips := []string{}
|
|
if alias == "*" {
|
|
return []string{"*"}, nil
|
|
}
|
|
|
|
log.Debug().
|
|
Str("alias", alias).
|
|
Msg("Expanding")
|
|
|
|
if strings.HasPrefix(alias, "group:") {
|
|
namespaces, err := expandGroup(aclPolicy, alias, stripEmailDomain)
|
|
if err != nil {
|
|
return ips, err
|
|
}
|
|
for _, n := range namespaces {
|
|
nodes := filterMachinesByNamespace(machines, n)
|
|
for _, node := range nodes {
|
|
ips = append(ips, node.IPAddresses.ToStringSlice()...)
|
|
}
|
|
}
|
|
|
|
return ips, nil
|
|
}
|
|
|
|
if strings.HasPrefix(alias, "tag:") {
|
|
owners, err := expandTagOwners(aclPolicy, alias, stripEmailDomain)
|
|
if err != nil {
|
|
if errors.Is(err, errInvalidTag) {
|
|
for _, machine := range machines {
|
|
for _, t := range machine.ForcedTags {
|
|
if alias == t {
|
|
ips = append(ips, machine.IPAddresses.ToStringSlice()...)
|
|
}
|
|
}
|
|
}
|
|
if len(ips) == 0 {
|
|
return ips, fmt.Errorf(
|
|
"%w. %v isn't owned by a TagOwner and no forced tags are defined.",
|
|
errInvalidTag,
|
|
alias,
|
|
)
|
|
}
|
|
return ips, nil
|
|
} else {
|
|
return ips, err
|
|
}
|
|
}
|
|
for _, namespace := range owners {
|
|
machines := filterMachinesByNamespace(machines, namespace)
|
|
for _, machine := range machines {
|
|
hi := machine.GetHostInfo()
|
|
for _, t := range hi.RequestTags {
|
|
if alias == t {
|
|
ips = append(ips, machine.IPAddresses.ToStringSlice()...)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return ips, nil
|
|
}
|
|
|
|
// if alias is a namespace
|
|
nodes := filterMachinesByNamespace(machines, alias)
|
|
nodes = excludeCorrectlyTaggedNodes(aclPolicy, nodes, alias)
|
|
|
|
for _, n := range nodes {
|
|
ips = append(ips, n.IPAddresses.ToStringSlice()...)
|
|
}
|
|
if len(ips) > 0 {
|
|
return ips, nil
|
|
}
|
|
|
|
// if alias is an host
|
|
if h, ok := aclPolicy.Hosts[alias]; ok {
|
|
return []string{h.String()}, nil
|
|
}
|
|
|
|
// if alias is an IP
|
|
ip, err := netaddr.ParseIP(alias)
|
|
if err == nil {
|
|
return []string{ip.String()}, nil
|
|
}
|
|
|
|
// if alias is an CIDR
|
|
cidr, err := netaddr.ParseIPPrefix(alias)
|
|
if err == nil {
|
|
return []string{cidr.String()}, nil
|
|
}
|
|
|
|
log.Warn().Msgf("No IPs found with the alias %v", alias)
|
|
|
|
return ips, nil
|
|
}
|
|
|
|
// excludeCorrectlyTaggedNodes will remove from the list of input nodes the ones
|
|
// that are correctly tagged since they should not be listed as being in the namespace
|
|
// we assume in this function that we only have nodes from 1 namespace.
|
|
func excludeCorrectlyTaggedNodes(
|
|
aclPolicy ACLPolicy,
|
|
nodes []Machine,
|
|
namespace string,
|
|
) []Machine {
|
|
out := []Machine{}
|
|
tags := []string{}
|
|
for tag, ns := range aclPolicy.TagOwners {
|
|
if containsString(ns, namespace) {
|
|
tags = append(tags, tag)
|
|
}
|
|
}
|
|
// for each machine if tag is in tags list, don't append it.
|
|
for _, machine := range nodes {
|
|
hi := machine.GetHostInfo()
|
|
|
|
found := false
|
|
for _, t := range hi.RequestTags {
|
|
if containsString(tags, t) {
|
|
found = true
|
|
|
|
break
|
|
}
|
|
}
|
|
if len(machine.ForcedTags) > 0 {
|
|
found = true
|
|
}
|
|
if !found {
|
|
out = append(out, machine)
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func expandPorts(portsStr string) (*[]tailcfg.PortRange, error) {
|
|
if portsStr == "*" {
|
|
return &[]tailcfg.PortRange{
|
|
{First: portRangeBegin, Last: portRangeEnd},
|
|
}, nil
|
|
}
|
|
|
|
ports := []tailcfg.PortRange{}
|
|
for _, portStr := range strings.Split(portsStr, ",") {
|
|
rang := strings.Split(portStr, "-")
|
|
switch len(rang) {
|
|
case 1:
|
|
port, err := strconv.ParseUint(rang[0], Base10, BitSize16)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ports = append(ports, tailcfg.PortRange{
|
|
First: uint16(port),
|
|
Last: uint16(port),
|
|
})
|
|
|
|
case expectedTokenItems:
|
|
start, err := strconv.ParseUint(rang[0], Base10, BitSize16)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
last, err := strconv.ParseUint(rang[1], Base10, BitSize16)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ports = append(ports, tailcfg.PortRange{
|
|
First: uint16(start),
|
|
Last: uint16(last),
|
|
})
|
|
|
|
default:
|
|
return nil, errInvalidPortFormat
|
|
}
|
|
}
|
|
|
|
return &ports, nil
|
|
}
|
|
|
|
func filterMachinesByNamespace(machines []Machine, namespace string) []Machine {
|
|
out := []Machine{}
|
|
for _, machine := range machines {
|
|
if machine.Namespace.Name == namespace {
|
|
out = append(out, machine)
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
// expandTagOwners will return a list of namespace. An owner can be either a namespace or a group
|
|
// a group cannot be composed of groups.
|
|
func expandTagOwners(
|
|
aclPolicy ACLPolicy,
|
|
tag string,
|
|
stripEmailDomain bool,
|
|
) ([]string, error) {
|
|
var owners []string
|
|
ows, ok := aclPolicy.TagOwners[tag]
|
|
if !ok {
|
|
return []string{}, fmt.Errorf(
|
|
"%w. %v isn't owned by a TagOwner. Please add one first. https://tailscale.com/kb/1018/acls/#tag-owners",
|
|
errInvalidTag,
|
|
tag,
|
|
)
|
|
}
|
|
for _, owner := range ows {
|
|
if strings.HasPrefix(owner, "group:") {
|
|
gs, err := expandGroup(aclPolicy, owner, stripEmailDomain)
|
|
if err != nil {
|
|
return []string{}, err
|
|
}
|
|
owners = append(owners, gs...)
|
|
} else {
|
|
owners = append(owners, owner)
|
|
}
|
|
}
|
|
|
|
return owners, nil
|
|
}
|
|
|
|
// expandGroup will return the list of namespace inside the group
|
|
// after some validation.
|
|
func expandGroup(
|
|
aclPolicy ACLPolicy,
|
|
group string,
|
|
stripEmailDomain bool,
|
|
) ([]string, error) {
|
|
outGroups := []string{}
|
|
aclGroups, ok := aclPolicy.Groups[group]
|
|
if !ok {
|
|
return []string{}, fmt.Errorf(
|
|
"group %v isn't registered. %w",
|
|
group,
|
|
errInvalidGroup,
|
|
)
|
|
}
|
|
for _, group := range aclGroups {
|
|
if strings.HasPrefix(group, "group:") {
|
|
return []string{}, fmt.Errorf(
|
|
"%w. A group cannot be composed of groups. https://tailscale.com/kb/1018/acls/#groups",
|
|
errInvalidGroup,
|
|
)
|
|
}
|
|
grp, err := NormalizeToFQDNRules(group, stripEmailDomain)
|
|
if err != nil {
|
|
return []string{}, fmt.Errorf(
|
|
"failed to normalize group %q, err: %w",
|
|
group,
|
|
errInvalidGroup,
|
|
)
|
|
}
|
|
outGroups = append(outGroups, grp)
|
|
}
|
|
|
|
return outGroups, nil
|
|
}
|