Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
338 changes: 338 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,338 @@
# Headscale API Wrapper Specification

This document describes the APIs exposed by `headscale-api-wrapper` to other Olares components. Here, "external" means callers outside the wrapper process. These APIs must remain internal to the cluster through Kubernetes Services and NetworkPolicies and must not be exposed directly to the public internet.

## 1. API categories

| Category | Caller | Port | Path prefix | Credential |
| --- | --- | --- | --- | --- |
| Auth key | Vault/LarePass call chain | `9000` | `/headscale` | Olares AccessToken |
| User device management | Settings and user-service | `8000` | `/headscale` | Olares AccessToken |
| Platform policy management | app-service | `8000` | `/internal/policy` | Kubernetes ServiceAccount token |

Recommended in-cluster addresses:

- Auth key: `http://headscale-authkey-svc.os-network:9000`
- User device and platform policy APIs: `http://headscale-server-svc.os-network:8000`

## 2. Common response format

All wrapper APIs use the following response envelope:

```json
{
"code": 0,
"message": "",
"data": {}
}
```

- `code = 0`: success.
- `code = 1001`: request, authentication, or Headscale call failure.
- Callers must check both the HTTP status and `code`.

Common HTTP status codes:

- `200`: success.
- `400`: invalid request body or port format.
- `401`: missing or invalid AccessToken or ServiceAccount token.
- `403`: the node does not belong to the current user, or the ServiceAccount is not authorized.
- `409`: the current policy structure cannot be modified safely.
- `500`: the wrapper failed to read or parse state.
- `502`: a Headscale API call failed.

## 3. Olares user authentication

Auth key and user device management requests must include:

```http
X-Authorization: Bearer <olares-access-token>
```

The wrapper sends the AccessToken to the LLDAP token verification endpoint and uses the returned `username` as the Headscale username.

`X-BFL-USER` is used only for diagnostics. It does not select the Headscale user and cannot override the username in the AccessToken.

## 4. Auth key API

### 4.1 Get a pre-auth key

```http
GET /headscale/preauthkey
```

Port: `9000`

Behavior:

1. Verify the Olares AccessToken.
2. Find the Headscale user with the same name, creating it if it does not exist.
3. Create a reusable, non-ephemeral pre-auth key that is valid for 24 hours.

Request body: none.

On success, `data` retains the response structure from the Headscale create pre-auth key API. For example:

```json
{
"code": 0,
"message": "",
"data": {
"preAuthKey": {
"id": "21",
"key": "hskey-auth-...",
"reusable": true,
"ephemeral": false,
"expiration": "2026-09-25T10:00:00Z",
"user": {
"name": "alice"
}
}
}
}
```

## 5. User device management APIs

These APIs run on port `8000` and may operate only on nodes owned by the Headscale user identified by the AccessToken.

### 5.1 List nodes owned by the current user

```http
POST /headscale/node
Content-Type: application/json
```

Request body:

```json
{}
```

The wrapper fetches all nodes from Headscale and returns only nodes whose `node.user.name` matches the authenticated username.

### 5.2 Delete a node owned by the current user

```http
POST /headscale/node
Content-Type: application/json
```

Request body:

```json
{
"id": "12"
}
```

When `id` is present, this endpoint deletes the node instead of listing nodes. The wrapper verifies ownership first and returns `403` for a node owned by another user.

### 5.3 Rename a node owned by the current user

```http
POST /headscale/node/rename
Content-Type: application/json
```

Request body:

```json
{
"id": "12",
"name": "macbook-pro"
}
```

Both `id` and `name` are required. The wrapper verifies ownership before applying the change.

### 5.4 Approve routes for a node owned by the current user

```http
POST /headscale/node/approve_routes
Content-Type: application/json
```

Request body:

```json
{
"id": "12",
"routes": ["192.168.1.0/24"]
}
```

- `id` is required.
- `routes` is the complete list of approved routes.
- `routes: []` clears all approved routes for the node.
- The wrapper verifies ownership before applying the change.

The user-facing APIs do not support transferring nodes between users or assigning arbitrary tags. This prevents users from bypassing shared-Headscale ACL isolation by changing node ownership or tags.

## 6. Platform policy management APIs

These APIs are intended only for app-service and run on port `8000`.

Requests must include the app-service Kubernetes ServiceAccount token:

```http
Authorization: Bearer <service-account-token>
```

The wrapper verifies the token through Kubernetes TokenReview and requires this identity by default:

```text
system:serviceaccount:os-framework:os-internal
```

The caller must use a projected ServiceAccount token with the dedicated `headscale-policy` audience. The wrapper sends the same audience in the TokenReview request, so the normal Kubernetes API token is rejected.

The expected namespace and ServiceAccount can be overridden with:

- `POLICY_CLIENT_NAMESPACE`
- `POLICY_CLIENT_SERVICE_ACCOUNT`
- `POLICY_TOKEN_AUDIENCE`

### 6.1 Read the application port policy

```http
GET /internal/policy/application-ports
```

Example response:

```json
{
"code": 0,
"message": "",
"data": {
"defaultPorts": {
"tcp": ["53", "80", "443", "18088"],
"udp": ["53"]
},
"applicationPorts": [
{
"user": "alice",
"tcp": ["445", "5000"],
"udp": ["5353"]
}
],
"effectivePorts": [
{
"user": "alice",
"tcp": ["53", "80", "443", "5000", "18088"],
"udp": ["53", "5353"]
}
],
"revision": "sha256:...",
"updatedAt": "2026-09-24T10:00:00Z",
"inSync": true
}
}
```

Field definitions:

- `defaultPorts`: platform ports available to every Headscale member.
- `applicationPorts`: dynamic ports declared by Applications and aggregated by Olares user.
- `effectivePorts`: the union of default and dynamic ports for users with dynamic entries. Users not present in this array still receive `defaultPorts`.
- `revision`: a digest of normalized `applicationPorts`.
- `updatedAt`: the last Headscale policy update time.
- `inSync`: whether the default and dynamic rules in the database match the wrapper's canonical structure.

### 6.2 Replace the application port policy

```http
PUT /internal/policy/application-ports
Content-Type: application/json
```

Request body:

```json
{
"applicationPorts": [
{
"user": "alice",
"tcp": ["445", "5000", "6000-6010"],
"udp": ["5353"]
},
{
"user": "bob",
"tcp": ["8080"],
"udp": []
}
]
}
```

Update semantics:

- This is a full replacement, not an incremental patch.
- Dynamic application ports for users omitted from the request are removed.
- `applicationPorts: []` removes all dynamic application ports while preserving platform default ports.
- Duplicate entries for the same user are merged and deduplicated.
- Platform default ports are removed from dynamic entries even if the request includes them.
- The wrapper ensures that a matching Headscale user exists before writing the policy.
- If the content is unchanged and the existing rules are canonical, the wrapper does not write to Headscale again.

Supported port formats:

- Single port: `"443"`
- Inclusive range: `"6000-6010"`
- Every numeric port must be within `1..65535`.

The success response has the same structure as GET and also includes:

```json
{
"changed": true
}
```

`changed` indicates whether this request actually updated the Headscale policy.

## 7. Platform default ports

The wrapper currently manages these default ports:

```text
TCP: 53, 80, 443, 18088
UDP: 53
```

They correspond to this Headscale policy rule:

```json
{
"action": "accept",
"src": ["autogroup:member"],
"proto": "tcp",
"dst": ["tag:olares:53,80,443,18088"]
}
```

Dynamic application rules are generated per user. For example:

```json
{
"action": "accept",
"src": ["alice@"],
"proto": "tcp",
"dst": ["tag:olares:445,5000"]
}
```

During a read-modify-write operation, the wrapper preserves unrelated ACLs, groups, tag owners, auto-approvers, and policy fields it does not recognize.

## 8. Removed and unsupported APIs

The shared Headscale architecture no longer exposes these legacy capabilities:

- `/inner/*` forwarding endpoints.
- Reading the Headscale control URL.
- Registering nodes through the management API.
- Transferring a node to another Headscale user.
- Assigning arbitrary tags to a node through a user-facing API.

New callers must not depend on these legacy APIs.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ require (
github.com/pkg/errors v0.9.1
github.com/sirupsen/logrus v1.9.3
github.com/spf13/pflag v1.0.5
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a
golang.org/x/crypto v0.9.0
gopkg.in/yaml.v2 v2.4.0
)
Expand Down
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MG
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
Expand Down Expand Up @@ -74,6 +75,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw=
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
Expand Down
8 changes: 8 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os"
"strconv"
"strings"
"sync"
"time"

"github.com/gin-gonic/gin"
Expand Down Expand Up @@ -66,6 +67,8 @@ var config string
var headers map[string]string
var proxyPrefix string = "/headscale"

var policyUpdateMu sync.Mutex

const authenticatedUserContextKey = "authenticated-user"

func init() {
Expand Down Expand Up @@ -176,6 +179,11 @@ func main() {
router := gin.Default()
router.SetTrustedProxies(nil)

internal := router.Group("/internal")
internal.Use(requireServiceAccount())
internal.GET("/policy/application-ports", getApplicationPorts)
internal.PUT("/policy/application-ports", putApplicationPorts)

rgProxy := router.Group(proxyPrefix)
rgProxy.Use(requireAuthenticatedUser())
rgProxy.POST(getMachineStr, func(c *gin.Context) {
Expand Down
Loading
Loading