Skip to content

ACLs

A key component of tailscale is the notion of Tailnet. This notion is hidden but the implications that it have on how to use tailscale are not.

For tailscale an tailnet is the following:

For personal users, you are a tailnet of many devices and one person. Each device gets a private Tailscale IP address in the CGNAT range and every device can talk directly to every other device, wherever they are on the internet.

For businesses and organizations, a tailnet is many devices and many users. It can be based on your Microsoft Active Directory, your Google Workspace, a GitHub organization, Okta tenancy, or other identity provider namespace. All of the devices and users in your tailnet can be seen by the tailnet administrators in the Tailscale admin console. There you can apply tailnet-wide configuration, such as ACLs that affect visibility of devices inside your tailnet, DNS settings, and more.

Current implementation and issues

Currently in headscale, the namespaces are used both as tailnet and users. The issue is that if we want to use the ACL's we can't use both at the same time.

Tailnet's cannot communicate with each others. So we can't have an ACL that authorize tailnet (namespace) A to talk to tailnet (namespace) B.

We also can't write ACLs based on the users (namespaces in headscale) since all devices belong to the same user.

With the current implementation the only ACL that we can user is to associate each headscale IP to a host manually then write the ACLs according to this manual mapping.

{
  "hosts": {
    "host1": "100.64.0.1",
    "server": "100.64.0.2"
  },
  "acls": [
    { "action": "accept", "users": ["host1"], "ports": ["host2:80,443"] }
  ]
}

While this works, it requires a lot of manual editing on the configuration and to keep track of all devices IP address.

Proposition for a next implementation

In order to ease the use of ACL's we need to split the tailnet and users notion.

A solution could be to consider a headscale server (in it's entirety) as a tailnet.

For personal users the default behavior could either allow all communications between all namespaces (like tailscale) or dissallow all communications between namespaces (current behavior).

For businesses and organisations, viewing a headscale instance a single tailnet would allow users (namespace) to talk to each other with the ACLs. As described in tailscale's documentation [[1]], a server should be tagged and personnal devices should be tied to a user. Translated in headscale's terms each user can have multiple devices and all those devices should be in the same namespace. The servers should be tagged and used as such.

This implementation would render useless the sharing feature that is currently implemented since an ACL could do the same. Simplifying to only one user interface to do one thing is easier and less confusing for the users.

To better suit the ACLs in this proposition, it's advised to consider that each namespaces belong to one person. This person can have multiple devices, they will all be considered as the same user in the ACLs. OIDC feature wouldn't need to map people to namespace, just create a namespace if the person isn't registered yet.

As a sidenote, users would like to write ACLs as YAML. We should offer users the ability to rules in either format (HuJSON or YAML).

Example

Let's build an example use case for a small business (It may be the place where ACL's are the most useful).

We have a small company with a boss, an admin, two developper and an intern.

The boss should have access to all servers but not to the users hosts. Admin should also have access to all hosts except that their permissions should be limited to maintaining the hosts (for example purposes). The developers can do anything they want on dev hosts, but only watch on productions hosts. Intern can only interact with the development servers.

Each user have at least a device connected to the network and we have some servers.

  • database.prod
  • database.dev
  • app-server1.prod
  • app-server1.dev
  • billing.internal

Current headscale implementation

Let's create some namespaces

headscale namespaces create prod
headscale namespaces create dev
headscale namespaces create internal
headscale namespaces create users

headscale nodes register -n users boss-computer
headscale nodes register -n users admin1-computer
headscale nodes register -n users dev1-computer
headscale nodes register -n users dev1-phone
headscale nodes register -n users dev2-computer
headscale nodes register -n users intern1-computer

headscale nodes register -n prod database
headscale nodes register -n prod app-server1

headscale nodes register -n dev database
headscale nodes register -n dev app-server1

headscale nodes register -n internal billing

headscale nodes list
ID  | Name              | Namespace | IP address
1   | boss-computer     | users     | 100.64.0.1
2   | admin1-computer   | users     | 100.64.0.2
3   | dev1-computer     | users     | 100.64.0.3
4   | dev1-phone        | users     | 100.64.0.4
5   | dev2-computer     | users     | 100.64.0.5
6   | intern1-computer  | users     | 100.64.0.6
7   | database          | prod      | 100.64.0.7
8   | app-server1       | prod      | 100.64.0.8
9   | database          | dev       | 100.64.0.9
10  | app-server1       | dev       | 100.64.0.10
11  | internal          | internal  | 100.64.0.11

In order to only allow the communications related to our description above we need to add the following ACLs

{
  "hosts": {
    "boss-computer": "100.64.0.1",
    "admin1-computer": "100.64.0.2",
    "dev1-computer": "100.64.0.3",
    "dev1-phone": "100.64.0.4",
    "dev2-computer": "100.64.0.5",
    "intern1-computer": "100.64.0.6",
    "prod-app-server1": "100.64.0.8"
  },
  "groups": {
    "group:dev": ["dev1-computer", "dev1-phone", "dev2-computer"],
    "group:admin": ["admin1-computer"],
    "group:boss": ["boss-computer"],
    "group:intern": ["intern1-computer"]
  },
  "acls": [
    // boss have access to all servers but no users hosts
    {
      "action": "accept",
      "users": ["group:boss"],
      "ports": ["prod:*", "dev:*", "internal:*"]
    },

    // admin have access to adminstration port (lets only consider port 22 here)
    {
      "action": "accept",
      "users": ["group:admin"],
      "ports": ["prod:22", "dev:22", "internal:22"]
    },

    // dev can do anything on dev servers and check access on prod servers
    {
      "action": "accept",
      "users": ["group:dev"],
      "ports": ["dev:*", "prod-app-server1:80,443"]
    },

    // interns only have access to port 80 and 443 on dev servers (lame internship)
    { "action": "accept", "users": ["group:intern"], "ports": ["dev:80,443"] },

    // users can access their own devices
    {
      "action": "accept",
      "users": ["dev1-computer"],
      "ports": ["dev1-phone:*"]
    },
    {
      "action": "accept",
      "users": ["dev1-phone"],
      "ports": ["dev1-computer:*"]
    },

    // internal namespace communications should still be allowed within the namespace
    { "action": "accept", "users": ["dev"], "ports": ["dev:*"] },
    { "action": "accept", "users": ["prod"], "ports": ["prod:*"] },
    { "action": "accept", "users": ["internal"], "ports": ["internal:*"] }
  ]
}

Since communications between namespace isn't possible we also have to share the devices between the namespaces.

// add boss host to prod, dev and internal network
headscale nodes share -i 1 -n prod
headscale nodes share -i 1 -n dev
headscale nodes share -i 1 -n internal

// add admin computer to prod, dev and internal network
headscale nodes share -i 2 -n prod
headscale nodes share -i 2 -n dev
headscale nodes share -i 2 -n internal

// add all dev to prod and dev network
headscale nodes share -i 3 -n dev
headscale nodes share -i 4 -n dev
headscale nodes share -i 3 -n prod
headscale nodes share -i 4 -n prod
headscale nodes share -i 5 -n dev
headscale nodes share -i 5 -n prod

headscale nodes share -i 6 -n dev

This fake network have not been tested but it should work. Operating it could be quite tedious if the company grows. Each time a new user join we have to add it to a group, and share it to the correct namespaces. If the user want multiple devices we have to allow communication to each of them one by one. If business conduct a change in the organisations we may have to rewrite all acls and reorganise all namespaces.

If we add servers in production we should also update the ACLs to allow dev access to certain category of them (only app servers for example).

example based on the proposition in this document

Let's create the namespaces

headscale namespaces create boss
headscale namespaces create admin1
headscale namespaces create dev1
headscale namespaces create dev2
headscale namespaces create intern1

We don't need to create namespaces for the servers because the servers will be tagged. When registering the servers we will need to add the flag --advertised-tags=tag:<tag1>,tag:<tag2>, and the user (namespace) that is registering the server should be allowed to do it. Since anyone can add tags to a server they can register, the check of the tags is done on headscale server and only valid tags are applied. A tag is valid if the namespace that is registering it is allowed to do it.

Here are the ACL's to implement the same permissions as above:

{
  // groups are simpler and only list the namespaces name
  "groups": {
    "group:boss": ["boss"],
    "group:dev": ["dev1", "dev2"],
    "group:admin": ["admin1"],
    "group:intern": ["intern1"]
  },
  "tagOwners": {
    // the administrators can add servers in production
    "tag:prod-databases": ["group:admin"],
    "tag:prod-app-servers": ["group:admin"],

    // the boss can tag any server as internal
    "tag:internal": ["group:boss"],

    // dev can add servers for dev purposes as well as admins
    "tag:dev-databases": ["group:admin", "group:dev"],
    "tag:dev-app-servers": ["group:admin", "group:dev"]

    // interns cannot add servers
  },
  "acls": [
    // boss have access to all servers
    {
      "action": "accept",
      "users": ["group:boss"],
      "ports": [
        "tag:prod-databases:*",
        "tag:prod-app-servers:*",
        "tag:internal:*",
        "tag:dev-databases:*",
        "tag:dev-app-servers:*"
      ]
    },

    // admin have only access to administrative ports of the servers
    {
      "action": "accept",
      "users": ["group:admin"],
      "ports": [
        "tag:prod-databases:22",
        "tag:prod-app-servers:22",
        "tag:internal:22",
        "tag:dev-databases:22",
        "tag:dev-app-servers:22"
      ]
    },

    {
      "action": "accept",
      "users": ["group:dev"],
      "ports": [
        "tag:dev-databases:*",
        "tag:dev-app-servers:*",
        "tag:prod-app-servers:80,443"
      ]
    },

    // servers should be able to talk to database. Database should not be able to initiate connections to server
    {
      "action": "accept",
      "users": ["tag:dev-app-servers"],
      "ports": ["tag:dev-databases:5432"]
    },
    {
      "action": "accept",
      "users": ["tag:prod-app-servers"],
      "ports": ["tag:prod-databases:5432"]
    },

    // interns have access to dev-app-servers only in reading mode
    {
      "action": "accept",
      "users": ["group:intern"],
      "ports": ["tag:dev-app-servers:80,443"]
    },

    // we still have to allow internal namespaces communications since nothing guarantees that each user have their own namespaces. This could be talked over.
    { "action": "accept", "users": ["boss"], "ports": ["boss:*"] },
    { "action": "accept", "users": ["dev1"], "ports": ["dev1:*"] },
    { "action": "accept", "users": ["dev2"], "ports": ["dev2:*"] },
    { "action": "accept", "users": ["admin1"], "ports": ["admin1:*"] },
    { "action": "accept", "users": ["intern1"], "ports": ["intern1:*"] }
  ]
}

With this implementation, the sharing step is not necessary. Maintenance cost of the ACL file is lower and less tedious (no need to map hostname and IP's into it).