From 1b5b5145b0291b1e1b65098344d03f32ba7c14b1 Mon Sep 17 00:00:00 2001 From: Morten Olsen Date: Fri, 22 Aug 2025 07:35:50 +0200 Subject: [PATCH] stuff --- .gitignore | 4 +- cert-issuer.yaml | 6 +- charts/apps/bytestash/templates/client.yaml | 4 +- .../templates/external-http-service.yaml | 11 ++ .../bytestash/templates/stateful-set.yaml | 10 +- charts/apps/bytestash/values.yaml | 5 +- manifests/client.yaml | 6 +- manifests/test-service.yaml | 14 ++ src/bootstrap/bootstrap.ts | 10 + src/bootstrap/repos/repos.ts | 11 ++ .../authentik-client.controller.ts | 175 ------------------ .../authentik-client.schemas.ts | 28 --- .../authentik-client/authentik-client.ts | 19 -- .../generate-secret.resource.ts | 61 ------ .../generate-secret.schemas.ts | 17 -- .../generate-secret/generate-secret.ts | 19 -- .../authentik-server/authentik-server.ts | 22 ++- .../cloudflare-route/cloudflare-route.ts | 0 .../cloudflare-tunnel/cloudflare-tunnel.ts | 94 ++++++++++ .../homelab/environment/environment.ts | 7 +- .../external-http-service.ts | 43 +++++ .../generate-secret/generate-secret.ts | 47 +++++ .../generate-secret/generate-secret.utils.ts | 0 src/resources/homelab/homelab.ts | 10 + .../homelab/http-service/http-service.ts | 83 +++++++++ .../homelab/oidc-client/oidc-client.ts | 109 +++++++++++ src/services/resources/resources.ts | 13 +- 27 files changed, 485 insertions(+), 343 deletions(-) create mode 100644 charts/apps/bytestash/templates/external-http-service.yaml create mode 100644 manifests/test-service.yaml delete mode 100644 src/custom-resouces/authentik-client/authentik-client.controller.ts delete mode 100644 src/custom-resouces/authentik-client/authentik-client.schemas.ts delete mode 100644 src/custom-resouces/authentik-client/authentik-client.ts delete mode 100644 src/custom-resouces/generate-secret/generate-secret.resource.ts delete mode 100644 src/custom-resouces/generate-secret/generate-secret.schemas.ts delete mode 100644 src/custom-resouces/generate-secret/generate-secret.ts delete mode 100644 src/resources/homelab/cloudflare-route/cloudflare-route.ts create mode 100644 src/resources/homelab/cloudflare-tunnel/cloudflare-tunnel.ts create mode 100644 src/resources/homelab/external-http-service.ts/external-http-service.ts create mode 100644 src/resources/homelab/generate-secret/generate-secret.ts rename src/{custom-resouces => resources/homelab}/generate-secret/generate-secret.utils.ts (100%) create mode 100644 src/resources/homelab/http-service/http-service.ts create mode 100644 src/resources/homelab/oidc-client/oidc-client.ts diff --git a/.gitignore b/.gitignore index 10bb0b5..8ccc75b 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Finder (MacOS) folder config .DS_Store -/data/ \ No newline at end of file +/data/ + +/cloudflare.yaml diff --git a/cert-issuer.yaml b/cert-issuer.yaml index db692f5..bcdc7c3 100644 --- a/cert-issuer.yaml +++ b/cert-issuer.yaml @@ -1,7 +1,7 @@ apiVersion: cert-manager.io/v1 kind: ClusterIssuer metadata: - name: letsencrypt-prod + name: lets-encrypt-prod annotations: argocd.argoproj.io/sync-options: SkipDryRunOnMissingResource=true spec: @@ -15,5 +15,5 @@ spec: cloudflare: email: alice@alice.com apiTokenSecretRef: - name: cloudflare-api-token - key: api-token \ No newline at end of file + name: cloudflare + key: token diff --git a/charts/apps/bytestash/templates/client.yaml b/charts/apps/bytestash/templates/client.yaml index 09ecb3b..a21656c 100644 --- a/charts/apps/bytestash/templates/client.yaml +++ b/charts/apps/bytestash/templates/client.yaml @@ -1,9 +1,9 @@ apiVersion: homelab.mortenolsen.pro/v1 -kind: AuthentikClient +kind: OidcClient metadata: name: '{{ .Release.Name }}' spec: - server: '{{ .Values.authentikServer }}' + environment: '{{ .Values.environment }}' redirectUris: - url: https://localhost:3000/api/v1/authentik/oauth2/callback matchingMode: strict diff --git a/charts/apps/bytestash/templates/external-http-service.yaml b/charts/apps/bytestash/templates/external-http-service.yaml new file mode 100644 index 0000000..ff86e79 --- /dev/null +++ b/charts/apps/bytestash/templates/external-http-service.yaml @@ -0,0 +1,11 @@ +apiVersion: homelab.mortenolsen.pro/v1 +kind: ExternalHttpService +metadata: + name: '{{ .Release.Name }}' +spec: + environment: '{{ .Values.environment }}' + subdomain: '{{ .Values.subdomain }}-external' + destination: + host: '{{ .Release.Name }}.{{ .Release.Namespace }}.svc.cluster.local' + port: + number: 80 diff --git a/charts/apps/bytestash/templates/stateful-set.yaml b/charts/apps/bytestash/templates/stateful-set.yaml index 2c812cd..46d5798 100644 --- a/charts/apps/bytestash/templates/stateful-set.yaml +++ b/charts/apps/bytestash/templates/stateful-set.yaml @@ -25,21 +25,21 @@ spec: - name: OIDC_ENABLED value: 'true' - name: OIDC_DISPLAY_NAME - value: Authentik + value: OIDC - name: OIDC_CLIENT_ID valueFrom: secretKeyRef: - name: authentik-client-{{ .Release.Name }} + name: '{{ .Release.Name }}-client' key: clientId - name: OIDC_CLIENT_SECRET valueFrom: secretKeyRef: - name: authentik-client-{{ .Release.Name }} + name: '{{ .Release.Name }}-client' key: clientSecret - name: OIDC_ISSUER_URL valueFrom: secretKeyRef: - name: authentik-client-{{ .Release.Name }} + name: '{{ .Release.Name }}-client' key: configuration # !! IMPORTANT !! @@ -62,7 +62,7 @@ spec: name: bytestash-data spec: accessModes: ['ReadWriteOnce'] - storageClassName: '{{ .Values.storageClassName }}' + storageClassName: '{{ .Values.environment }}' resources: requests: storage: 5Gi diff --git a/charts/apps/bytestash/values.yaml b/charts/apps/bytestash/values.yaml index 65f3462..3067066 100644 --- a/charts/apps/bytestash/values.yaml +++ b/charts/apps/bytestash/values.yaml @@ -1,5 +1,2 @@ -environment: dev/dev -postgresCluster: dev/dev-postgres-cluster -authentikServer: dev/dev-authentik-server -storageClassName: dev-retain +environment: dev subdomain: bytestash diff --git a/manifests/client.yaml b/manifests/client.yaml index a896b0b..ee4449c 100644 --- a/manifests/client.yaml +++ b/manifests/client.yaml @@ -1,9 +1,9 @@ apiVersion: homelab.mortenolsen.pro/v1 -kind: AuthentikClient +kind: OidcClient metadata: name: test-client spec: - server: dev/dev-authentik-server + environment: dev redirectUris: - url: https://localhost:3000/api/v1/authentik/oauth2/callback - matchingMode: strict \ No newline at end of file + matchingMode: strict diff --git a/manifests/test-service.yaml b/manifests/test-service.yaml new file mode 100644 index 0000000..26a9991 --- /dev/null +++ b/manifests/test-service.yaml @@ -0,0 +1,14 @@ +apiVersion: networking.istio.io/v1alpha3 +kind: ServiceEntry +metadata: + name: test-example-com + namespace: dev +spec: + hosts: + - authentik.one.dev.olsen.cloud + # (the address field is optional if you use 'resolution: DNS') + ports: + - number: 80 + name: https + protocol: HTTPS + resolution: DNS diff --git a/src/bootstrap/bootstrap.ts b/src/bootstrap/bootstrap.ts index d35266e..4b1baae 100644 --- a/src/bootstrap/bootstrap.ts +++ b/src/bootstrap/bootstrap.ts @@ -1,3 +1,5 @@ +import { CloudflareTunnel } from '#resources/homelab/cloudflare-tunnel/cloudflare-tunnel.ts'; +import { ResourceService } from '#services/resources/resources.ts'; import type { Services } from '../utils/service.ts'; import { NamespaceService } from './namespaces/namespaces.ts'; @@ -22,10 +24,18 @@ class BootstrapService { return this.#services.get(ReleaseService); } + public get cloudflareTunnel() { + const resourceService = this.#services.get(ResourceService); + return resourceService.get(CloudflareTunnel, 'cloudflare-tunnel', this.namespaces.homelab.name); + } + public ensure = async () => { await this.namespaces.ensure(); await this.repos.ensure(); await this.releases.ensure(); + await this.cloudflareTunnel.ensure({ + spec: {}, + }); }; } diff --git a/src/bootstrap/repos/repos.ts b/src/bootstrap/repos/repos.ts index 57c58ae..648d7df 100644 --- a/src/bootstrap/repos/repos.ts +++ b/src/bootstrap/repos/repos.ts @@ -8,16 +8,19 @@ class RepoService { #jetstack: HelmRepo; #istio: HelmRepo; #authentik: HelmRepo; + #cloudflare: HelmRepo; constructor(services: Services) { const resourceService = services.get(ResourceService); this.#jetstack = resourceService.get(HelmRepo, 'jetstack', NAMESPACE); this.#istio = resourceService.get(HelmRepo, 'istio', NAMESPACE); this.#authentik = resourceService.get(HelmRepo, 'authentik', NAMESPACE); + this.#cloudflare = resourceService.get(HelmRepo, 'cloudflare', NAMESPACE); this.#jetstack.on('changed', this.ensure); this.#istio.on('changed', this.ensure); this.#authentik.on('changed', this.ensure); + this.#cloudflare.on('changed', this.ensure); } public get jetstack() { @@ -32,6 +35,10 @@ class RepoService { return this.#authentik; } + public get cloudflare() { + return this.#cloudflare; + } + public ensure = async () => { await this.#jetstack.set({ url: 'https://charts.jetstack.io', @@ -44,6 +51,10 @@ class RepoService { await this.#authentik.set({ url: 'https://charts.goauthentik.io', }); + + await this.#cloudflare.set({ + url: 'https://cloudflare.github.io/helm-charts', + }); }; } diff --git a/src/custom-resouces/authentik-client/authentik-client.controller.ts b/src/custom-resouces/authentik-client/authentik-client.controller.ts deleted file mode 100644 index 927ba44..0000000 --- a/src/custom-resouces/authentik-client/authentik-client.controller.ts +++ /dev/null @@ -1,175 +0,0 @@ -import type { V1Secret } from '@kubernetes/client-node'; -import type { z } from 'zod'; - -import { - CustomResource, - type CustomResourceOptions, - type SubresourceResult, -} from '../../services/custom-resources/custom-resources.custom-resource.ts'; -import { ResourceReference } from '../../services/resources/resources.ref.ts'; -import { ResourceService, type Resource } from '../../services/resources/resources.ts'; -import { getWithNamespace } from '../../utils/naming.ts'; -import { decodeSecret, encodeSecret } from '../../utils/secrets.ts'; -import { CONTROLLED_LABEL } from '../../utils/consts.ts'; -import { isDeepSubset } from '../../utils/objects.ts'; -import { AuthentikService } from '../../services/authentik/authentik.service.ts'; -import { authentikServerSecretSchema } from '../authentik-server/authentik-server.schemas.ts'; - -import { authentikClientSecretSchema, type authentikClientSpecSchema } from './authentik-client.schemas.ts'; - -class AuthentikClientController extends CustomResource { - #serverSecret: ResourceReference; - #clientSecretResource: Resource; - - constructor(options: CustomResourceOptions) { - super(options); - const resourceService = this.services.get(ResourceService); - - this.#serverSecret = new ResourceReference(); - this.#clientSecretResource = resourceService.get({ - apiVersion: 'v1', - kind: 'Secret', - name: `authentik-client-${this.name}`, - namespace: this.namespace, - }); - - this.#updateResouces(); - - this.#serverSecret.on('changed', this.queueReconcile); - this.#clientSecretResource.on('changed', this.queueReconcile); - } - - #updateResouces = () => { - const serverSecretNames = getWithNamespace(`${this.spec.server}-server`, this.namespace); - const resourceService = this.services.get(ResourceService); - this.#serverSecret.current = resourceService.get({ - apiVersion: 'v1', - kind: 'Secret', - name: serverSecretNames.name, - namespace: serverSecretNames.namespace, - }); - }; - - #reconcileClientSecret = async (): Promise => { - const serverSecret = this.#serverSecret.current; - if (!serverSecret?.exists || !serverSecret.data) { - return { - ready: false, - failed: true, - message: 'Server or server secret not found', - }; - } - const serverSecretData = authentikServerSecretSchema.safeParse(decodeSecret(serverSecret.data)); - if (!serverSecretData.success || !serverSecretData.data) { - return { - ready: false, - failed: true, - message: 'Server secret not found', - }; - } - const url = serverSecretData.data.url; - const appName = this.name; - const clientSecretData = authentikClientSecretSchema.safeParse(decodeSecret(this.#clientSecretResource.data)); - - const expectedValues: z.infer = { - clientId: this.name, - clientSecret: clientSecretData.data?.clientSecret || crypto.randomUUID(), - configuration: new URL(`/application/o/${appName}/.well-known/openid-configuration`, url).toString(), - configurationIssuer: new URL(`/application/o/${appName}/`, url).toString(), - authorization: new URL(`/application/o/${appName}/authorize/`, url).toString(), - token: new URL(`/application/o/${appName}/token/`, url).toString(), - userinfo: new URL(`/application/o/${appName}/userinfo/`, url).toString(), - endSession: new URL(`/application/o/${appName}/end-session/`, url).toString(), - jwks: new URL(`/application/o/${appName}/jwks/`, url).toString(), - }; - if (!isDeepSubset(clientSecretData.data, expectedValues)) { - await this.#clientSecretResource.patch({ - metadata: { - ownerReferences: [this.ref], - labels: { - ...CONTROLLED_LABEL, - }, - }, - data: encodeSecret(expectedValues), - }); - return { - ready: false, - syncing: true, - message: 'UpdatingManifest', - }; - } - return { - ready: true, - }; - }; - - #reconcileServer = async (): Promise => { - const serverSecret = this.#serverSecret.current; - const clientSecret = this.#clientSecretResource; - - if (!serverSecret?.exists || !serverSecret.data) { - return { - ready: false, - failed: true, - message: 'Server secret not found', - }; - } - - const serverSecretData = authentikServerSecretSchema.safeParse(decodeSecret(serverSecret.data)); - if (!serverSecretData.success || !serverSecretData.data) { - return { - ready: false, - failed: true, - message: 'Server secret not found', - }; - } - - const clientSecretData = authentikClientSecretSchema.safeParse(decodeSecret(clientSecret.data)); - if (!clientSecretData.success || !clientSecretData.data) { - return { - ready: false, - failed: true, - message: 'Client secret not found', - }; - } - - const authentikService = this.services.get(AuthentikService); - const authentikServer = authentikService.get({ - url: { - internal: `http://${serverSecretData.data.host}`, - external: serverSecretData.data.url, - }, - token: serverSecretData.data.token, - }); - - (await authentikServer).upsertClient({ - ...this.spec, - name: this.name, - secret: clientSecretData.data.clientSecret, - }); - - return { - ready: true, - }; - }; - - public reconcile = async () => { - if (!this.exists || this.metadata?.deletionTimestamp) { - return; - } - this.#updateResouces(); - await Promise.all([ - this.reconcileSubresource('Secret', this.#reconcileClientSecret), - this.reconcileSubresource('Server', this.#reconcileServer), - ]); - - const secretReady = this.conditions.get('Secret')?.status === 'True'; - const serverReady = this.conditions.get('Server')?.status === 'True'; - - await this.conditions.set('Ready', { - status: secretReady && serverReady ? 'True' : 'False', - }); - }; -} - -export { AuthentikClientController }; diff --git a/src/custom-resouces/authentik-client/authentik-client.schemas.ts b/src/custom-resouces/authentik-client/authentik-client.schemas.ts deleted file mode 100644 index 0e5a058..0000000 --- a/src/custom-resouces/authentik-client/authentik-client.schemas.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ClientTypeEnum, SubModeEnum } from '@goauthentik/api'; -import { z } from 'zod'; - -const authentikClientSpecSchema = z.object({ - server: z.string(), - subMode: z.enum(SubModeEnum).optional(), - clientType: z.enum(ClientTypeEnum).optional(), - redirectUris: z.array( - z.object({ - url: z.string(), - matchingMode: z.enum(['strict', 'regex']), - }), - ), -}); - -const authentikClientSecretSchema = z.object({ - clientId: z.string(), - clientSecret: z.string().optional(), - configuration: z.string(), - configurationIssuer: z.string(), - authorization: z.string(), - token: z.string(), - userinfo: z.string(), - endSession: z.string(), - jwks: z.string(), -}); - -export { authentikClientSpecSchema, authentikClientSecretSchema }; diff --git a/src/custom-resouces/authentik-client/authentik-client.ts b/src/custom-resouces/authentik-client/authentik-client.ts deleted file mode 100644 index 668e2b5..0000000 --- a/src/custom-resouces/authentik-client/authentik-client.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts'; -import { GROUP } from '../../utils/consts.ts'; - -import { AuthentikClientController } from './authentik-client.controller.ts'; -import { authentikClientSpecSchema } from './authentik-client.schemas.ts'; - -const authentikClientDefinition = createCustomResourceDefinition({ - group: GROUP, - version: 'v1', - kind: 'AuthentikClient', - names: { - plural: 'authentikclients', - singular: 'authentikclient', - }, - create: (options) => new AuthentikClientController(options), - spec: authentikClientSpecSchema, -}); - -export { authentikClientDefinition }; diff --git a/src/custom-resouces/generate-secret/generate-secret.resource.ts b/src/custom-resouces/generate-secret/generate-secret.resource.ts deleted file mode 100644 index c1eb56c..0000000 --- a/src/custom-resouces/generate-secret/generate-secret.resource.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { V1Secret } from '@kubernetes/client-node'; - -import { - CustomResource, - type CustomResourceOptions, -} from '../../services/custom-resources/custom-resources.custom-resource.ts'; -import { Resource, ResourceService } from '../../services/resources/resources.ts'; -import { decodeSecret, encodeSecret } from '../../utils/secrets.ts'; -import { isDeepSubset } from '../../utils/objects.ts'; - -import { generateSecrets } from './generate-secret.utils.ts'; -import { generateSecretSpecSchema } from './generate-secret.schemas.ts'; - -class GenerateSecretResource extends CustomResource { - #secretResource: Resource; - - constructor(options: CustomResourceOptions) { - super(options); - const resourceService = this.services.get(ResourceService); - - this.#secretResource = resourceService.get({ - apiVersion: 'v1', - kind: 'Secret', - name: this.name, - namespace: this.namespace, - }); - - this.#secretResource.on('changed', this.queueReconcile); - } - - public reconcile = async () => { - if (!this.exists || this.metadata?.deletionTimestamp) { - return; - } - - const secrets = generateSecrets(this.spec); - const current = decodeSecret(this.#secretResource.data) || {}; - - const expected = { - ...secrets, - ...current, - }; - - if (!isDeepSubset(current, expected)) { - this.#secretResource.patch({ - data: encodeSecret(expected), - }); - this.conditions.set('SecretUpdated', { - status: 'False', - reason: 'SecretUpdated', - }); - } - - this.conditions.set('Ready', { - status: 'True', - reason: 'Ready', - }); - }; -} - -export { GenerateSecretResource }; diff --git a/src/custom-resouces/generate-secret/generate-secret.schemas.ts b/src/custom-resouces/generate-secret/generate-secret.schemas.ts deleted file mode 100644 index 826f5bf..0000000 --- a/src/custom-resouces/generate-secret/generate-secret.schemas.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { z } from 'zod'; - -const generateSecretFieldSchema = z.object({ - name: z.string(), - value: z.string().optional(), - encoding: z.enum(['base64', 'base64url', 'hex', 'utf8', 'numeric']).optional(), - length: z.number().optional(), -}); - -const generateSecretSpecSchema = z.object({ - fields: z.array(generateSecretFieldSchema), -}); - -type GenerateSecretField = z.infer; -type GenerateSecretSpec = z.infer; - -export { generateSecretSpecSchema, type GenerateSecretField, type GenerateSecretSpec }; diff --git a/src/custom-resouces/generate-secret/generate-secret.ts b/src/custom-resouces/generate-secret/generate-secret.ts deleted file mode 100644 index 6f52f26..0000000 --- a/src/custom-resouces/generate-secret/generate-secret.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { createCustomResourceDefinition } from '../../services/custom-resources/custom-resources.ts'; -import { GROUP } from '../../utils/consts.ts'; - -import { GenerateSecretResource } from './generate-secret.resource.ts'; -import { generateSecretSpecSchema } from './generate-secret.schemas.ts'; - -const generateSecretDefinition = createCustomResourceDefinition({ - group: GROUP, - version: 'v1', - kind: 'GenerateSecret', - names: { - plural: 'generate-secrets', - singular: 'generate-secret', - }, - spec: generateSecretSpecSchema, - create: (options) => new GenerateSecretResource(options), -}); - -export { generateSecretDefinition }; diff --git a/src/resources/homelab/authentik-server/authentik-server.ts b/src/resources/homelab/authentik-server/authentik-server.ts index c81095a..6354b74 100644 --- a/src/resources/homelab/authentik-server/authentik-server.ts +++ b/src/resources/homelab/authentik-server/authentik-server.ts @@ -75,6 +75,26 @@ class AuthentikServer extends CustomResource { this.#destinationRule.on('changed', this.queueReconcile); } + public get service() { + return this.#service; + } + + public get secret() { + return this.#secret; + } + + public get subdomain() { + return this.spec?.subdomain || 'authentik'; + } + + public get domain() { + return `${this.subdomain}.${this.#environment.current?.spec?.domain}`; + } + + public get url() { + return `https://${this.domain}`; + } + public reconcile = async () => { if (!this.spec) { throw new NotReadyError('MissingSpec'); @@ -240,7 +260,7 @@ class AuthentikServer extends CustomResource { ownerReferences: [this.ref], }, spec: { - gateways: [`${gateway.namespace}/${gateway.name}`], + gateways: [`${gateway.namespace}/${gateway.name}`, 'mesh'], hosts: [domain], http: [ { diff --git a/src/resources/homelab/cloudflare-route/cloudflare-route.ts b/src/resources/homelab/cloudflare-route/cloudflare-route.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/resources/homelab/cloudflare-tunnel/cloudflare-tunnel.ts b/src/resources/homelab/cloudflare-tunnel/cloudflare-tunnel.ts new file mode 100644 index 0000000..084fd93 --- /dev/null +++ b/src/resources/homelab/cloudflare-tunnel/cloudflare-tunnel.ts @@ -0,0 +1,94 @@ +import { + CustomResource, + Resource, + ResourceService, + type CustomResourceOptions, +} from '#services/resources/resources.ts'; +import z from 'zod'; +import { ExternalHttpService } from '../external-http-service.ts/external-http-service.ts'; +import { API_VERSION } from '#utils/consts.ts'; +import { HelmRelease } from '#resources/flux/helm-release/helm-release.ts'; +import { RepoService } from '#bootstrap/repos/repos.ts'; +import { Secret } from '#resources/core/secret/secret.ts'; +import { NotReadyError } from '#utils/errors.ts'; +import { NamespaceService } from '#bootstrap/namespaces/namespaces.ts'; + +const specSchema = z.object({}); + +type SecretData = { + account: string; + tunnelName: string; + tunnelId: string; + secret: string; +}; +class CloudflareTunnel extends CustomResource { + public static readonly apiVersion = API_VERSION; + public static readonly kind = 'CloudflareTunnel'; + public static readonly spec = specSchema; + public static readonly scope = 'Cluster'; + + #helmRelease: HelmRelease; + #secret: Secret; + + constructor(options: CustomResourceOptions) { + super(options); + const resourceService = this.services.get(ResourceService); + const namespaceService = this.services.get(NamespaceService); + const namespace = namespaceService.homelab.name; + resourceService.on('changed', this.#handleResourceChanged); + + this.#helmRelease = resourceService.get(HelmRelease, this.name, namespace); + this.#secret = resourceService.get(Secret, 'cloudflare', namespace); + this.#secret.on('changed', this.queueReconcile); + } + + #handleResourceChanged = (resource: Resource) => { + if (resource instanceof CloudflareTunnel) { + this.queueReconcile(); + } + }; + + public reconcile = async () => { + const secret = this.#secret.value; + if (!secret) { + throw new NotReadyError('MissingSecret', `Secret ${this.#secret.namespace}/${this.#secret.name} does not exist`); + } + const resourceService = this.services.get(ResourceService); + const repoService = this.services.get(RepoService); + const routes = resourceService.getAllOfKind(ExternalHttpService); + const ingress = routes.map(({ rule }) => ({ + hostname: rule?.hostname, + service: `http://${rule?.destination.host}:${rule?.destination.port.number}`, + })); + await this.#helmRelease.ensure({ + metadata: { + ownerReferences: [this.ref], + }, + spec: { + interval: '1h', + values: { + cloudflare: { + account: secret.account, + tunnelName: secret.tunnelName, + tunnelId: secret.tunnelId, + secret: secret.secret, + ingress, + }, + }, + chart: { + spec: { + chart: 'cloudflare-tunnel', + sourceRef: { + apiVersion: 'source.toolkit.fluxcd.io/v1', + kind: 'HelmRepository', + name: repoService.cloudflare.name, + namespace: repoService.cloudflare.namespace, + }, + }, + }, + }, + }); + }; +} + +export { CloudflareTunnel }; diff --git a/src/resources/homelab/environment/environment.ts b/src/resources/homelab/environment/environment.ts index 67eb6d4..5d2e938 100644 --- a/src/resources/homelab/environment/environment.ts +++ b/src/resources/homelab/environment/environment.ts @@ -12,6 +12,7 @@ import { StorageClass } from '#resources/core/storage-class/storage-class.ts'; import { PROVISIONER } from '#resources/core/pvc/pvc.ts'; import { Gateway } from '#resources/istio/gateway/gateway.ts'; import { NotReadyError } from '#utils/errors.ts'; +import { NamespaceService } from '#bootstrap/namespaces/namespaces.ts'; const specSchema = z.object({ domain: z.string(), @@ -37,11 +38,12 @@ class Environment extends CustomResource { constructor(options: CustomResourceOptions) { super(options); const resourceService = this.services.get(ResourceService); + const namespaceService = this.services.get(NamespaceService); this.#namespace = resourceService.get(Namespace, this.name); this.#namespace.on('changed', this.queueReconcile); - this.#certificate = resourceService.get(Certificate, this.name, this.name); + this.#certificate = resourceService.get(Certificate, this.name, namespaceService.homelab.name); this.#certificate.on('changed', this.queueReconcile); this.#storageClass = resourceService.get(StorageClass, this.name); @@ -97,9 +99,6 @@ class Environment extends CustomResource { }, }); await this.#certificate.ensure({ - metadata: { - ownerReferences: [this.ref], - }, spec: { secretName: `${this.name}-tls`, issuerRef: { diff --git a/src/resources/homelab/external-http-service.ts/external-http-service.ts b/src/resources/homelab/external-http-service.ts/external-http-service.ts new file mode 100644 index 0000000..1f99e77 --- /dev/null +++ b/src/resources/homelab/external-http-service.ts/external-http-service.ts @@ -0,0 +1,43 @@ +import { CustomResource, ResourceService, type CustomResourceOptions } from '#services/resources/resources.ts'; +import { z } from 'zod'; +import { Environment } from '../environment/environment.ts'; +import { API_VERSION } from '#utils/consts.ts'; + +const specSchema = z.object({ + environment: z.string(), + subdomain: z.string(), + destination: z.object({ + host: z.string(), + port: z.object({ + number: z.number(), + }), + }), +}); + +class ExternalHttpService extends CustomResource { + public static readonly apiVersion = API_VERSION; + public static readonly kind = 'ExternalHttpService'; + public static readonly spec = specSchema; + public static readonly scope = 'Namespaced'; + + constructor(options: CustomResourceOptions) { + super(options); + } + + public get rule() { + if (!this.spec) { + return undefined; + } + const resourceService = this.services.get(ResourceService); + const env = resourceService.get(Environment, this.spec.environment); + const hostname = `${this.spec.subdomain}.${env.spec?.domain}`; + return { + domain: env.spec?.domain, + subdomain: this.spec.subdomain, + hostname, + destination: this.spec.destination, + }; + } +} + +export { ExternalHttpService }; diff --git a/src/resources/homelab/generate-secret/generate-secret.ts b/src/resources/homelab/generate-secret/generate-secret.ts new file mode 100644 index 0000000..b47b5a8 --- /dev/null +++ b/src/resources/homelab/generate-secret/generate-secret.ts @@ -0,0 +1,47 @@ +import { Secret } from '#resources/core/secret/secret.ts'; +import { CustomResource, ResourceService, type CustomResourceOptions } from '#services/resources/resources.ts'; +import { z } from 'zod'; +import { generateSecrets } from './generate-secret.utils.ts'; +import { API_VERSION } from '#utils/consts.ts'; + +const generateSecretFieldSchema = z.object({ + name: z.string(), + value: z.string().optional(), + encoding: z.enum(['base64', 'base64url', 'hex', 'utf8', 'numeric']).optional(), + length: z.number().optional(), +}); + +const specSchema = z.object({ + fields: z.array(generateSecretFieldSchema), +}); + +class GenerateSecret extends CustomResource { + public static readonly apiVersion = API_VERSION; + public static readonly kind = 'GenerateSecret'; + public static readonly spec = specSchema; + public static readonly scope = 'Namespaced'; + + #secret: Secret; + + constructor(options: CustomResourceOptions) { + super(options); + + const resourceService = this.services.get(ResourceService); + + this.#secret = resourceService.get(Secret, this.name, this.namespace); + } + + public reconcile = async () => { + const secrets = generateSecrets(this.spec); + const current = this.#secret.value; + + const expected = { + ...secrets, + ...current, + }; + + await this.#secret.ensure(expected); + }; +} + +export { GenerateSecret }; diff --git a/src/custom-resouces/generate-secret/generate-secret.utils.ts b/src/resources/homelab/generate-secret/generate-secret.utils.ts similarity index 100% rename from src/custom-resouces/generate-secret/generate-secret.utils.ts rename to src/resources/homelab/generate-secret/generate-secret.utils.ts diff --git a/src/resources/homelab/homelab.ts b/src/resources/homelab/homelab.ts index 39308b0..2d641fd 100644 --- a/src/resources/homelab/homelab.ts +++ b/src/resources/homelab/homelab.ts @@ -5,13 +5,23 @@ import { PostgresDatabase } from './postgres-database/postgres-database.ts'; import { AuthentikServer } from './authentik-server/authentik-server.ts'; import type { InstallableResourceClass } from '#services/resources/resources.ts'; +import { OIDCClient } from './oidc-client/oidc-client.ts'; +import { HttpService } from './http-service/http-service.ts'; +import { GenerateSecret } from './generate-secret/generate-secret.ts'; +import { ExternalHttpService } from './external-http-service.ts/external-http-service.ts'; +import { CloudflareTunnel } from './cloudflare-tunnel/cloudflare-tunnel.ts'; const homelab = { PostgresCluster, RedisServer, Environment, + ExternalHttpService, + CloudflareTunnel, AuthentikServer, PostgresDatabase, + OIDCClient, + HttpService, + GenerateSecret, } satisfies Record>; export { homelab }; diff --git a/src/resources/homelab/http-service/http-service.ts b/src/resources/homelab/http-service/http-service.ts new file mode 100644 index 0000000..fedfbd9 --- /dev/null +++ b/src/resources/homelab/http-service/http-service.ts @@ -0,0 +1,83 @@ +import { VirtualService } from '#resources/istio/virtual-service/virtual-service.ts'; +import { + CustomResource, + ResourceReference, + ResourceService, + type CustomResourceOptions, +} from '#services/resources/resources.ts'; +import { z } from 'zod'; +import { Environment } from '../environment/environment.ts'; +import { NotReadyError } from '#utils/errors.ts'; +import { API_VERSION } from '#utils/consts.ts'; + +const specSchema = z.object({ + environment: z.string(), + subdomain: z.string(), + destination: z.object({ + host: z.string(), + port: z.object({ + number: z.number().optional(), + name: z.string().optional(), + }), + }), +}); + +class HttpService extends CustomResource { + public static readonly apiVersion = API_VERSION; + public static readonly kind = 'HttpService'; + public static readonly spec = specSchema; + public static readonly scope = 'Namespaced'; + + #virtualService: VirtualService; + #environment: ResourceReference; + + constructor(options: CustomResourceOptions) { + super(options); + + const resourceService = this.services.get(ResourceService); + this.#virtualService = resourceService.get(VirtualService, this.name, this.namespace); + this.#virtualService.on('changed', this.queueReconcile); + + this.#environment = new ResourceReference(); + this.#environment.on('changed', this.queueReconcile); + } + + public reconcile = async () => { + if (!this.spec) { + throw new NotReadyError('MissingSpec'); + } + const resourceService = this.services.get(ResourceService); + + this.#environment.current = resourceService.get(Environment, this.spec.environment); + const env = this.#environment.current; + if (!env.exists) { + throw new NotReadyError('MissingEnvironment'); + } + const gateway = env.gateway; + const domain = env.spec?.domain; + if (!domain) { + throw new NotReadyError('MissingDomain'); + } + const host = `${this.spec.subdomain}.${domain}`; + this.#virtualService.ensure({ + metadata: { + ownerReferences: [this.ref], + }, + spec: { + hosts: [host, 'mesh'], + gateways: [`${gateway.namespace}/${gateway.name}`, 'mesh'], + http: [ + { + route: [ + { + destination: this.spec.destination, + }, + ], + }, + ], + }, + }); + }; +} + +export { HttpService }; diff --git a/src/resources/homelab/oidc-client/oidc-client.ts b/src/resources/homelab/oidc-client/oidc-client.ts new file mode 100644 index 0000000..ee9df5c --- /dev/null +++ b/src/resources/homelab/oidc-client/oidc-client.ts @@ -0,0 +1,109 @@ +import { + CustomResource, + ResourceReference, + ResourceService, + type CustomResourceOptions, +} from '#services/resources/resources.ts'; +import { ClientTypeEnum, SubModeEnum } from '@goauthentik/api'; +import { z } from 'zod'; +import { Environment } from '../environment/environment.ts'; +import { API_VERSION } from '#utils/consts.ts'; +import { NotReadyError } from '#utils/errors.ts'; +import { Secret } from '#resources/core/secret/secret.ts'; +import { generateRandomHexPass } from '#utils/secrets.ts'; +import { AuthentikService } from '#services/authentik/authentik.service.ts'; + +const specSchema = z.object({ + environment: z.string(), + subMode: z.enum(SubModeEnum).optional(), + clientType: z.enum(ClientTypeEnum).optional(), + redirectUris: z.array( + z.object({ + url: z.string(), + matchingMode: z.enum(['strict', 'regex']), + }), + ), +}); + +type SecretData = { + clientId: string; + clientSecret?: string; + configuration: string; + configurationIssuer: string; + authorization: string; + token: string; + userinfo: string; + endSession: string; + jwks: string; +}; +class OIDCClient extends CustomResource { + public static readonly apiVersion = API_VERSION; + public static readonly kind = 'OidcClient'; + public static readonly spec = specSchema; + public static readonly scope = 'Namespaced'; + + #environment = new ResourceReference(); + #secret: Secret; + + constructor(options: CustomResourceOptions) { + super(options); + const resourceService = this.services.get(ResourceService); + this.#secret = resourceService.get(Secret, `${this.name}-client`, this.namespace); + } + + public get appName() { + return this.name; + } + + public reconcile = async () => { + if (!this.spec) { + throw new NotReadyError('MissingSpec'); + } + const resourceService = this.services.get(ResourceService); + this.#environment.current = resourceService.get(Environment, this.spec.environment); + if (!this.#environment.current.exists) { + throw new NotReadyError('EnvironmentNotFound'); + } + const authentik = this.#environment.current.authentikServer; + const authentikSecret = authentik.secret.value; + if (!authentikSecret) { + throw new Error('MissingAuthentikSecret'); + } + + const url = authentik.url; + + await this.#secret.set((current) => ({ + clientSecret: generateRandomHexPass(), + ...current, + clientId: this.name, + configuration: new URL(`/application/o/${this.appName}/.well-known/openid-configuration`, url).toString(), + configurationIssuer: new URL(`/application/o/${this.appName}/`, url).toString(), + authorization: new URL(`/application/o/${this.appName}/authorize/`, url).toString(), + token: new URL(`/application/o/${this.appName}/token/`, url).toString(), + userinfo: new URL(`/application/o/${this.appName}/userinfo/`, url).toString(), + endSession: new URL(`/application/o/${this.appName}/end-session/`, url).toString(), + jwks: new URL(`/application/o/${this.appName}/jwks/`, url).toString(), + })); + + const secret = this.#secret.value; + if (!secret) { + throw new NotReadyError('MissingSecret'); + } + const authentikService = this.services.get(AuthentikService); + const authentikServer = await authentikService.get({ + url: { + internal: `http://${authentikSecret.host}`, + external: authentikSecret.url, + }, + token: authentikSecret.token, + }); + + await authentikServer.upsertClient({ + ...this.spec, + name: this.name, + secret: secret.clientSecret, + }); + }; +} + +export { OIDCClient }; diff --git a/src/services/resources/resources.ts b/src/services/resources/resources.ts index 3fdc98a..296107c 100644 --- a/src/services/resources/resources.ts +++ b/src/services/resources/resources.ts @@ -8,6 +8,7 @@ import { Resource, type ResourceOptions } from './resource/resource.ts'; import { createManifest } from './resources.utils.ts'; import { K8sService } from '#services/k8s/k8s.ts'; +import { EventEmitter } from 'eventemitter3'; type ResourceClass = (new (options: ResourceOptions) => Resource) & { apiVersion: string; @@ -21,7 +22,11 @@ type InstallableResourceClass = ResourceClass & { scope: 'Namespaced' | 'Cluster'; }; -class ResourceService { +type ResourceServiceEvents = { + changed: (resource: Resource) => void; +}; + +class ResourceService extends EventEmitter { #services: Services; #registry: Map< ResourceClass, @@ -34,6 +39,7 @@ class ResourceService { >; constructor(services: Services) { + super(); this.#services = services; this.#registry = new Map(); } @@ -65,6 +71,10 @@ class ResourceService { } }; + public getAllOfKind = >(type: T) => { + return (this.#registry.get(type)?.resources?.filter((r) => r.exists) as InstanceType[]) || []; + }; + public get = >(type: T, name: string, namespace?: string) => { let resourceRegistry = this.#registry.get(type); if (!resourceRegistry) { @@ -88,6 +98,7 @@ class ResourceService { }, services: this.#services, }); + current.on('changed', this.emit.bind(this, 'changed', current)); resources.push(current); } return current as InstanceType;