This commit is contained in:
Morten Olsen
2025-08-22 07:35:50 +02:00
parent cfd2d76873
commit 1b5b5145b0
27 changed files with 485 additions and 343 deletions

2
.gitignore vendored
View File

@@ -34,3 +34,5 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
.DS_Store
/data/
/cloudflare.yaml

View File

@@ -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
name: cloudflare
key: token

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -1,5 +1,2 @@
environment: dev/dev
postgresCluster: dev/dev-postgres-cluster
authentikServer: dev/dev-authentik-server
storageClassName: dev-retain
environment: dev
subdomain: bytestash

View File

@@ -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

View File

@@ -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

View File

@@ -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: {},
});
};
}

View File

@@ -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',
});
};
}

View File

@@ -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<typeof authentikClientSpecSchema> {
#serverSecret: ResourceReference<V1Secret>;
#clientSecretResource: Resource<V1Secret>;
constructor(options: CustomResourceOptions<typeof authentikClientSpecSchema>) {
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<SubresourceResult> => {
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<typeof authentikClientSecretSchema> = {
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<SubresourceResult> => {
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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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<typeof generateSecretSpecSchema> {
#secretResource: Resource<V1Secret>;
constructor(options: CustomResourceOptions<typeof generateSecretSpecSchema>) {
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 };

View File

@@ -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<typeof generateSecretFieldSchema>;
type GenerateSecretSpec = z.infer<typeof generateSecretSpecSchema>;
export { generateSecretSpecSchema, type GenerateSecretField, type GenerateSecretSpec };

View File

@@ -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 };

View File

@@ -75,6 +75,26 @@ class AuthentikServer extends CustomResource<typeof specSchema> {
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<typeof specSchema> {
ownerReferences: [this.ref],
},
spec: {
gateways: [`${gateway.namespace}/${gateway.name}`],
gateways: [`${gateway.namespace}/${gateway.name}`, 'mesh'],
hosts: [domain],
http: [
{

View File

@@ -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<typeof specSchema> {
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<SecretData>;
constructor(options: CustomResourceOptions<typeof specSchema>) {
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<SecretData>, 'cloudflare', namespace);
this.#secret.on('changed', this.queueReconcile);
}
#handleResourceChanged = (resource: Resource<ExpectedAny>) => {
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 };

View File

@@ -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<typeof specSchema> {
constructor(options: CustomResourceOptions<typeof specSchema>) {
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<typeof specSchema> {
},
});
await this.#certificate.ensure({
metadata: {
ownerReferences: [this.ref],
},
spec: {
secretName: `${this.name}-tls`,
issuerRef: {

View File

@@ -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<typeof specSchema> {
public static readonly apiVersion = API_VERSION;
public static readonly kind = 'ExternalHttpService';
public static readonly spec = specSchema;
public static readonly scope = 'Namespaced';
constructor(options: CustomResourceOptions<typeof specSchema>) {
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 };

View File

@@ -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<typeof specSchema> {
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<typeof specSchema>) {
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 };

View File

@@ -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<string, InstallableResourceClass<ExpectedAny>>;
export { homelab };

View File

@@ -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<typeof specSchema> {
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<typeof Environment>;
constructor(options: CustomResourceOptions<typeof specSchema>) {
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 };

View File

@@ -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<typeof specSchema> {
public static readonly apiVersion = API_VERSION;
public static readonly kind = 'OidcClient';
public static readonly spec = specSchema;
public static readonly scope = 'Namespaced';
#environment = new ResourceReference<typeof Environment>();
#secret: Secret<SecretData>;
constructor(options: CustomResourceOptions<typeof specSchema>) {
super(options);
const resourceService = this.services.get(ResourceService);
this.#secret = resourceService.get(Secret<SecretData>, `${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 };

View File

@@ -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<T extends KubernetesObject> = (new (options: ResourceOptions<T>) => Resource<T>) & {
apiVersion: string;
@@ -21,7 +22,11 @@ type InstallableResourceClass<T extends KubernetesObject> = ResourceClass<T> & {
scope: 'Namespaced' | 'Cluster';
};
class ResourceService {
type ResourceServiceEvents = {
changed: (resource: Resource<ExpectedAny>) => void;
};
class ResourceService extends EventEmitter<ResourceServiceEvents> {
#services: Services;
#registry: Map<
ResourceClass<ExpectedAny>,
@@ -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 = <T extends ResourceClass<ExpectedAny>>(type: T) => {
return (this.#registry.get(type)?.resources?.filter((r) => r.exists) as InstanceType<T>[]) || [];
};
public get = <T extends ResourceClass<ExpectedAny>>(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<T>;