diff --git a/helm/Chart.yaml b/helm/Chart.yaml new file mode 100644 index 00000000..88ab572f --- /dev/null +++ b/helm/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: headplane +description: "The headplane Helm chart provides an easy way to deploy a Headscale UI with headplane, including an embedded Tailscale relay. This chart simplifies the setup of a private networking solution using Kubernetes." +type: application +version: 0.4.4 +appVersion: "0.6.0" diff --git a/helm/README.md b/helm/README.md new file mode 100644 index 00000000..28e5d341 --- /dev/null +++ b/helm/README.md @@ -0,0 +1,180 @@ +# headplane + +![Version: 0.1.0](https://img.shields.io/badge/Version-0.1.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 0.6.0](https://img.shields.io/badge/AppVersion-0.6.0-informational?style=flat-square) + +This helm chart deploys [Headplane](https://github.com/tale/headplane) + [Headscale](https://github.com/juanfont/headscale) and an embedded Tailscale relay in a kubernetes cluster. + +## Installation + +### Prerequisites +- Kubernetes cluster +- Helm installed + +### Install the Chart +```sh +# Install with default values +helm install headplane oci://harbor.lag0.com.br/library/headplane + +# Install with custom values +helm install headplane oci://harbor.lag0.com.br/library/headplane -f values.yaml + +``` + +### Upgrade the Chart +```sh +helm upgrade headplane oci://harbor.lag0.com.br/library/headplane +``` + +* Some config changes may require manual pod restart to take place + +### Uninstall the Chart +```sh +helm uninstall headplane +``` + +## Values + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| headplane.config.headscale.config_path | string | `"/etc/headscale/config.yaml"` | | +| headplane.config.headscale.config_strict | string | `"true"` | | +| headplane.config.headscale.url | string | `"https://vpn.example.com"` | | +| headplane.config.integration.kubernetes.enabled | bool | `true` | | +| headplane.config.integration.kubernetes.pod_name | string | `"headplane-0"` | | +| headplane.config.integration.kubernetes.validate_manifest | bool | `true` | | +| headplane.config.server.cookie_secure | bool | `true` | | +| headplane.config.server.host | string | `"0.0.0.0"` | | +| headplane.config.server.port | int | `3000` | | +| headplane.envFrom | list | `[]` | | +| headplane.image | string | `"ghcr.io/tale/headplane:0.6.0"` | | +| headplane.oidc.client_id | string | `"REPLACE_IT_WITH_YOUR_OIDC_CLIENT_ID_FOR_HEADPLANE"` | | +| headplane.oidc.disable_api_key_login | bool | `true` | | +| headplane.oidc.enabled | bool | `false` | | +| headplane.oidc.issuer | string | `"https://your-oidc-issuer-url.com"` | | +| headplane.oidc.redirect_uri | string | `"https://your-headplane-admin-domain.com/admin/oidc/callback"` | | +| headplane.oidc.secret_name | string | `"oidc-secrets"` | | +| headplane.oidc.token_endpoint_auth_method | string | `"client_secret_post"` | | +| headscale.acl | string | `"{\n \"acls\": []\n}\n"` | | +| headscale.config.database.debug | bool | `false` | | +| headscale.config.database.sqlite.path | string | `"/etc/headscale/db.sqlite"` | | +| headscale.config.database.type | string | `"sqlite"` | | +| headscale.config.derp.paths | list | `[]` | | +| headscale.config.derp.server.automatically_add_embedded_derp_region | bool | `true` | | +| headscale.config.derp.server.enabled | bool | `false` | | +| headscale.config.derp.server.ipv4 | string | `"1.2.3.4"` | | +| headscale.config.derp.server.ipv6 | string | `"2001:db8::1"` | | +| headscale.config.derp.server.private_key_path | string | `"/var/lib/headscale/derp_server_private.key"` | | +| headscale.config.derp.server.region_code | string | `"headscale"` | | +| headscale.config.derp.server.region_id | int | `999` | | +| headscale.config.derp.server.region_name | string | `"Headscale Embedded DERP"` | | +| headscale.config.derp.server.stun_listen_addr | string | `"0.0.0.0:3478"` | | +| headscale.config.derp.urls[0] | string | `"https://controlplane.tailscale.com/derpmap/default"` | | +| headscale.config.dns.base_domain | string | `"headscale.vpn"` | | +| headscale.config.dns.magic_dns | bool | `true` | | +| headscale.config.dns.nameservers.global[0] | string | `"1.1.1.1"` | | +| headscale.config.dns.nameservers.global[1] | string | `"8.8.8.8"` | | +| headscale.config.grpc_allow_insecure | bool | `false` | | +| headscale.config.grpc_listen_addr | string | `"0.0.0.0:50443"` | | +| headscale.config.listen_addr | string | `"0.0.0.0:8080"` | | +| headscale.config.metrics_listen_addr | string | `"0.0.0.0:9090"` | | +| headscale.config.noise.private_key_path | string | `"/etc/headscale/noise_private.key"` | | +| headscale.config.policy.mode | string | `"database"` | | +| headscale.config.policy.path | string | `"/etc/headscale/acl.hujson"` | | +| headscale.config.prefixes.allocation | string | `"sequential"` | | +| headscale.config.prefixes.v4 | string | `"100.64.0.0/10"` | | +| headscale.config.prefixes.v6 | string | `"fd7a:115c:a1e0::/48"` | | +| headscale.config.server_url | string | `"https://vpn.example.com"` | | +| headscale.envFrom | list | `[]` | | +| headscale.image | string | `"headscale/headscale:0.26.1"` | | +| headscale.oidc.client_id | string | `"YOUR_OIDC_CLIENT_ID_FOR_HEADSCALE"` | | +| headscale.oidc.enabled | bool | `false` | | +| headscale.oidc.issuer | string | `"https://your-oidc-issuer.com"` | | +| headscale.oidc.pkce.enabled | bool | `false` | | +| headscale.oidc.pkce.method | string | `"S256"` | | +| headscale.oidc.secret_name | string | `"oidc-secrets"` | | +| ingress.annotations | list | `[]` | | +| ingress.className | string | `"nginx"` | | +| ingress.enabled | bool | `false` | | +| ingress.headplaneDomain | string | `"headplane.example.com"` | | +| ingress.headscaleDomain | string | `"vpn.example.com"` | | +| ingress.labels | list | `[]` | | +| ingress.tlsSecretName | string | `"headplane-tls"` | | +| pvc.accessModes[0] | string | `"ReadWriteOnce"` | | +| pvc.annotations | object | `{}` | | +| pvc.enabled | bool | `true` | | +| pvc.labels | list | `[]` | | +| pvc.name | string | `"headscale-config"` | | +| pvc.storage | string | `"1Gi"` | | +| relay.config.advertise_exit_node | string | `"true"` | | +| relay.config.authKey | string | `""` | | +| relay.config.exit_node | string | `"example.com"` | | +| relay.config.firewall_debug | string | `"false"` | | +| relay.config.hostname | string | `"example.com"` | | +| relay.config.login_server | string | `"https://vpn.example.com"` | | +| relay.config.routes | string | `"10.0.0.0/8"` | | +| relay.enabled | bool | `false` | | +| relay.image | string | `"ghcr.io/tailscale/tailscale:v1.80.3"` | | +| relay.pvc.accessModes[0] | string | `"ReadWriteOnce"` | | +| relay.pvc.enabled | bool | `false` | | +| relay.pvc.name | string | `"tailscale-relay-data"` | | +| relay.pvc.storage | string | `"1Gi"` | | + +### OIDC Configuration + +To use OIDC, you must provide the OIDC client secrets via Kubernetes secret: + +```sh +kubectl create secret generic oidc-secrets \ + --from-literal=HEADPLANE_OIDC__CLIENT_SECRET=your-headplane-oidc-client-secret \ + --from-literal=HEADPLANE_OIDC__CLIENT_ID=your-headplane-oidc-client-id \ + --from-literal=HEADSCALE_OIDC__CLIENT_SECRET=your-headscale-oidc-client-secret \ + --from-literal=HEADSCALE_OIDC__CLIENT_ID=your-headscale-oidc-client-id \ + -n +``` + +Then enable OIDC in your `values.yaml`: +```yaml +headplane: + oidc: + enabled: true + issuer: "https://your-oidc-issuer-url.com" + redirect_uri: "https://your-headplane-admin-domain.com/admin/oidc/callback" + secret_name: "oidc-secrets" # Name of your OIDC secret + +headscale: + oidc: + enabled: true + issuer: "https://your-oidc-issuer.com" + secret_name: "oidc-secrets" # Same secret as Headplane +``` + +You can add any additional environment variables by creating more secrets or config-maps and adding them to the `envFrom` section. For example, to add custom configuration: + +```sh +kubectl create secret generic headplane-custom-config \ + --from-literal=CUSTOM_VAR=value \ + --from-literal=ANOTHER_VAR=another-value \ + -n +``` + +Then add it to your values: +```yaml +headplane: + envFrom: + - secretRef: + name: headplane-custom-config +``` + +Note: Make sure to keep your secrets secure and never commit them to version control. +Consider using a secrets management solution in production like external-secrets. + +## License +Copyright © 2025 antoniolago + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at: + +``` +http://www.apache.org/licenses/LICENSE-2.0 +``` + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. \ No newline at end of file diff --git a/helm/README.md.gotmpl b/helm/README.md.gotmpl new file mode 100644 index 00000000..95b1580a --- /dev/null +++ b/helm/README.md.gotmpl @@ -0,0 +1,101 @@ +# headplane + +![Version: 0.1.0](https://img.shields.io/badge/Version-0.1.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 0.6.0](https://img.shields.io/badge/AppVersion-0.6.0-informational?style=flat-square) + +This helm chart deploys [Headplane](https://github.com/tale/headplane) + [Headscale](https://github.com/juanfont/headscale) and an embedded Tailscale relay in a kubernetes cluster. + +## Installation + +### Prerequisites +- Kubernetes cluster +- Helm installed + +### Install the Chart +```sh +# Install with default values +helm install headplane oci://harbor.lag0.com.br/library/headplane + +# Install with custom values +helm install headplane oci://harbor.lag0.com.br/library/headplane -f values.yaml + +``` + +### Upgrade the Chart +```sh +helm upgrade headplane oci://harbor.lag0.com.br/library/headplane +``` + +* Some config changes may require manual pod restart to take place + +### Uninstall the Chart +```sh +helm uninstall headplane +``` + +## Values + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +{{- range .Values }} +| {{ .Key }} | {{ .Type }} | {{ .Default }} | {{ .Description }} | +{{- end }} + +### OIDC Configuration + +To use OIDC, you must provide the OIDC client secrets via Kubernetes secret: + +```sh +kubectl create secret generic oidc-secrets \ + --from-literal=HEADPLANE_OIDC__CLIENT_SECRET=your-headplane-oidc-client-secret \ + --from-literal=HEADPLANE_OIDC__CLIENT_ID=your-headplane-oidc-client-id \ + --from-literal=HEADSCALE_OIDC__CLIENT_SECRET=your-headscale-oidc-client-secret \ + --from-literal=HEADSCALE_OIDC__CLIENT_ID=your-headscale-oidc-client-id \ + -n +``` + +Then enable OIDC in your `values.yaml`: +```yaml +headplane: + oidc: + enabled: true + issuer: "https://your-oidc-issuer-url.com" + redirect_uri: "https://your-headplane-admin-domain.com/admin/oidc/callback" + secret_name: "oidc-secrets" # Name of your OIDC secret + +headscale: + oidc: + enabled: true + issuer: "https://your-oidc-issuer.com" + secret_name: "oidc-secrets" # Same secret as Headplane +``` + +You can add any additional environment variables by creating more secrets or config-maps and adding them to the `envFrom` section. For example, to add custom configuration: + +```sh +kubectl create secret generic headplane-custom-config \ + --from-literal=CUSTOM_VAR=value \ + --from-literal=ANOTHER_VAR=another-value \ + -n +``` + +Then add it to your values: +```yaml +headplane: + envFrom: + - secretRef: + name: headplane-custom-config +``` + +Note: Make sure to keep your secrets secure and never commit them to version control. +Consider using a secrets management solution in production like external-secrets. + +## License +Copyright © 2025 antoniolago + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at: + +``` +http://www.apache.org/licenses/LICENSE-2.0 +``` + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. \ No newline at end of file diff --git a/helm/templates/_helpers.tpl b/helm/templates/_helpers.tpl new file mode 100644 index 00000000..103ecf75 --- /dev/null +++ b/helm/templates/_helpers.tpl @@ -0,0 +1,10 @@ +{{/* +Generate a random cookie secret if none is provided +*/}} +{{- define "headplane.cookieSecret" -}} +{{- if and .Values.headplane.secret .Values.headplane.secret.server (hasKey .Values.headplane.secret.server "cookie_secret") .Values.headplane.secret.server.cookie_secret -}} +{{- .Values.headplane.secret.server.cookie_secret -}} +{{- else -}} +{{- randAlphaNum 32 -}} +{{- end -}} +{{- end -}} \ No newline at end of file diff --git a/helm/templates/configmap-headscale-acl.yaml b/helm/templates/configmap-headscale-acl.yaml new file mode 100644 index 00000000..27b3debd --- /dev/null +++ b/helm/templates/configmap-headscale-acl.yaml @@ -0,0 +1,11 @@ +{{- if .Values.headscale.acl }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: headscale-acl + labels: + app: headplane +data: + acl.hujson: |- + {{- .Values.headscale.acl | nindent 4 }} +{{- end }} \ No newline at end of file diff --git a/helm/templates/ingress.yaml b/helm/templates/ingress.yaml new file mode 100644 index 00000000..9bf8a93c --- /dev/null +++ b/helm/templates/ingress.yaml @@ -0,0 +1,46 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: headplane + {{- if .Values.ingress.annotations }} + annotations: + {{- range $key, $value := .Values.ingress.annotations }} + {{ $key }}: {{ $value | quote }} + {{- end }} + {{- end }} + {{- if .Values.ingress.labels }} + labels: + {{- range $key, $value := .Values.ingress.labels }} + {{ $key }}: {{ $value | quote }} + {{- end }} + {{- end }} +spec: + ingressClassName: {{ .Values.ingress.className }} + rules: + - host: {{ .Values.ingress.headscaleDomain }} + http: + paths: + - backend: + service: + name: headplane + port: + number: 8080 + path: / + pathType: Prefix + - host: {{ .Values.ingress.headplaneDomain }} + http: + paths: + - backend: + service: + name: headplane + port: + number: 3000 + path: /admin + pathType: Prefix + tls: + - hosts: + - {{ .Values.ingress.headplaneDomain }} + - {{ .Values.ingress.headscaleDomain }} + secretName: {{ .Values.ingress.tlsSecretName }} +{{- end }} diff --git a/helm/templates/job.yaml b/helm/templates/job.yaml new file mode 100644 index 00000000..829b82a8 --- /dev/null +++ b/helm/templates/job.yaml @@ -0,0 +1,57 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: headscale-generate-token + namespace: {{ .Release.Namespace }} + annotations: + "helm.sh/hook": pre-install,pre-upgrade + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded +spec: + template: + spec: + restartPolicy: Never + serviceAccountName: headscale-token + containers: + - name: headscale-generate-token + image: bitnami/kubectl:latest + command: + - /bin/sh + - -c + - | + set -e + + echo "Waiting for headscale container to be Ready..." + while [ -z "$CONTAINER_STATUS" ]; do + CONTAINER_STATUS=$(kubectl get pod headplane-0 -n {{ .Release.Namespace }} -o=jsonpath="{.status.containerStatuses[?(@.name==\"headscale\")].state.running}") + sleep 1 + done + + echo "Checking if API token needs to be generated..." + if kubectl get secret headscale-api-token -n {{ .Release.Namespace }} >/dev/null 2>&1; then + CURRENT_TOKEN=$(kubectl get secret headscale-api-token -n {{ .Release.Namespace }} -o jsonpath='{.data.HEADPLANE_OIDC__HEADSCALE_API_KEY}' | base64 -d) + echo "Current token value: '$CURRENT_TOKEN'" + echo "Current token length: ${#CURRENT_TOKEN}" + echo "Expected placeholder: 'placeholder-token-will-be-replaced-by-job'" + echo "Expected length: 42" + if [[ "$CURRENT_TOKEN" != "placeholder-token-will-be-replaced-by-job" ]]; then + echo "Real API token already exists. Skipping token generation." + exit 0 + else + echo "Placeholder token found. Generating real API token..." + fi + else + echo "Secret not found. Generating API token..." + fi + + echo "Generating Headscale API token..." + TOKEN=$(kubectl -n {{ .Release.Namespace }} exec -i headplane-0 -c headscale -- headscale apikeys create -e 100y) + + if [ -z "$TOKEN" ]; then + echo "Failed to retrieve API token" + exit 1 + fi + + echo "Updating headscale-api-token secret with generated API token..." + kubectl patch secret headscale-api-token -n {{ .Release.Namespace }} -p="{\"data\":{\"HEADPLANE_OIDC__HEADSCALE_API_KEY\":\"$(echo -n "$TOKEN" | base64)\"}}" + + echo "Successfully updated headscale-api-token secret with real API token" diff --git a/helm/templates/rolebindings.yaml b/helm/templates/rolebindings.yaml new file mode 100644 index 00000000..0cfc043b --- /dev/null +++ b/helm/templates/rolebindings.yaml @@ -0,0 +1,39 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: headplane-agent + namespace: {{ .Release.Namespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: headplane-agent +subjects: +- kind: ServiceAccount + name: headplane +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: tailscale-relay + namespace: {{ .Release.Namespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: tailscale-relay +subjects: +- kind: ServiceAccount + name: tailscale-relay +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: headscale-token + namespace: {{ .Release.Namespace }} +subjects: + - kind: ServiceAccount + name: headscale-token +roleRef: + kind: Role + name: headscale-token + apiGroup: rbac.authorization.k8s.io diff --git a/helm/templates/roles.yaml b/helm/templates/roles.yaml new file mode 100644 index 00000000..5c0e288a --- /dev/null +++ b/helm/templates/roles.yaml @@ -0,0 +1,63 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: headplane-agent + namespace: {{ .Release.Namespace }} +rules: +- apiGroups: [''] + resources: ['pods'] + verbs: ['get', 'list'] +- apiGroups: ['apps'] + resources: ['deployments'] + verbs: ['get', 'list'] +- apiGroups: ['batch'] + resources: ['jobs'] + verbs: ['get', 'list', 'watch'] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: tailscale-relay + namespace: {{ .Release.Namespace }} +rules: +- apiGroups: + - "" + resources: + - secrets + verbs: + - create +- apiGroups: + - "" + resourceNames: + - tailscale-auth + resources: + - secrets + verbs: + - get + - update + - patch +- apiGroups: + - "" + resources: + - events + verbs: + - get + - create + - patch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: headscale-token + namespace: {{ .Release.Namespace }} +rules: + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch"] + - apiGroups: [""] + resources: ["pods/exec"] + verbs: ["create"] + - apiGroups: [""] + resources: ["secrets"] + verbs: ["create", "get", "update", "patch"] diff --git a/helm/templates/secret-headplane.yaml b/helm/templates/secret-headplane.yaml new file mode 100644 index 00000000..3e628d44 --- /dev/null +++ b/helm/templates/secret-headplane.yaml @@ -0,0 +1,24 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: headplane-config +type: Opaque +stringData: + config.yaml: | + server: +{{- toYaml .Values.headplane.config.server | nindent 6 }} + cookie_secret: {{ include "headplane.cookieSecret" . | quote }} + headscale: +{{- toYaml .Values.headplane.config.headscale | nindent 6 }} + integration: +{{- toYaml .Values.headplane.config.integration | nindent 6 }} +{{- if .Values.headplane.oidc.enabled }} + oidc: + issuer: {{ .Values.headplane.oidc.issuer | quote }} + disable_api_key_login: {{ .Values.headplane.oidc.disable_api_key_login | quote }} + token_endpoint_auth_method: {{ .Values.headplane.oidc.token_endpoint_auth_method | quote }} + redirect_uri: {{ .Values.headplane.oidc.redirect_uri | quote }} + client_id: {{ .Values.headplane.oidc.client_id | quote }} + headscale_api_key: {{ .Values.headplane.oidc.headscale_api_key | default "placeholder-token-will-be-replaced-by-job" | quote }} +{{- end }} diff --git a/helm/templates/secret-headscale-api-token.yaml b/helm/templates/secret-headscale-api-token.yaml new file mode 100644 index 00000000..3cf9a2d8 --- /dev/null +++ b/helm/templates/secret-headscale-api-token.yaml @@ -0,0 +1,9 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: headscale-api-token + namespace: {{ .Release.Namespace }} +type: Opaque +stringData: + HEADPLANE_OIDC__HEADSCALE_API_KEY: "placeholder-token-will-be-replaced-by-job" \ No newline at end of file diff --git a/helm/templates/secret-headscale.yaml b/helm/templates/secret-headscale.yaml new file mode 100644 index 00000000..c45b4b12 --- /dev/null +++ b/helm/templates/secret-headscale.yaml @@ -0,0 +1,31 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: headscale-config +type: Opaque +stringData: + config.yaml: | +{{- toYaml .Values.headscale.config | nindent 4 }} + {{- if .Values.headscale.oidc.enabled }} + oidc: + issuer: {{ .Values.headscale.oidc.issuer | quote }} + client_id: {{ .Values.headscale.oidc.client_id | quote }} + {{- if .Values.headscale.oidc.allowed_groups }} + allowed_groups: + {{- toYaml .Values.headscale.oidc.allowed_groups | nindent 8 }} + {{- end }} + {{- if .Values.headscale.oidc.allowed_domains }} + allowed_domains: + {{- toYaml .Values.headscale.oidc.allowed_domains | nindent 8 }} + {{- end }} + {{- if .Values.headscale.oidc.allowed_users }} + allowed_users: + {{- toYaml .Values.headscale.oidc.allowed_users | nindent 8 }} + {{- end }} + {{- if .Values.headscale.oidc.pkce.enabled }} + pkce: + enabled: {{ .Values.headscale.oidc.pkce.enabled | quote }} + method: {{ .Values.headscale.oidc.pkce.method | quote }} + {{- end }} +{{- end }} diff --git a/helm/templates/service.yaml b/helm/templates/service.yaml new file mode 100644 index 00000000..67e88d37 --- /dev/null +++ b/helm/templates/service.yaml @@ -0,0 +1,21 @@ +--- +apiVersion: v1 +kind: Service +metadata: + labels: + app: headplane + name: headplane + namespace: {{ .Release.Namespace }} +spec: + ports: + - name: headscale + port: 8080 + protocol: TCP + targetPort: 8080 + - name: headplane + port: 3000 + protocol: TCP + targetPort: 3000 + selector: + app: headplane + type: ClusterIP diff --git a/helm/templates/serviceaccounts.yaml b/helm/templates/serviceaccounts.yaml new file mode 100644 index 00000000..5c4f1e4d --- /dev/null +++ b/helm/templates/serviceaccounts.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: headplane + namespace: {{ .Release.Namespace }} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: tailscale-relay + namespace: {{ .Release.Namespace }} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: headscale-token + namespace: {{ .Release.Namespace }} diff --git a/helm/templates/statefulset-headplane.yaml b/helm/templates/statefulset-headplane.yaml new file mode 100644 index 00000000..381696d8 --- /dev/null +++ b/helm/templates/statefulset-headplane.yaml @@ -0,0 +1,122 @@ +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: headplane + labels: + app: headplane +spec: + selector: + matchLabels: + app: headplane + template: + metadata: + labels: + app: headplane + spec: + shareProcessNamespace: true + serviceAccountName: headplane + initContainers: + - name: copy-headscale-config + image: busybox:latest + command: + - sh + - -c + - | + cp /headscale-config/config.yaml /headscale-data/config.yaml + cp /headscale-acl/acl.hujson /headscale-data/acl.hujson + volumeMounts: + - name: headscale-data + mountPath: /headscale-data + - name: headscale-config + mountPath: /headscale-config + - name: headscale-acl + mountPath: /headscale-acl + containers: + - name: headplane + image: {{ .Values.headplane.image }} + command: + - /bin/sh + - -c + - | + echo "Waiting for headscale-generate-token job to complete..." + kubectl wait --for=condition=complete job/headscale-generate-token -n {{ .Release.Namespace }} --timeout=300s + echo "Job completed successfully, starting headplane..." + exec node /app/build/server/index.js + envFrom: + - secretRef: + name: headscale-api-token + {{- if .Values.headplane.oidc.enabled }} + - secretRef: + name: {{ .Values.headplane.oidc.secret_name }} + {{- end }} + {{- with .Values.headplane.envFrom }} + {{- toYaml . | nindent 10 }} + {{- end }} + env: + - name: HEADPLANE_LOAD_ENV_OVERRIDES + value: 'true' + - name: 'HEADPLANE_INTEGRATION__KUBERNETES__POD_NAME' + valueFrom: + fieldRef: + fieldPath: metadata.name + volumeMounts: + - name: headplane-config + mountPath: /etc/headplane + - name: headscale-data + mountPath: /etc/headscale + - name: headscale + image: {{ .Values.headscale.image }} + args: + - serve + volumeMounts: + - name: headscale-data + mountPath: /etc/headscale + - name: headplane-config + mountPath: /etc/headplane + envFrom: + {{- if .Values.headscale.oidc.enabled }} + - secretRef: + name: {{ .Values.headscale.oidc.secret_name }} + {{- end }} + {{- with .Values.headscale.envFrom }} + {{- toYaml . | nindent 10 }} + {{- end }} + volumes: + - name: headscale-config + secret: + secretName: headscale-config + - name: headplane-config + secret: + secretName: headplane-config + - name: headscale-acl + configMap: + name: headscale-acl + {{- if .Values.pvc.enabled }} + volumeClaimTemplates: + - metadata: + name: headscale-data + {{- if .Values.pvc.annotations }} + annotations: + {{- range $key, $value := .Values.pvc.annotations }} + {{ $key | quote }}: {{ $value | quote }} + {{- end }} + {{- end }} + {{- if .Values.pvc.labels }} + labels: + {{- range $key, $value := .Values.pvc.labels }} + {{ $key | quote }}: {{ $value | quote }} + {{- end }} + {{- end }} + spec: + accessModes: + {{- range .Values.pvc.accessModes }} + - {{ . | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.pvc.storage | quote }} + {{- if .Values.pvc.storageClassName }} + storageClassName: {{ .Values.pvc.storageClassName | quote }} + {{- end }} + {{- end }} diff --git a/helm/templates/statefulset-tailscale-relay.yaml b/helm/templates/statefulset-tailscale-relay.yaml new file mode 100644 index 00000000..02e6d245 --- /dev/null +++ b/helm/templates/statefulset-tailscale-relay.yaml @@ -0,0 +1,100 @@ +{{- if .Values.relay.enabled -}} +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: tailscale-relay + labels: + app: tailscale +spec: + replicas: 1 + selector: + matchLabels: + app: tailscale + template: + metadata: + labels: + app: tailscale + spec: + serviceAccountName: "tailscale-relay" + containers: + - name: tailscale + image: {{ .Values.relay.image }} + imagePullPolicy: Always + env: + {{- $extraArgs := list }} + {{- if .Values.relay.config.login_server }} + {{- $extraArgs = append $extraArgs (printf "--login-server=%s" .Values.relay.config.login_server) }} + {{- end }} + {{- if .Values.relay.config.advertise_exit_node }} + {{- $extraArgs = append $extraArgs "--advertise-exit-node" }} + {{- end }} + {{- if gt (len $extraArgs) 0 }} + - name: TS_EXTRA_ARGS + value: "{{ join " " $extraArgs }}" + {{- end }} + {{- if .Values.relay.config.exit_node }} + - name: TS_EXIT_NODE + value: "{{ .Values.relay.config.exit_node }}" + {{- end }} + {{- if .Values.relay.config.hostname }} + - name: TS_HOSTNAME + value: "{{ .Values.relay.config.hostname }}" + {{- end }} + {{- if .Values.relay.config.routes }} + - name: TS_ROUTES + value: "{{ .Values.relay.config.routes }}" + {{- end }} + {{- if .Values.relay.config.firewall_debug }} + - name: TS_DEBUG_FIREWALL_MODE + value: "{{ .Values.relay.config.firewall_debug }}" + {{- end }} + - name: TS_KUBE_SECRET + value: "tailscale-auth" + - name: TS_USERSPACE + value: "true" + {{- if .Values.relay.config.authKey }} + - name: TS_AUTHKEY + value: "{{ .Values.relay.config.authKey }}" + {{- else }} + - name: TS_AUTHKEY + valueFrom: + secretKeyRef: + name: tailscale-auth + key: TS_AUTHKEY + optional: true + {{- end }} + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_UID + valueFrom: + fieldRef: + fieldPath: metadata.uid + {{- if .Values.relay.pvc.enabled }} + volumeMounts: + - name: tailscale-data + mountPath: /var/lib/tailscale + {{- end }} + securityContext: + capabilities: + add: + - NET_ADMIN + {{- if .Values.relay.pvc.enabled }} + volumeClaimTemplates: + - metadata: + name: tailscale-data + spec: + accessModes: + {{- range .Values.relay.pvc.accessModes }} + - {{ . | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.relay.pvc.storage | quote }} + {{- if .Values.relay.pvc.storageClassName }} + storageClassName: {{ .Values.relay.pvc.storageClassName | quote }} + {{- end }} + {{- end }} +{{- end }} diff --git a/helm/test/templates/mock-oidc-server.yaml b/helm/test/templates/mock-oidc-server.yaml new file mode 100644 index 00000000..f4d4ec15 --- /dev/null +++ b/helm/test/templates/mock-oidc-server.yaml @@ -0,0 +1,110 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: mock-oidc-server +spec: + selector: + app: mock-oidc-server + ports: + - protocol: TCP + port: 80 + targetPort: 80 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: mock-oidc-config +data: + openid-configuration: | + { + "issuer": "http://mock-oidc-server", + "authorization_endpoint": "http://mock-oidc-server/oauth/authorize", + "token_endpoint": "http://mock-oidc-server/oauth/token", + "userinfo_endpoint": "http://mock-oidc-server/userinfo", + "jwks_uri": "http://mock-oidc-server/.well-known/jwks.json", + "response_types_supported": ["code", "token", "id_token"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"], + "scopes_supported": ["openid", "profile", "email"], + "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], + "claims_supported": ["sub", "iss", "name", "email"] + } + jwks.json: | + { + "keys": [ + { + "kty": "RSA", + "kid": "test-key", + "use": "sig", + "alg": "RS256", + "n": "test-modulus", + "e": "AQAB" + } + ] + } + userinfo: | + { + "sub": "test-user-id", + "name": "Test User", + "email": "test@example.com" + } + nginx.conf: | + events { + worker_connections 1024; + } + http { + server { + listen 80; + + location /.well-known/openid-configuration { + add_header Content-Type application/json; + add_header Access-Control-Allow-Origin *; + return 200 '{"issuer":"http://mock-oidc-server","authorization_endpoint":"http://mock-oidc-server/oauth/authorize","token_endpoint":"http://mock-oidc-server/oauth/token","userinfo_endpoint":"http://mock-oidc-server/userinfo","jwks_uri":"http://mock-oidc-server/.well-known/jwks.json","response_types_supported":["code","token","id_token"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256"],"scopes_supported":["openid","profile","email"],"token_endpoint_auth_methods_supported":["client_secret_post","client_secret_basic"],"claims_supported":["sub","iss","name","email"]}'; + } + + location /.well-known/jwks.json { + add_header Content-Type application/json; + add_header Access-Control-Allow-Origin *; + return 200 '{"keys":[{"kty":"RSA","kid":"test-key","use":"sig","alg":"RS256","n":"test-modulus","e":"AQAB"}]}'; + } + + location /userinfo { + add_header Content-Type application/json; + add_header Access-Control-Allow-Origin *; + return 200 '{"sub":"test-user-id","name":"Test User","email":"test@example.com"}'; + } + + location / { + return 404; + } + } + } +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mock-oidc-server +spec: + replicas: 1 + selector: + matchLabels: + app: mock-oidc-server + template: + metadata: + labels: + app: mock-oidc-server + spec: + containers: + - name: mock-oidc-server + image: nginx:alpine + ports: + - containerPort: 80 + volumeMounts: + - name: nginx-config + mountPath: /etc/nginx/nginx.conf + subPath: nginx.conf + volumes: + - name: nginx-config + configMap: + name: mock-oidc-config \ No newline at end of file diff --git a/helm/test/templates/secret-oidc.yaml b/helm/test/templates/secret-oidc.yaml new file mode 100644 index 00000000..7bf6f80f --- /dev/null +++ b/helm/test/templates/secret-oidc.yaml @@ -0,0 +1,17 @@ +--- +{{- if or .Values.headplane.oidc.enabled .Values.headscale.oidc.enabled }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ .Values.headplane.oidc.secret_name | default .Values.headscale.oidc.secret_name | default "oidc-secrets" }} +type: Opaque +stringData: + {{- if .Values.headplane.oidc.enabled }} + HEADPLANE_OIDC__CLIENT_SECRET: "test-headplane-oidc-client-secret" + HEADPLANE_OIDC__CLIENT_ID: {{ .Values.headplane.oidc.client_id | default "test-headplane-oidc-client-id" | quote }} + {{- end }} + {{- if .Values.headscale.oidc.enabled }} + HEADSCALE_OIDC__CLIENT_SECRET: "test-headscale-oidc-client-secret" + HEADSCALE_OIDC__CLIENT_ID: {{ .Values.headscale.oidc.client_id | default "test-headscale-oidc-client-id" | quote }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/helm/test/test-cases/00-lag0-test-case.yaml b/helm/test/test-cases/00-lag0-test-case.yaml new file mode 100644 index 00000000..8af843ef --- /dev/null +++ b/helm/test/test-cases/00-lag0-test-case.yaml @@ -0,0 +1,82 @@ +headplane: + image: ghcr.io/tale/headplane:0.6.0 + config: + server: + host: "0.0.0.0" + port: 3000 + cookie_secure: true + headscale: + url: "https://vpn.lag0.com.br" + config_path: "/etc/headscale/config.yaml" + config_strict: "true" + integration: + kubernetes: + enabled: true + validate_manifest: false + pod_name: "headplane-0" + secret: + name: headplane-secret + create: true +headscale: + image: headscale/headscale:0.26.0 + config: + server_url: https://headscale.lag0.com.br + listen_addr: 0.0.0.0:8080 + metrics_listen_addr: 0.0.0.0:9090 + grpc_listen_addr: 0.0.0.0:50443 + grpc_allow_insecure: false + policy: + mode: database + prefixes: + v4: 100.64.0.0/10 + v6: fd7a:115c:a1e0::/48 + allocation: sequential + database: + type: sqlite + debug: false + sqlite: + path: /etc/headscale/db.sqlite + noise: + private_key_path: /etc/headscale/noise_private.key + derp: + server: + enabled: true + region_id: 999 + region_code: "headscale" + region_name: "Headscale Embedded DERP" + stun_listen_addr: "0.0.0.0:3478" + private_key_path: /var/lib/headscale/derp_server_private.key + automatically_add_embedded_derp_region: true + ipv4: 1.2.3.4 + ipv6: 2001:db8::1 + urls: + - https://controlplane.tailscale.com/derpmap/default + paths: [] + dns: + magic_dns: true + base_domain: clients.lag0.com.br + nameservers: + global: + - 1.1.1.1 + - 8.8.8.8 +relay: + enabled: false +pvc: + enabled: true + name: headscale-config + accessModes: + - ReadWriteOnce + storage: 1Gi + annotations: + kustomize.toolkit.fluxcd.io/prune: disabled +# storageClassName: default + +ingress: + enabled: false + className: nginx + annotations: + cert-manager.io/cluster-issuer: "letsencrypt-production" + labels: [] + headplaneDomain: "headscale.lag0.com.br" + headscaleDomain: "vpn.lag0.com.br" + tlsSecretName: "headplane-tls" diff --git a/helm/test/test-cases/01-definetelynobody-test-case.yaml b/helm/test/test-cases/01-definetelynobody-test-case.yaml new file mode 100644 index 00000000..1bfbc69e --- /dev/null +++ b/helm/test/test-cases/01-definetelynobody-test-case.yaml @@ -0,0 +1,18 @@ +headplane: + config: + headscale: + url: "https://vpn.test.example.com" + oidc: + # client_id: "test-headplane-client-id" + enabled: true + issuer: "http://mock-oidc-server" + redirect_uri: "https://headplane.test.example.com/admin/oidc/callback" +headscale: + config: + server_url: "https://vpn.test.example.com" + dns: + base_domain: "test.vpn" + oidc: + client_id: "test-headscale-client-id" + enabled: true + issuer: "http://mock-oidc-server" \ No newline at end of file diff --git a/helm/test/test-cases/02-AzSiAz-test-case.yaml b/helm/test/test-cases/02-AzSiAz-test-case.yaml new file mode 100644 index 00000000..51ba0816 --- /dev/null +++ b/helm/test/test-cases/02-AzSiAz-test-case.yaml @@ -0,0 +1,22 @@ +# Test case for PR #3 +headplane: + config: + headscale: + url: "https://vpn.test.example.com" + oidc: + client_id: "test-headplane-client-id" + enabled: true + issuer: "http://mock-oidc-server" + redirect_uri: "https://headplane.test.example.com/admin/oidc/callback" +headscale: + config: + server_url: "https://vpn.test.example.com" + dns: + base_domain: "test.vpn" + oidc: + pkce: + enabled: true + method: S256 + client_id: "test-headscale-client-id" + enabled: true + issuer: "http://mock-oidc-server" \ No newline at end of file diff --git a/helm/values.yaml b/helm/values.yaml new file mode 100644 index 00000000..9eb76eaa --- /dev/null +++ b/helm/values.yaml @@ -0,0 +1,216 @@ +# Headplane UI and API configuration +headplane: + # The container image for Headplane + image: ghcr.io/tale/headplane:0.6.0 + oidc: + # Enable OIDC integration for Headplane + enabled: false + # OIDC issuer URL + issuer: "https://your-oidc-issuer-url.com" + # Disable API key login + disable_api_key_login: true + # OIDC token endpoint auth method + token_endpoint_auth_method: "client_secret_post" + # OIDC redirect URI + redirect_uri: "https://your-headplane-admin-domain.com/admin/oidc/callback" + # OIDC client ID + client_id: "REPLACE_IT_WITH_YOUR_OIDC_CLIENT_ID_FOR_HEADPLANE" + # Name of the secret containing OIDC credentials + secret_name: "oidc-secrets" + config: + server: + # The host address for the Headplane server + host: "0.0.0.0" + # The port for the Headplane server + port: 3000 + # Use secure cookies (should be true in production) + cookie_secure: true + headscale: + # The URL for the Headscale server + url: "https://vpn.example.com" + # Path to the Headscale config file + config_path: "/etc/headscale/config.yaml" + # Enable strict config mode + config_strict: "true" + integration: + kubernetes: + # Enable Kubernetes integration + enabled: true + # Validate Kubernetes manifest + validate_manifest: true + # The pod name for Headplane + pod_name: "headplane-0" + # Additional secrets to mount as environment variables + envFrom: [] + # - secretRef: + # name: headscale-api-token + +# Headscale server configuration +headscale: + # The container image for Headscale + image: headscale/headscale:0.26.1 + + # Access Control List configuration (JSON format) + # Only applicable if headscale.config.policy.mode is 'file' + acl: | + { + "acls": [] + } + + oidc: + # Enable OIDC integration for Headscale + enabled: false + # OIDC issuer URL + issuer: "https://your-oidc-issuer.com" + # OIDC client ID + client_id: "YOUR_OIDC_CLIENT_ID_FOR_HEADSCALE" + # Name of the secret containing OIDC credentials + secret_name: "oidc-secrets" + pkce: + enabled: false + method: S256 + config: + # The public URL for the Headscale server + server_url: https://vpn.example.com + # The address Headscale listens on + listen_addr: 0.0.0.0:8080 + # The address for metrics + metrics_listen_addr: 0.0.0.0:9090 + # The address for gRPC + grpc_listen_addr: 0.0.0.0:50443 + # Allow insecure gRPC connections + grpc_allow_insecure: false + policy: + # Policy mode: 'file' or 'database' + mode: database + # Path to the policy file (used if mode is 'file') + path: "/etc/headscale/acl.hujson" + prefixes: + # IPv4 prefix for Headscale + v4: 100.64.0.0/10 + # IPv6 prefix for Headscale + v6: fd7a:115c:a1e0::/48 + # IP allocation mode + allocation: sequential + database: + # Database type (sqlite recommended for demo/testing) + type: sqlite + # Enable database debug logging + debug: false + sqlite: + # Path to the SQLite database file + path: /etc/headscale/db.sqlite + noise: + # Path to the Noise protocol private key + private_key_path: /etc/headscale/noise_private.key + derp: + server: + # Enable embedded DERP server + enabled: false + # DERP region ID + region_id: 999 + # DERP region code + region_code: "headscale" + # DERP region name + region_name: "Headscale Embedded DERP" + # STUN listen address + stun_listen_addr: "0.0.0.0:3478" + # Path to DERP server private key + private_key_path: /var/lib/headscale/derp_server_private.key + # Automatically add embedded DERP region + automatically_add_embedded_derp_region: true + # DERP IPv4 address + ipv4: 1.2.3.4 + # DERP IPv6 address + ipv6: 2001:db8::1 + # List of DERP map URLs + urls: + - https://controlplane.tailscale.com/derpmap/default + # Additional DERP paths + paths: [] + dns: + # Enable MagicDNS + magic_dns: true + # Base domain for MagicDNS + base_domain: headscale.vpn + nameservers: + global: + - 1.1.1.1 + - 8.8.8.8 + # allowed_groups: + # - vpn_access + # allowed_domains: + # - example.com + # allowed_users: + # - alice@example.com + # Additional secrets to mount as environment variables + envFrom: [] + +# Tailscale relay configuration +relay: + # Enable the Tailscale relay + enabled: false + # The container image for the Tailscale relay + image: ghcr.io/tailscale/tailscale:v1.80.3 + config: + # This is an insecure field, be sure to NOT use a reusable auth key here + # if not provided, will use TS_AUTHKEY from tailscale-auth secret + authKey: "" + # Hostname for the relay + hostname: "example.com" + # Exit node configuration + exit_node: "example.com" + # Tailscale login server URL + login_server: "https://vpn.example.com" + # Whether to advertise as exit node + advertise_exit_node: "true" + # Enable firewall debug mode + firewall_debug: "false" + # Routes to advertise + routes: "10.0.0.0/8" + pvc: + # Enable persistent storage for the relay + enabled: false + # Name of the PVC for relay data + name: tailscale-relay-data + # Access modes for the PVC + accessModes: + - ReadWriteOnce + # Storage size for the PVC + storage: 1Gi +# storageClassName: default + +# Persistent volume claim for Headscale +pvc: + # Enable persistent storage for Headscale + enabled: true + # Name of the PVC for Headscale data + name: headscale-config + # Access modes for the PVC + accessModes: + - ReadWriteOnce + # Storage size for the PVC + storage: 1Gi + # Annotations for the PVC + annotations: {} + # Labels for the PVC + labels: [] +# storageClassName: default + +# Ingress configuration +ingress: + # Enable ingress for Headplane and Headscale + enabled: false + # Ingress class name + className: nginx + # Additional ingress annotations + annotations: [] + # cert-manager.io/cluster-issuer: "cloudflare" + # Additional ingress labels + labels: [] + # Domain for Headplane UI + headplaneDomain: "headplane.example.com" + # Domain for Headscale API + headscaleDomain: "vpn.example.com" + # Name of the TLS secret + tlsSecretName: "headplane-tls"