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
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

## Unreleased

### Added

- **PAT seeding**: Optional Personal Access Token seeding via `pat.*` values.
When `pat.enabled: true`, a service user account and PAT are seeded into
the database using Initium's `seed` command. The seed waits for the server
to create its schema (GORM AutoMigrate), then idempotently inserts account,
user, and PAT records.
- **SQLite**: PAT seed runs as a Kubernetes native sidecar (init container with
`restartPolicy: Always`, K8s 1.28+) in the server Deployment. The sidecar
uses Initium's `--sidecar` flag to stay alive after seeding, maintaining
full pod readiness (`2/2 Running`). This avoids ReadWriteOnce PVC
multi-attach issues that prevent a separate Job from mounting the PVC.
- **PostgreSQL/MySQL**: PAT seed runs as a post-install/post-upgrade Helm hook
Job with a `wait-for` init container for server TCP readiness.
- PAT seed spec uses `wait_for` to wait for `accounts`, `users`, and
`personal_access_tokens` tables before inserting data.
- PAT seed data uses `unique_key` for idempotent inserts (safe on re-installs).
- PAT seed ConfigMap is a regular release resource for SQLite and a Helm hook
for external databases.
- E2E tests extended to verify PAT authentication with `GET /api/groups`
across all three database backends (SQLite, PostgreSQL, MySQL).
- Unit tests for PAT seed Job, ConfigMap, and sidecar templates.
- Upgraded Initium init container image to v1.0.4 (adds `--sidecar` flag for
keeping the process alive after task completion, SHA256/base64 template
filters, PostgreSQL text primary key fix).

### Changed

- **Breaking:** Replaced raw DSN secret (`server.secrets.storeDsn`) with structured
Expand Down
99 changes: 97 additions & 2 deletions charts/netbird/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ For external databases (PostgreSQL, MySQL), the chart automatically:

## Prerequisites

- Kubernetes 1.24+
- Kubernetes 1.24+ (1.28+ required for SQLite PAT seeding with native sidecars)
- Helm 3.x
- An OAuth2 / OIDC identity provider (Auth0, Keycloak, Authentik, Zitadel, etc.)
- An Ingress controller (nginx recommended) with TLS termination
Expand Down Expand Up @@ -160,6 +160,88 @@ dashboard:
- netbird.example.com
```

## Personal Access Token (PAT) Seeding

The chart can optionally seed the database with a Personal Access Token
after deployment. This enables immediate API access without manual token
creation — useful for automation, CI/CD, and GitOps workflows.

### Generating a PAT

NetBird PATs have the format `nbp_<30-char-secret><6-char-checksum>` (40
chars total). The database stores a base64-encoded SHA256 hash.

Generate a token and its hash:

```bash
# Using Python
python3 -c "
import hashlib, base64, secrets, zlib
secret = secrets.token_urlsafe(22)[:30]
checksum = zlib.crc32(secret.encode()) & 0xffffffff
chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
cs = ''
v = checksum
while v > 0: cs = chars[v % 62] + cs; v //= 62
token = 'nbp_' + secret + cs.rjust(6, '0')
hashed = base64.b64encode(hashlib.sha256(token.encode()).digest()).decode()
print(f'Token: {token}')
print(f'Hash: {hashed}')
"

# Or using openssl
TOKEN="nbp_$(openssl rand -base64 32 | tr -dc 'a-zA-Z0-9' | head -c30)000000"
HASH=$(printf '%s' "$TOKEN" | openssl dgst -sha256 -binary | openssl base64 -A)
echo "Token: $TOKEN"
echo "Hash: $HASH"
```

### Creating the Secret

```bash
kubectl create secret generic netbird-pat \
--from-literal=token='nbp_...' \
--from-literal=hashedToken='base64hash...' \
-n netbird
```

### Enabling PAT Seeding

```yaml
pat:
enabled: true
secret:
secretName: netbird-pat
name: "my-api-token"
expirationDays: 365
```

The seeding mechanism depends on the database type:

- **SQLite**: The seed runs as a **native sidecar** (Kubernetes 1.28+) in the
server Deployment. It is declared as an init container with
`restartPolicy: Always` and uses the `--sidecar` flag to stay alive after
seeding. This is required because SQLite uses a local file and
ReadWriteOnce PVCs cannot be mounted by multiple pods simultaneously.
- **PostgreSQL / MySQL**: The seed runs as a post-install/post-upgrade Helm
hook Job that connects to the database over the network.

In both cases, the seed:
1. Waits for the `accounts`, `users`, and `personal_access_tokens` tables
to exist (created by the server via GORM AutoMigrate)
2. Idempotently inserts a service user account and PAT

> **Note:** The SQLite PAT sidecar requires **Kubernetes 1.28+** for native
> sidecar support. The sidecar stays alive after completing the seed
> (via Initium's `--sidecar` flag), so the pod shows `2/2 Running`.

### Using the PAT

```bash
# Authenticate with the PAT
curl -H "Authorization: Token nbp_..." https://netbird.example.com/api/groups
```

## Values Reference

### Global
Expand All @@ -186,6 +268,19 @@ dashboard:
| `database.passwordSecret.secretKey` | string | `"password"` | Key in the Secret |
| `database.sslMode` | string | `"disable"` | SSL mode for PostgreSQL (ignored for mysql/sqlite) |

### PAT (Personal Access Token)

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `pat.enabled` | bool | `false` | Enable PAT seeding via post-install Job |
| `pat.secret.secretName` | string | `""` | Kubernetes Secret containing token and hash |
| `pat.secret.tokenKey` | string | `"token"` | Key in Secret for the plaintext PAT |
| `pat.secret.hashedTokenKey` | string | `"hashedToken"` | Key in Secret for the base64-encoded SHA256 hash |
| `pat.name` | string | `"helm-seeded-token"` | Display name for the PAT |
| `pat.userId` | string | `"helm-seed-user"` | User ID for the service user |
| `pat.accountId` | string | `"helm-seed-account"` | Account ID for the service user |
| `pat.expirationDays` | int | `365` | PAT expiration in days from deployment |

### Server

| Key | Type | Default | Description |
Expand All @@ -195,7 +290,7 @@ dashboard:
| `server.image.tag` | string | `""` (appVersion) | Server image tag |
| `server.image.pullPolicy` | string | `"IfNotPresent"` | Image pull policy |
| `server.initImage.repository` | string | `"ghcr.io/kitstream/initium"` | Init container image ([Initium](https://github.com/KitStream/initium)) |
| `server.initImage.tag` | string | `"1.0.0"` | Init container image tag |
| `server.initImage.tag` | string | `"1.0.4"` | Init container image tag |
| `server.imagePullSecrets` | list | `[]` | Component-level pull secrets |

#### Server Configuration
Expand Down
14 changes: 14 additions & 0 deletions charts/netbird/templates/NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,17 @@ Components:
{{- end }}

Database: {{ .Values.database.type }}
{{- if .Values.pat.enabled }}

PAT Seeding: ENABLED
{{- if eq .Values.database.type "sqlite" }}
A sidecar container in the server pod will seed a Personal Access Token
into the SQLite database. The sidecar shares the data volume with the
server (required for ReadWriteOnce PVCs).
{{- else }}
A post-install Job will seed a Personal Access Token into the database.
{{- end }}
Secret: {{ .Values.pat.secret.secretName }}
The plaintext PAT can be retrieved with:
kubectl get secret {{ .Values.pat.secret.secretName }} -n {{ .Release.Namespace }} -o jsonpath='{.data.{{ .Values.pat.secret.tokenKey }}}' | base64 -d
{{- end }}
82 changes: 82 additions & 0 deletions charts/netbird/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -236,3 +236,85 @@ phases:
database: {{ .Values.database.name }}
create_if_missing: true
{{- end }}
{{/*
netbird.database.patDatabaseUrl — constructs the database URL for PAT seeding.
This URL connects to the target database (not the system database).
For sqlite, it points to the database file.
The spec is a MiniJinja template — {{ env.DB_PASSWORD }} is resolved
by Initium at runtime from the DB_PASSWORD environment variable.
*/}}
{{- define "netbird.database.patDatabaseUrl" -}}
{{- if eq .Values.database.type "sqlite" -}}
/var/lib/netbird/store.db
{{- else if eq .Values.database.type "postgresql" -}}
postgres://{{ .Values.database.user }}:{{ "{{ env.DB_PASSWORD }}" }}@{{ .Values.database.host }}:{{ include "netbird.database.port" . }}/{{ .Values.database.name }}?sslmode={{ .Values.database.sslMode }}
{{- else if eq .Values.database.type "mysql" -}}
mysql://{{ .Values.database.user }}:{{ "{{ env.DB_PASSWORD }}" }}@{{ .Values.database.host }}:{{ include "netbird.database.port" . }}/{{ .Values.database.name }}
{{- end -}}
{{- end }}
{{/*
netbird.pat.seedSpec — renders the Initium seed spec YAML for
inserting a Personal Access Token into the database.
The seed waits for the personal_access_tokens table (created by NetBird
on startup via GORM AutoMigrate), then idempotently inserts the
account, user, and PAT records.
MiniJinja placeholders:
{{ env.PAT_HASHED_TOKEN }} — base64-encoded SHA256 hash of the PAT
*/}}
{{- define "netbird.pat.seedSpec" -}}
database:
driver: {{ include "netbird.database.engine" . }}
url: "{{ include "netbird.database.patDatabaseUrl" . }}"
phases:
- name: seed-pat
order: 1
wait_for:
- type: table
name: personal_access_tokens
timeout: 120s
- type: table
name: users
timeout: 120s
- type: table
name: accounts
timeout: 120s
seed_sets:
- name: pat-account
order: 1
tables:
- table: accounts
unique_key: [id]
rows:
- id: {{ .Values.pat.accountId | quote }}
created_by: "helm-seed"
domain: "netbird.selfhosted"
domain_category: "private"
is_domain_primary_account: 1
- name: pat-user
order: 2
tables:
- table: users
unique_key: [id]
rows:
- id: {{ .Values.pat.userId | quote }}
account_id: {{ .Values.pat.accountId | quote }}
role: "admin"
is_service_user: 1
service_user_name: "helm-seed-service-user"
non_deletable: 0
blocked: 0
issued: "api"
- name: pat-token
order: 3
tables:
- table: personal_access_tokens
unique_key: [id]
rows:
- id: "helm-seeded-pat"
user_id: {{ .Values.pat.userId | quote }}
name: {{ .Values.pat.name | quote }}
hashed_token: "{{ "{{ env.PAT_HASHED_TOKEN }}" }}"
expiration_date: {{ now | dateModify (printf "+%dh" (mul .Values.pat.expirationDays 24)) | date "2006-01-02 15:04:05" | quote }}
created_by: {{ .Values.pat.userId | quote }}
created_at: {{ now | date "2006-01-02 15:04:05" | quote }}
{{- end }}
30 changes: 30 additions & 0 deletions charts/netbird/templates/pat-seed-configmap.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{{/*
ConfigMap for the PAT seed spec. Only rendered when pat.enabled is true.
Contains the Initium seed spec that inserts account, user, and PAT records.

For SQLite this is a regular release resource (consumed by the sidecar in the
Deployment). For external databases it remains a Helm hook so the seed Job
can reference it during the post-install/post-upgrade phase.
*/}}
{{- if .Values.pat.enabled }}
{{- $isExternal := eq (include "netbird.database.isExternal" .) "true" }}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "netbird.server.fullname" . }}-pat-seed
namespace: {{ .Release.Namespace }}
labels:
{{- include "netbird.labels" . | nindent 4 }}
app.kubernetes.io/name: {{ include "netbird.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: pat-seed
{{- if $isExternal }}
annotations:
helm.sh/hook: post-install,post-upgrade
helm.sh/hook-weight: "0"
helm.sh/hook-delete-policy: before-hook-creation
{{- end }}
data:
pat-seed.yaml: |
{{- include "netbird.pat.seedSpec" . | nindent 4 }}
{{- end }}
101 changes: 101 additions & 0 deletions charts/netbird/templates/pat-seed-job.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
{{/*
Post-install/post-upgrade Job - seeds a Personal Access Token into the
NetBird database via Initium seed command.

Only rendered for external databases (PostgreSQL/MySQL) where the seed
connects over the network. For SQLite the seed runs as a sidecar container
in the server Deployment to share the same PVC (ReadWriteOnce PVCs do not
support multi-pod attach).
*/}}
{{- if .Values.pat.enabled }}
{{- $isExternal := eq (include "netbird.database.isExternal" .) "true" }}
{{- if $isExternal }}
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "netbird.server.fullname" . }}-pat-seed
namespace: {{ .Release.Namespace }}
labels:
{{- include "netbird.labels" . | nindent 4 }}
app.kubernetes.io/name: {{ include "netbird.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: pat-seed
annotations:
helm.sh/hook: post-install,post-upgrade
helm.sh/hook-weight: "5"
helm.sh/hook-delete-policy: before-hook-creation
spec:
backoffLimit: 3
ttlSecondsAfterFinished: 300
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "netbird.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: pat-seed
spec:
restartPolicy: OnFailure
serviceAccountName: {{ include "netbird.serviceAccountName" . }}
{{- with (coalesce .Values.server.imagePullSecrets .Values.imagePullSecrets) }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
initContainers:
- name: wait-server
image: "{{ .Values.server.initImage.repository }}:{{ .Values.server.initImage.tag }}"
args:
- wait-for
- --target
- "tcp://{{ include "netbird.server.fullname" . }}:{{ .Values.server.service.port }}"
- --timeout
- "180s"
securityContext:
runAsNonRoot: true
runAsUser: 65534
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
containers:
- name: pat-seed
image: "{{ .Values.server.initImage.repository }}:{{ .Values.server.initImage.tag }}"
args:
- seed
- --spec
- /spec/pat-seed.yaml
env:
- name: PAT_HASHED_TOKEN
valueFrom:
secretKeyRef:
name: {{ .Values.pat.secret.secretName }}
key: {{ .Values.pat.secret.hashedTokenKey }}
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.database.passwordSecret.secretName }}
key: {{ .Values.database.passwordSecret.secretKey }}
securityContext:
runAsNonRoot: true
runAsUser: 65534
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
volumeMounts:
- name: config
mountPath: /spec
readOnly: true
volumes:
- name: config
configMap:
name: {{ include "netbird.server.fullname" . }}-pat-seed
{{- with .Values.server.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.server.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
{{- end }}
Loading