Compare commits

..
Author SHA1 Message Date
Gitea Actions Bot be1c048e59 Auto-update README with current k8s applications
Keycloak Terraform / Terraform (pull_request) Successful in 15s
Generated by CI/CD workflow on 2026-06-01 10:15:36

This PR updates the README.md file with the current list of applications found in the k8s/ directory structure.
2026-06-01 10:15:36 +00:00
283 changed files with 3262 additions and 13489 deletions

No files matched your search

+1 -3
View File
@@ -30,11 +30,9 @@ jobs:
cli_config_credentials_token: ${{ secrets.TF_API_TOKEN }}
- name: Setup kubeconfig
env:
CI_KUBECONFIG: ${{ secrets.KUBECONFIG }}
run: |
mkdir -p ~/.kube
printf '%s' "$CI_KUBECONFIG" > ~/.kube/config
echo "${{ secrets.KUBECONFIG }}" > ~/.kube/config
chmod 600 ~/.kube/config
- name: Terraform Init
-3
View File
@@ -59,6 +59,3 @@ tags
[._]*.un~
.DS_Store
# Helm charts pulled by kustomize (--enable-helm) when rendering locally
k8s-ng/**/charts/
+2 -2
View File
@@ -25,7 +25,6 @@ ArgoCD homelab project
| **longhorn** | [![longhorn](https://ag.hexor.cy/api/badge?name=longhorn&revision=true)](https://ag.hexor.cy/applications/argocd/longhorn) |
| **postgresql** | [![postgresql](https://ag.hexor.cy/api/badge?name=postgresql&revision=true)](https://ag.hexor.cy/applications/argocd/postgresql) |
| **prom-stack** | [![prom-stack](https://ag.hexor.cy/api/badge?name=prom-stack&revision=true)](https://ag.hexor.cy/applications/argocd/prom-stack) |
| **reloader** | [![reloader](https://ag.hexor.cy/api/badge?name=reloader&revision=true)](https://ag.hexor.cy/applications/argocd/reloader) |
| **system-upgrade** | [![system-upgrade](https://ag.hexor.cy/api/badge?name=system-upgrade&revision=true)](https://ag.hexor.cy/applications/argocd/system-upgrade) |
### Games
@@ -43,7 +42,8 @@ ArgoCD homelab project
| Application | Status |
| :--- | :---: |
| **comfyui** | [![comfyui](https://ag.hexor.cy/api/badge?name=comfyui&revision=true)](https://ag.hexor.cy/applications/argocd/comfyui) |
| **furumi** | [![furumi](https://ag.hexor.cy/api/badge?name=furumi&revision=true)](https://ag.hexor.cy/applications/argocd/furumi) |
| **furumi-dev** | [![furumi-dev](https://ag.hexor.cy/api/badge?name=furumi-dev&revision=true)](https://ag.hexor.cy/applications/argocd/furumi-dev) |
| **furumi-server** | [![furumi-server](https://ag.hexor.cy/api/badge?name=furumi-server&revision=true)](https://ag.hexor.cy/applications/argocd/furumi-server) |
| **gitea** | [![gitea](https://ag.hexor.cy/api/badge?name=gitea&revision=true)](https://ag.hexor.cy/applications/argocd/gitea) |
| **greece-notifier** | [![greece-notifier](https://ag.hexor.cy/api/badge?name=greece-notifier&revision=true)](https://ag.hexor.cy/applications/argocd/greece-notifier) |
| **hexound** | [![hexound](https://ag.hexor.cy/api/badge?name=hexound&revision=true)](https://ag.hexor.cy/applications/argocd/hexound) |
-50
View File
@@ -1,50 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: furumi-player
labels:
app: furumi-player
spec:
replicas: 1
strategy:
type: Recreate # the library is a hostPath on a single node
selector:
matchLabels:
app: furumi-player
template:
metadata:
labels:
app: furumi-player
annotations:
reloader.stakater.com/auto: "true"
spec:
# The music library lives on this node's /data (LVM raid, not replicated).
nodeSelector:
kubernetes.io/hostname: music
containers:
- name: furumi-player
image: ultradesu/furumusic:latest
imagePullPolicy: Always
env:
- name: FURU_DATABASE_URL
valueFrom:
secretKeyRef:
name: furumi-creds
key: PG_STRING
ports:
- name: http
containerPort: 8000
volumeMounts:
- name: library
mountPath: /media
- name: inbox
mountPath: /inbox
volumes:
- name: library
hostPath:
path: /data/furumi/library
type: Directory
- name: inbox
hostPath:
path: /data/furumi/inbox
type: Directory
-24
View File
@@ -1,24 +0,0 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: furumi
annotations:
cert-manager.io/cluster-issuer: letsencrypt
traefik.ingress.kubernetes.io/router.entrypoints: websecure
spec:
ingressClassName: traefik
rules:
- host: music.hexor.cy
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: furumi-player
port:
number: 8000
tls:
- secretName: furumi-tls
hosts:
- music.hexor.cy
-9
View File
@@ -1,9 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- secrets.yaml
- deployment.yaml
- service.yaml
- servicemonitor.yaml
- ingress.yaml
-47
View File
@@ -1,47 +0,0 @@
# Read-only access to the furumi-db Secret in the psql namespace
# (see postgresql/databases/furumi.yaml for the other half).
apiVersion: v1
kind: ServiceAccount
metadata:
name: db-credentials-reader
---
apiVersion: external-secrets.io/v1
kind: SecretStore
metadata:
name: psql-credentials
spec:
provider:
kubernetes:
remoteNamespace: psql
server:
caProvider:
type: ConfigMap
name: kube-root-ca.crt
key: ca.crt
auth:
serviceAccount:
name: db-credentials-reader
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: furumi-creds
spec:
refreshInterval: 1h
secretStoreRef:
kind: SecretStore
name: psql-credentials
target:
name: furumi-creds
template:
data:
PG_STRING: "postgresql://{{ .username }}:{{ .password }}@main-rw.psql.svc:5432/furumi"
data:
- secretKey: username
remoteRef:
key: furumi-db
property: username
- secretKey: password
remoteRef:
key: furumi-db
property: password
-13
View File
@@ -1,13 +0,0 @@
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: furumi-player
spec:
selector:
matchLabels:
app: furumi-player
endpoints:
- port: http
path: /metrics
interval: 30s
scrapeTimeout: 10s
-12
View File
@@ -1,12 +0,0 @@
# k8s-secrets
The `k8s-secrets` Argo CD Application reads this directory through
`core-state/values.yaml` and deploys into namespace `k8s-secret`.
External Secrets loads the `k8s/openai/creds` Vaultwarden item into
`openai-creds`. The reader at `pass.hexor.cy` can only read that Secret and is
protected by the Keycloak forward-auth proxy. Its access policy is managed in
`terraform/keycloak/terraform.tfvars`.
The Kubernetes resource names and namespace match the former deployment so
Argo CD can switch the source path without replacing the service or certificate.
@@ -1,32 +0,0 @@
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: openai-creds
spec:
refreshInterval: 5m
secretStoreRef:
name: vaultwarden-login
kind: ClusterSecretStore
target:
name: openai-creds
creationPolicy: Owner
deletionPolicy: Retain
template:
type: Opaque
data:
USER: "{{ .user }}"
PASS: "{{ .pass }}"
TOTP: "{{ .totp }}"
data:
- secretKey: user
remoteRef:
key: k8s/openai/creds
property: login.username
- secretKey: pass
remoteRef:
key: k8s/openai/creds
property: login.password
- secretKey: totp
remoteRef:
key: k8s/openai/creds
property: login.totp
-38
View File
@@ -1,38 +0,0 @@
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: auth-proxy
spec:
forwardAuth:
address: http://auth-proxy.keycloak.svc:80/auth
trustForwardHeader: false
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: secret-reader
spec:
entryPoints:
- websecure
routes:
- match: Host(`pass.hexor.cy`)
kind: Rule
middlewares:
- name: auth-proxy
services:
- name: secret-reader
port: 80
tls:
secretName: secret-reader-tls
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: secret-reader-tls
spec:
secretName: secret-reader-tls
issuerRef:
name: letsencrypt
kind: ClusterIssuer
dnsNames:
- pass.hexor.cy
@@ -1,7 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: k8s-secret
resources:
- external-secret.yaml
- reader.yaml
- ingress.yaml
-109
View File
@@ -1,109 +0,0 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: secret-reader
labels:
app: secret-reader
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: secret-reader
rules:
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["openai-creds"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: secret-reader
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: secret-reader
subjects:
- kind: ServiceAccount
name: secret-reader
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: secret-reader
labels:
app: secret-reader
spec:
replicas: 1
selector:
matchLabels:
app: secret-reader
template:
metadata:
labels:
app: secret-reader
spec:
serviceAccountName: secret-reader
nodeSelector:
homelab.hexor.cy/pool: core
homelab.hexor.cy/exposure: external
securityContext:
runAsNonRoot: true
runAsUser: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: secret-reader
image: ultradesu/k8s-secrets:0.2.2@sha256:f206942d21ca9dcd131d6e19ba26cc17671815919217aef657b30a9f85854778
imagePullPolicy: IfNotPresent
args:
- --secrets
- openai-creds
- --namespace
- k8s-secret
- --port
- "3000"
ports:
- name: http
containerPort: 3000
env:
- name: RUST_LOG
value: info
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 150m
memory: 128Mi
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 5
periodSeconds: 5
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 10
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
---
apiVersion: v1
kind: Service
metadata:
name: secret-reader
labels:
app: secret-reader
spec:
selector:
app: secret-reader
ports:
- name: http
port: 80
targetPort: http
-18
View File
@@ -1,18 +0,0 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: llamacpp-config
data:
LLAMA_CACHE: /models
LLAMA_ARG_HOST: 0.0.0.0
LLAMA_ARG_PORT: "8080"
# The model is chosen here: <hf-user>/<repo>:<quant>. It is downloaded into
# /models on first start; changing it restarts the pod (reloader).
#LLAMA_ARG_HF_REPO: "unsloth/Qwen3.8-27B-GGUF:UD-Q6_K"
LLAMA_ARG_HF_REPO: "unsloth/Qwen3.6-35B-A3B-GGUF:UD-Q6_K"
LLAMA_ARG_CTX_SIZE: "131072"
LLAMA_ARG_N_GPU_LAYERS: all
LLAMA_ARG_FLASH_ATTN: "on"
LLAMA_ARG_FIT: "on"
# Strix Halo: mmap-ed weights hang or corrupt on ROCm; read them directly.
LLAMA_ARG_LOAD_MODE: dio
-76
View File
@@ -1,76 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: llamacpp
annotations:
reloader.stakater.com/auto: "true"
labels:
app: llamacpp
spec:
replicas: 1
strategy:
type: Recreate # one GPU, one pod
selector:
matchLabels:
app: llamacpp
template:
metadata:
labels:
app: llamacpp
spec:
dnsPolicy: Default
nodeSelector:
homelab.hexor.cy/pool: core
homelab.hexor.cy/exposure: internal
workload/ai: "true"
tolerations:
- key: workload
operator: Equal
value: ai
effect: NoSchedule
containers:
- name: llamacpp
image: ghcr.io/ggml-org/llama.cpp:server-rocm-b11434
imagePullPolicy: IfNotPresent
envFrom:
- configMapRef:
name: llamacpp-config
env:
- name: HF_TOKEN
valueFrom:
secretKeyRef:
name: llamacpp-hf-token
key: token
optional: true
# hipBLASLt gives the biggest prompt-processing gain on ROCm 7.x.
- name: ROCBLAS_USE_HIPBLASLT
value: "1"
ports:
- name: http
containerPort: 8080
protocol: TCP
resources:
limits:
amd.com/gpu: 1
startupProbe:
httpGet:
path: /health
port: http
failureThreshold: 180 # first start downloads the model
periodSeconds: 10
timeoutSeconds: 5
readinessProbe:
httpGet:
path: /health
port: http
failureThreshold: 3
periodSeconds: 10
timeoutSeconds: 5
volumeMounts:
- name: models
mountPath: /models
volumes:
- name: models
hostPath:
path: /var/lib/llamacpp/models
type: DirectoryOrCreate
-7
View File
@@ -1,7 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- configmap.yaml
- deployment.yaml
- service.yaml
-15
View File
@@ -1,15 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: llamacpp
labels:
app: llamacpp
spec:
type: ClusterIP
selector:
app: llamacpp
ports:
- name: http
port: 8080
targetPort: http
protocol: TCP
-561
View File
@@ -1,561 +0,0 @@
---
apiVersion: v1
kind: ConfigMap
metadata:
name: nginx-config
namespace: minecraft
data:
nginx.conf: |
user nginx;
worker_processes 1;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
server {
listen 80;
# Custom 502 error page with auto-refresh
error_page 502 /502.html;
location = /502.html {
internal;
return 200 '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Server Loading</title><style>body{font-family:Arial,sans-serif;text-align:center;margin-top:100px;background:#f0f0f0}h1{color:#333}p{color:#666;font-size:18px}</style></head><body><h1>Server is loading probably...</h1><p>Please wait a moment and try refreshing the page.</p><script>setTimeout(function(){window.location.reload();}, 10000);</script></body></html>';
add_header Content-Type text/html;
}
# Main location - proxy to Minecraft Dynmap
location / {
# Proxy configuration for Dynmap server
proxy_pass http://localhost:8123;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Inject user authentication meta tag into HTML head
sub_filter '<head>' '<head><meta name="remote-user" content="$http_x_authentik_username">';
# Replace default Dynmap title with custom server name
sub_filter 'Minecraft Dynamic Map' "Hexor's MC server";
# Inject custom script before closing body tag
sub_filter "</body>" '<script src="/clients/inject.js"></script></body>';
# Apply sub_filter replacements globally (not just once)
sub_filter_once off;
}
# Serve inject.js and .ps1 scripts inline (no forced download)
location = /clients/inject.js {
alias /mc/clients/inject.js;
default_type application/javascript;
}
location ~ ^/clients/(.+\.ps1)$ {
alias /mc/clients/$1;
default_type text/plain;
}
# Static file serving for client downloads
location /clients/ {
alias /mc/clients/;
sendfile on; # Enable efficient file serving
add_header Content-Disposition "attachment"; # Force download
autoindex on; # Enable directory listing
gzip off; # Disable compression for downloads
chunked_transfer_encoding off; # Disable chunked encoding
}
}
}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: client-scripts
namespace: minecraft
data:
inject.js: |
(function() {
function getUsername() {
var metas = document.querySelectorAll("meta");
for (var i = 0; i < metas.length; i++) {
if (metas[i].getAttribute("name") === "remote-user") {
return metas[i].getAttribute("content");
}
}
var jwt = document.cookie.split("; ").find(function(row) {
return row.startsWith("authentik_session=");
});
if (jwt) {
try {
var token = jwt.split("=")[1];
var payload = JSON.parse(atob(token.split(".")[1]));
return payload.sub || payload.username || "web-user";
} catch(e) {}
}
return "web-user";
}
var username = getUsername();
console.log("Username found:", username);
if (username && username !== "web-user" &&
window.location.search.indexOf("playername=") === -1) {
var currentUrl = new URL(window.location.href);
currentUrl.searchParams.set("playername", username);
console.log("Redirecting to:", currentUrl.href);
window.location.href = currentUrl.href;
}
document.addEventListener("DOMContentLoaded", function() {
// User block
var userBlock = document.createElement("div");
userBlock.style.cssText = "background-color:#CEC6CB;color:black;padding:8px;text-align:center;font-size:medium;border-radius:4px;position:absolute;top:10px;right:150px;max-width:200px;";
userBlock.innerHTML = "Logged in as: <b>" + username + "</b>";
document.body.appendChild(userBlock);
// Info block
var infoBlock = document.createElement("p");
infoBlock.style.cssText = "background-color:#CEC6CB;color:black;padding:10px;text-align:center;font-size:large;display:inline-block;border-radius:4px;position:absolute;top:10px;left:150px;";
infoBlock.innerHTML = 'GEYMERSKIY SOYUZ Server<br>'
+ 'Get <a href="https://github.com/PrismLauncher/PrismLauncher/releases/tag/8.4">Prism Launcher</a> '
+ 'and <a href="/clients/1.12.2.zip">client.zip</a> for this server. '
+ 'Server address <b>minecraft.hexor.cy:30565</b><br>'
+ 'Requires <a href="https://www.java.com/en/download/manual.jsp">Java 8</a><br><br>'
+ '<a href="#" id="showInstallBtn" style="color:black;text-decoration:underline;">Windows Install Script</a>';
document.body.appendChild(infoBlock);
// Modal
var modal = document.createElement("div");
modal.id = "installModal";
modal.style.cssText = "display:none;position:fixed;z-index:1000;left:0;top:0;width:100%;height:100%;background-color:rgba(0,0,0,0.5);";
modal.innerHTML = '<div style="background-color:#CEC6CB;margin:15% auto;padding:10px;border-radius:4px;width:70%;max-width:500px;text-align:center;color:black;font-size:large;">'
+ '<h3 style="margin-top:0;color:black;">Windows Installation</h3>'
+ '<p style="color:black;">Copy and paste this command into PowerShell:</p>'
+ '<textarea id="scriptCommand" readonly style="width:90%;height:60px;font-family:monospace;padding:8px;border:1px solid #888;border-radius:4px;resize:none;background-color:white;color:black;"></textarea>'
+ '<br><br>'
+ '<button id="copyButton" style="background-color:#CEC6CB;color:black;padding:10px 15px;border:1px solid #888;border-radius:4px;cursor:pointer;margin-right:10px;font-size:large;">Copy</button>'
+ '<button id="closeButton" style="background-color:#CEC6CB;color:black;padding:10px 15px;border:1px solid #888;border-radius:4px;cursor:pointer;font-size:large;">Close</button>'
+ '</div>';
document.body.appendChild(modal);
// Generate PowerShell command with username
function buildPsCommand() {
var d = "$";
var q = "'";
return d + 'f="' + d + 'env:TEMP\\mc-install.ps1"; iwr -useb https://minecraft.hexor.cy/clients/win-install.ps1 -OutFile '
+ d + 'f; powershell -ExecutionPolicy Bypass -File ' + d + 'f -Username ' + q + username + q + '; Remove-Item ' + d + 'f';
}
document.getElementById("showInstallBtn").addEventListener("click", function(e) {
e.preventDefault();
modal.style.display = "block";
document.getElementById("scriptCommand").value = buildPsCommand();
});
document.getElementById("closeButton").addEventListener("click", function() {
modal.style.display = "none";
});
document.getElementById("copyButton").addEventListener("click", function() {
var textarea = document.getElementById("scriptCommand");
textarea.select();
textarea.setSelectionRange(0, 99999);
document.execCommand("copy");
var btn = document.getElementById("copyButton");
btn.style.borderColor = "#4CAF50";
setTimeout(function() { btn.style.borderColor = "#888"; }, 2000);
});
modal.addEventListener("click", function(event) {
if (event.target === modal) {
modal.style.display = "none";
}
});
});
})();
win-install.ps1: |
# Game Setup Script for PrismLauncher and Minecraft Client
# This script downloads and configures PrismLauncher with Hexor client
param(
[string]$Username = "",
[string]$InstallPath = "$env:USERPROFILE\Games\PrismLauncher"
)
# Enable TLS 1.2 for downloads
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
# Function to write colored log messages
function Write-Log {
param(
[string]$Message,
[string]$Level = "INFO"
)
switch ($Level) {
"INFO" {
Write-Host "[" -NoNewline
Write-Host "INFO" -ForegroundColor Blue -NoNewline
Write-Host "] $Message"
}
"WARN" {
Write-Host "[" -NoNewline
Write-Host "WARN" -ForegroundColor Yellow -NoNewline
Write-Host "] $Message"
}
"ERROR" {
Write-Host "[" -NoNewline
Write-Host "ERROR" -ForegroundColor Red -NoNewline
Write-Host "] $Message"
}
"SUCCESS" {
Write-Host "[" -NoNewline
Write-Host "SUCCESS" -ForegroundColor Green -NoNewline
Write-Host "] $Message"
}
}
}
# Function to download file with progress
function Download-File {
param(
[string]$Url,
[string]$OutputPath
)
try {
Write-Log "Downloading from: $Url"
Write-Log "Saving to: $OutputPath"
if (Test-Path $OutputPath) {
Remove-Item $OutputPath -Force
Write-Log "Removed existing file to avoid corruption" "WARN"
}
$webClient = New-Object System.Net.WebClient
$webClient.DownloadFile($Url, $OutputPath)
if (Test-Path $OutputPath) {
$fileSize = (Get-Item $OutputPath).Length
Write-Log "Download completed successfully ($fileSize bytes)" "SUCCESS"
return $true
} else {
Write-Log "Download failed - file not found" "ERROR"
return $false
}
}
catch {
Write-Log "Download failed: $($_.Exception.Message)" "ERROR"
return $false
}
}
# Function to extract ZIP archive
function Extract-Archive {
param(
[string]$ArchivePath,
[string]$DestinationPath
)
try {
Write-Log "Extracting archive: $ArchivePath"
Write-Log "Destination: $DestinationPath"
if (!(Test-Path $ArchivePath)) {
Write-Log "Archive file not found: $ArchivePath" "ERROR"
return $false
}
$fileSize = (Get-Item $ArchivePath).Length
if ($fileSize -lt 1000) {
Write-Log "Archive file too small ($fileSize bytes), probably corrupted" "ERROR"
return $false
}
if (!(Test-Path $DestinationPath)) {
New-Item -ItemType Directory -Path $DestinationPath -Force | Out-Null
}
$existingFiles = Get-ChildItem -Path $DestinationPath -File | Where-Object { $_.Extension -ne ".zip" }
$existingDirs = Get-ChildItem -Path $DestinationPath -Directory
if ($existingFiles.Count -gt 0 -or $existingDirs.Count -gt 0) {
Write-Log "Clearing existing files in destination directory" "WARN"
$existingFiles | Remove-Item -Force -ErrorAction SilentlyContinue
$existingDirs | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
}
Add-Type -AssemblyName System.IO.Compression.FileSystem
try {
$zip = [System.IO.Compression.ZipFile]::OpenRead($ArchivePath)
$entryCount = $zip.Entries.Count
$zip.Dispose()
Write-Log "ZIP file validated ($entryCount entries)" "SUCCESS"
}
catch {
Write-Log "ZIP file validation failed: $($_.Exception.Message)" "ERROR"
return $false
}
[System.IO.Compression.ZipFile]::ExtractToDirectory($ArchivePath, $DestinationPath)
Write-Log "Archive extracted successfully" "SUCCESS"
return $true
}
catch {
Write-Log "Archive extraction failed: $($_.Exception.Message)" "ERROR"
if (Test-Path $ArchivePath) {
Write-Log "Removing potentially corrupted archive file" "WARN"
Remove-Item $ArchivePath -Force -ErrorAction SilentlyContinue
}
return $false
}
}
# Function to check Java installation
function Check-JavaInstallation {
try {
$javaVersion = java -version 2>&1 | Select-String "version"
if ($javaVersion) {
Write-Log "Java is installed: $($javaVersion.ToString().Trim())" "SUCCESS"
return $true
}
}
catch {
Write-Log "Java is not installed or not in PATH" "WARN"
Write-Log "Please download Java from: https://www.java.com/en/download/manual.jsp" "WARN"
Write-Log "Look for 'Windows Offline (64-bit)' version" "WARN"
$response = Read-Host "Do you want to continue without Java? (y/n)"
if ($response -eq 'y' -or $response -eq 'Y') {
Write-Log "Continuing without Java verification" "WARN"
return $true
} else {
Write-Log "Installation cancelled. Please install Java first." "ERROR"
return $false
}
}
}
# Function to generate a random hex string (UUID-like without dashes)
function New-RandomHexId {
param([int]$Length = 32)
$bytes = New-Object byte[] ($Length / 2)
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
$rng.GetBytes($bytes)
$rng.Dispose()
return ($bytes | ForEach-Object { $_.ToString("x2") }) -join ''
}
# Function to create accounts.json file with offline account
function Create-AccountsFile {
param(
[string]$TargetPath,
[string]$PlayerName
)
$accountsPath = Join-Path $TargetPath "accounts.json"
$profileId = New-RandomHexId -Length 32
$clientToken = New-RandomHexId -Length 32
$iat = [int][double]::Parse((Get-Date -UFormat %s))
$accountsObj = @{
accounts = @(
@{
entitlement = @{
canPlayMinecraft = $true
ownsMinecraft = $true
}
"msa-client-id" = ""
type = "MSA"
},
@{
active = $true
entitlement = @{
canPlayMinecraft = $true
ownsMinecraft = $true
}
profile = @{
capes = @()
id = $profileId
name = $PlayerName
skin = @{
id = ""
url = ""
variant = ""
}
}
type = "Offline"
ygg = @{
extra = @{
clientToken = $clientToken
userName = $PlayerName
}
iat = $iat
token = "0"
}
}
)
formatVersion = 3
}
try {
$accountsObj | ConvertTo-Json -Depth 10 | Out-File -FilePath $accountsPath -Encoding UTF8
Write-Log "Created accounts.json at: $accountsPath" "SUCCESS"
Write-Log "Player name: $PlayerName" "SUCCESS"
return $true
}
catch {
Write-Log "Failed to create accounts.json: $($_.Exception.Message)" "ERROR"
return $false
}
}
# Main installation process
Write-Log "Starting PrismLauncher and Minecraft client setup"
Write-Log "Player name: $Username"
Write-Log "Installation path: $InstallPath"
# Create installation directory and clear if exists (but keep ZIP files)
if (Test-Path $InstallPath) {
Write-Log "Installation directory exists, clearing contents" "WARN"
$existingFiles = Get-ChildItem -Path $InstallPath -File | Where-Object { $_.Extension -ne ".zip" }
$existingDirs = Get-ChildItem -Path $InstallPath -Directory
$existingFiles | Remove-Item -Force -ErrorAction SilentlyContinue
$existingDirs | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
} else {
New-Item -ItemType Directory -Path $InstallPath -Force | Out-Null
Write-Log "Created installation directory: $InstallPath"
}
# Step 1: Download PrismLauncher
Write-Log "Step 1: Downloading PrismLauncher..."
$launcherUrl = "https://github.com/PrismLauncher/PrismLauncher/releases/download/8.4/PrismLauncher-Windows-MSVC-Portable-8.4.zip"
$launcherZip = Join-Path $InstallPath "PrismLauncher-8.4.zip"
$launcherExtractPath = Join-Path $InstallPath "PrismLauncher-Windows-MSVC-Portable-8.4"
if (!(Download-File -Url $launcherUrl -OutputPath $launcherZip)) {
Write-Log "Failed to download PrismLauncher. Exiting." "ERROR"
exit 1
}
# Step 2: Extract PrismLauncher
Write-Log "Step 2: Extracting PrismLauncher..."
if (!(Extract-Archive -ArchivePath $launcherZip -DestinationPath $InstallPath)) {
Write-Log "Failed to extract PrismLauncher. Exiting." "ERROR"
exit 1
}
# Find the actual extracted directory
$extractedDirs = Get-ChildItem -Path $InstallPath -Directory | Where-Object { $_.Name -like "*PrismLauncher*" }
if ($extractedDirs.Count -gt 0) {
$launcherExtractPath = $extractedDirs[0].FullName
Write-Log "Found PrismLauncher directory: $launcherExtractPath" "SUCCESS"
} else {
Write-Log "Could not find extracted PrismLauncher directory. Checking for direct extraction..." "WARN"
$prismExe = Join-Path $InstallPath "prismlauncher.exe"
if (Test-Path $prismExe) {
$launcherExtractPath = $InstallPath
Write-Log "PrismLauncher extracted directly to: $launcherExtractPath" "SUCCESS"
} else {
Write-Log "Failed to locate PrismLauncher files. Exiting." "ERROR"
exit 1
}
}
# Step 3: Create accounts.json in InstallPath (only if username provided)
if ($Username -and $Username -ne "web-user") {
Write-Log "Step 3: Creating accounts configuration for $Username..."
if (!(Create-AccountsFile -TargetPath $InstallPath -PlayerName $Username)) {
Write-Log "Failed to create accounts.json. Exiting." "ERROR"
exit 1
}
} else {
Write-Log "Step 3: No username provided, skipping accounts.json creation" "WARN"
}
# Step 4: Check Java installation
Write-Log "Step 4: Checking Java installation..."
if (!(Check-JavaInstallation)) {
Write-Log "Java check failed. Exiting." "ERROR"
exit 1
}
# Step 5: Download Minecraft client
Write-Log "Step 5: Downloading Minecraft client..."
$minecraftUrl = "https://minecraft.hexor.cy/clients/1.12.2.zip"
$minecraftZip = Join-Path $InstallPath "minecraft-1.12.2.zip"
$instancesPath = Join-Path $launcherExtractPath "instances"
$hexorPath = Join-Path $instancesPath "Hexor"
if (!(Download-File -Url $minecraftUrl -OutputPath $minecraftZip)) {
Write-Log "Failed to download Minecraft client. Exiting." "ERROR"
exit 1
}
# Step 6: Create instances directory and extract Minecraft client
Write-Log "Step 6: Setting up Minecraft client..."
if (!(Test-Path $instancesPath)) {
New-Item -ItemType Directory -Path $instancesPath -Force | Out-Null
Write-Log "Created instances directory"
}
if (!(Test-Path $hexorPath)) {
New-Item -ItemType Directory -Path $hexorPath -Force | Out-Null
Write-Log "Created Hexor instance directory"
}
if (!(Extract-Archive -ArchivePath $minecraftZip -DestinationPath $hexorPath)) {
Write-Log "Failed to extract Minecraft client. Exiting." "ERROR"
exit 1
}
# Step 7: Cleanup temporary files
Write-Log "Step 7: Cleaning up temporary files..."
try {
Remove-Item $launcherZip -Force
Remove-Item $minecraftZip -Force
Write-Log "Temporary files cleaned up" "SUCCESS"
}
catch {
Write-Log "Could not remove temporary files: $($_.Exception.Message)" "WARN"
}
# Final success message
Write-Log "=== INSTALLATION COMPLETED SUCCESSFULLY ===" "SUCCESS"
Write-Log "PrismLauncher location: $launcherExtractPath" "SUCCESS"
Write-Log "Executable: $(Join-Path $launcherExtractPath 'prismlauncher.exe')" "SUCCESS"
Write-Log "Minecraft client installed in: $hexorPath" "SUCCESS"
if ($Username -and $Username -ne "web-user") {
Write-Log "Player name configured: $Username" "SUCCESS"
Write-Log "Accounts file: $(Join-Path $InstallPath 'accounts.json')" "SUCCESS"
}
Write-Log ""
Write-Log "You can now run PrismLauncher and the Hexor instance should be available!"
Write-Log "If Java was not installed, please download it from: https://www.java.com/en/download/manual.jsp"
# Ask if user wants to launch the game
$launchResponse = Read-Host "Do you want to launch PrismLauncher now? (y/n)"
if ($launchResponse -eq 'y' -or $launchResponse -eq 'Y') {
$launcherExe = Join-Path $launcherExtractPath "prismlauncher.exe"
if (Test-Path $launcherExe) {
Write-Log "Launching PrismLauncher..." "INFO"
Start-Process -FilePath $launcherExe -WorkingDirectory $launcherExtractPath
} else {
Write-Log "Launcher executable not found!" "ERROR"
}
}
-137
View File
@@ -1,137 +0,0 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: minecraft
namespace: minecraft
labels:
app: minecraft
spec:
selector:
matchLabels:
app: minecraft
replicas: 1
strategy:
type: Recreate
template:
metadata:
labels:
app: minecraft
spec:
nodeSelector:
kubernetes.io/hostname: music
volumes:
- name: storage
hostPath:
path: /data/k8s/mc-server/
type: Directory
- name: nginx-config
configMap:
name: nginx-config
- name: client-scripts
configMap:
name: client-scripts
terminationGracePeriodSeconds: 180
containers:
- name: minecraft
image: eclipse-temurin:8-jdk-ubi10-minimal@sha256:85c943a7093a276b0f6ae13dee2ffecb98e390ab6a32fc7e4b8b1fc6bb103dd7
command: ["java"]
args:
- -Xms4G
- -Xmx4G
- -XX:+UseG1GC
- -XX:+ParallelRefProcEnabled
- -XX:MaxGCPauseMillis=200
- -XX:+UnlockExperimentalVMOptions
- -XX:+DisableExplicitGC
- -XX:+AlwaysPreTouch
- -XX:G1NewSizePercent=30
- -XX:G1MaxNewSizePercent=40
- -XX:G1HeapRegionSize=8M
- -XX:G1ReservePercent=20
- -XX:G1HeapWastePercent=5
- -XX:G1MixedGCCountTarget=4
- -XX:InitiatingHeapOccupancyPercent=15
- -XX:G1MixedGCLiveThresholdPercent=90
- -XX:G1RSetUpdatingPauseTimePercent=5
- -XX:SurvivorRatio=32
- -XX:+PerfDisableSharedMem
- -XX:MaxTenuringThreshold=1
- -jar
- forge-1.12.2-14.23.5.2854.jar
- nogui
workingDir: /mc/
resources:
requests:
memory: "8Gi"
cpu: "2000m"
limits:
memory: "12Gi"
cpu: "4000m"
ports:
- name: game
containerPort: 25565
protocol: TCP
- name: dynmap
containerPort: 8123
protocol: TCP
- name: webstatus-mod
containerPort: 8080
protocol: TCP
startupProbe:
tcpSocket:
port: game
periodSeconds: 10
failureThreshold: 90
livenessProbe:
tcpSocket:
port: game
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
tcpSocket:
port: game
periodSeconds: 10
timeoutSeconds: 5
volumeMounts:
- name: storage
mountPath: /mc
- name: nginx
image: nginx@sha256:f9ea18bfa4fad859e1ed38259d711da7ccad2c3516e875cec3351a57c859f571
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "256Mi"
cpu: "200m"
ports:
- containerPort: 80
volumeMounts:
- name: nginx-config
mountPath: /etc/nginx/nginx.conf
subPath: nginx.conf
- name: storage
mountPath: /mc
- name: client-scripts
mountPath: /mc/clients/win-install.ps1
subPath: win-install.ps1
- name: client-scripts
mountPath: /mc/clients/inject.js
subPath: inject.js
---
apiVersion: v1
kind: Service
metadata:
name: minecraft-exporter
namespace: minecraft
spec:
selector:
app: minecraft
ports:
- protocol: TCP
port: 19565
targetPort: 19565
-24
View File
@@ -1,24 +0,0 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: minecraft
annotations:
cert-manager.io/cluster-issuer: letsencrypt
traefik.ingress.kubernetes.io/router.entrypoints: websecure
spec:
ingressClassName: traefik
rules:
- host: minecraft.hexor.cy
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: minecraft-dynmap
port:
number: 80
tls:
- secretName: minecraft-tls
hosts:
- minecraft.hexor.cy
-8
View File
@@ -1,8 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: minecraft
resources:
- deployments.yaml
- configmaps.yaml
- services.yaml
- ingress.yaml
-39
View File
@@ -1,39 +0,0 @@
---
apiVersion: v1
kind: Service
metadata:
name: minecraft-dynmap
spec:
selector:
app: minecraft
ports:
- protocol: TCP
port: 80
targetPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: minecraft-webstatus-mod
spec:
selector:
app: minecraft
ports:
- protocol: TCP
port: 80
targetPort: 8080
---
apiVersion: v1
kind: Service
metadata:
name: minecraft-game
spec:
type: LoadBalancer
externalTrafficPolicy: Cluster
ports:
- port: 30565
targetPort: 25565
selector:
app: minecraft
-23
View File
@@ -1,23 +0,0 @@
# MTProxy
Argo CD deploys this directory through the `mtproxy` entry in
`core-state/values.yaml`. Two telemt DaemonSets run on every node labeled
`homelab.hexor.cy/exposure=external`: classic MTProxy on 30443/TCP and
fake-TLS on 30444/TCP. The classic listener replaces the old amd64-only
`telegrammessenger/proxy` image so the arm64 `spb` node is covered.
Vaultwarden item `k8s/mtproxy/secret` must contain a custom field named
`Secret` with the existing 32-character hexadecimal MTProxy secret. External
Secrets renders both configurations from that field. Reloader restarts the
DaemonSets when it changes. Changing this value invalidates existing links.
`mtproxy-link-reconciler` lists external nodes every five minutes and creates
`mtproxy-links` and `telemt-links` Secrets. A node's public DNS name must be
`<node>.hexor.cy` and must point to that node. The reader at `proxy.hexor.cy`
has read-only access to those two Secrets and is protected by Keycloak's
forward auth; its access policy is in `terraform/keycloak/terraform.tfvars`.
The proxy Pods have no Kubernetes API access.
Before traffic is switched over, allow inbound 30443/TCP and 30444/TCP on
every external node and verify both DNS and connectivity on each node.
Neither the cluster nor host firewall is changed by these manifests.
-149
View File
@@ -1,149 +0,0 @@
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: mtproxy-classic
labels:
app: mtproxy-classic
spec:
selector:
matchLabels:
app: mtproxy-classic
template:
metadata:
labels:
app: mtproxy-classic
annotations:
reloader.stakater.com/auto: "true"
spec:
nodeSelector:
homelab.hexor.cy/exposure: external
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: telemt
image: ghcr.io/telemt/telemt@sha256:b311c20596213a3941dcd11f499be982de74f554eecfffe57fab51c1bf80d50a
imagePullPolicy: IfNotPresent
workingDir: /run/telemt
args: ["/app/config.toml"]
ports:
- name: proxy
containerPort: 30443
hostPort: 30443
protocol: TCP
- name: metrics
containerPort: 9090
protocol: TCP
readinessProbe:
tcpSocket:
port: proxy
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 256Mi
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: config
mountPath: /app/config.toml
subPath: classic.toml
readOnly: true
- name: workdir
mountPath: /run/telemt
volumes:
- name: config
secret:
secretName: mtproxy-config
defaultMode: 0444
items:
- key: classic.toml
path: classic.toml
- name: workdir
emptyDir:
medium: Memory
sizeLimit: 4Mi
---
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: mtproxy-tls
labels:
app: mtproxy-tls
spec:
selector:
matchLabels:
app: mtproxy-tls
template:
metadata:
labels:
app: mtproxy-tls
annotations:
reloader.stakater.com/auto: "true"
spec:
nodeSelector:
homelab.hexor.cy/exposure: external
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: telemt
image: ghcr.io/telemt/telemt@sha256:b311c20596213a3941dcd11f499be982de74f554eecfffe57fab51c1bf80d50a
imagePullPolicy: IfNotPresent
workingDir: /run/telemt
args: ["/app/config.toml"]
ports:
- name: proxy
containerPort: 30444
hostPort: 30444
protocol: TCP
- name: metrics
containerPort: 9090
protocol: TCP
readinessProbe:
tcpSocket:
port: proxy
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 256Mi
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: config
mountPath: /app/config.toml
subPath: tls.toml
readOnly: true
- name: workdir
mountPath: /run/telemt
volumes:
- name: config
secret:
secretName: mtproxy-config
defaultMode: 0444
items:
- key: tls.toml
path: tls.toml
- name: workdir
emptyDir:
medium: Memory
sizeLimit: 4Mi
-74
View File
@@ -1,74 +0,0 @@
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: mtproxy-config
spec:
refreshInterval: 1h
secretStoreRef:
name: vaultwarden-fields
kind: ClusterSecretStore
target:
name: mtproxy-config
creationPolicy: Owner
deletionPolicy: Retain
template:
type: Opaque
data:
SECRET: "{{ .secret }}"
classic.toml: |
[general]
use_middle_proxy = true
log_level = "normal"
[general.modes]
classic = true
secure = false
tls = false
[server]
port = 30443
metrics_port = 9090
metrics_whitelist = ["0.0.0.0/0"]
[server.api]
enabled = false
[[server.listeners]]
ip = "0.0.0.0"
[access.users]
user = "{{ .secret }}"
tls.toml: |
[general]
use_middle_proxy = true
log_level = "normal"
[general.modes]
classic = false
secure = false
tls = true
[server]
port = 30444
metrics_port = 9090
metrics_whitelist = ["0.0.0.0/0"]
[server.api]
enabled = false
[[server.listeners]]
ip = "0.0.0.0"
[censorship]
tls_domain = "ya.ru"
mask = true
tls_emulation = true
tls_front_dir = "tlsfront"
[access.users]
user = "{{ .secret }}"
data:
- secretKey: secret
remoteRef:
key: k8s/mtproxy/secret
property: Secret
-38
View File
@@ -1,38 +0,0 @@
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: mtproxy-auth
spec:
forwardAuth:
address: http://auth-proxy.keycloak.svc:80/auth
trustForwardHeader: false
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: mtproxy-links
spec:
entryPoints:
- websecure
routes:
- match: Host(`proxy.hexor.cy`)
kind: Rule
middlewares:
- name: mtproxy-auth
services:
- name: mtproxy-link-reader
port: 80
tls:
secretName: mtproxy-links-tls
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: mtproxy-links-tls
spec:
secretName: mtproxy-links-tls
issuerRef:
name: letsencrypt
kind: ClusterIssuer
dnsNames:
- proxy.hexor.cy
-16
View File
@@ -1,16 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: mtproxy
resources:
- external-secrets.yaml
- daemonsets.yaml
- metrics.yaml
- links.yaml
- reader.yaml
- ingress.yaml
configMapGenerator:
- name: mtproxy-link-reconciler
files:
- reconcile.py=link-reconciler.py
generatorOptions:
disableNameSuffixHash: true
-75
View File
@@ -1,75 +0,0 @@
"""Publish client links for every external node, using its public DNS name."""
import base64
import json
import urllib.error
import urllib.parse
import urllib.request
API = "https://kubernetes.default.svc"
NAMESPACE = open("/var/run/secrets/kubernetes.io/serviceaccount/namespace", encoding="utf-8").read().strip()
TOKEN = open("/var/run/secrets/kubernetes.io/serviceaccount/token", encoding="utf-8").read().strip()
CA = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
def request(method, path, body=None):
import ssl
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
API + path,
data=data,
method=method,
headers={
"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(req, context=ssl.create_default_context(cafile=CA), timeout=15) as response:
return json.load(response)
def publish(name, links):
path = f"/api/v1/namespaces/{NAMESPACE}/secrets"
data = {node: base64.b64encode(link.encode()).decode() for node, link in links.items()}
try:
current = request("GET", f"{path}/{name}")
except urllib.error.HTTPError as error:
if error.code != 404:
raise
request("POST", path, {"apiVersion": "v1", "kind": "Secret", "metadata": {"name": name}, "type": "Opaque", "data": data})
return
if current.get("data", {}) == data:
return
# PUT replaces the data map, removing links for nodes that are no longer external.
request("PUT", f"{path}/{name}", {
"apiVersion": "v1",
"kind": "Secret",
"metadata": {"name": name, "resourceVersion": current["metadata"]["resourceVersion"]},
"type": "Opaque",
"data": data,
})
secret = open("/config/SECRET", encoding="utf-8").read().strip()
if len(secret) != 32 or any(char not in "0123456789abcdefABCDEF" for char in secret):
raise ValueError("Vaultwarden Secret must be 32 hexadecimal characters")
selector = urllib.parse.quote("homelab.hexor.cy/exposure=external", safe="")
nodes = request("GET", f"/api/v1/nodes?labelSelector={selector}")["items"]
if not nodes:
raise ValueError("No external nodes found")
classic = {}
tls = {}
for node in nodes:
name = node["metadata"]["name"]
# DNS is maintained outside the cluster; no public IP needs to be stored on a Node.
host = f"{name}.hexor.cy"
classic[name] = f"tg://proxy?server={host}&port=30443&secret={secret}"
tls[name] = f"tg://proxy?server={host}&port=30444&secret=ee{secret}79612e7275"
publish("mtproxy-links", classic)
publish("telemt-links", tls)
print(f"Published MTProxy links for {len(nodes)} external nodes")
-108
View File
@@ -1,108 +0,0 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: mtproxy-link-reconciler
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: mtproxy-external-node-reader
rules:
- apiGroups: [""]
resources: ["nodes"]
verbs: ["list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: mtproxy-external-node-reader
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: mtproxy-external-node-reader
subjects:
- kind: ServiceAccount
name: mtproxy-link-reconciler
namespace: mtproxy
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: mtproxy-link-writer
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["create"]
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["mtproxy-links", "telemt-links"]
verbs: ["get", "update"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: mtproxy-link-writer
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: mtproxy-link-writer
subjects:
- kind: ServiceAccount
name: mtproxy-link-reconciler
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: mtproxy-link-reconciler
spec:
schedule: "*/5 * * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 2
jobTemplate:
spec:
backoffLimit: 2
template:
spec:
serviceAccountName: mtproxy-link-reconciler
restartPolicy: OnFailure
nodeSelector:
homelab.hexor.cy/exposure: external
securityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: reconcile
image: python:3.13-alpine3.22@sha256:e81548ac35b07a3bd4805f275107592ef458b1e893c0e04d45aedaa19416cca5
command: ["python", "/scripts/reconcile.py"]
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 128Mi
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: scripts
mountPath: /scripts
readOnly: true
- name: config
mountPath: /config
readOnly: true
volumes:
- name: scripts
configMap:
name: mtproxy-link-reconciler
- name: config
secret:
secretName: mtproxy-config
items:
- key: SECRET
path: SECRET
-65
View File
@@ -1,65 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: mtproxy-classic-metrics
labels:
app: mtproxy-classic
spec:
clusterIP: None
selector:
app: mtproxy-classic
ports:
- name: metrics
port: 9090
targetPort: metrics
---
apiVersion: v1
kind: Service
metadata:
name: mtproxy-tls-metrics
labels:
app: mtproxy-tls
spec:
clusterIP: None
selector:
app: mtproxy-tls
ports:
- name: metrics
port: 9090
targetPort: metrics
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: mtproxy-classic
spec:
selector:
matchLabels:
app: mtproxy-classic
endpoints:
- port: metrics
path: /metrics
interval: 30s
scrapeTimeout: 10s
relabelings:
- action: replace
sourceLabels: [__meta_kubernetes_pod_node_name]
targetLabel: node
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: mtproxy-tls
spec:
selector:
matchLabels:
app: mtproxy-tls
endpoints:
- port: metrics
path: /metrics
interval: 30s
scrapeTimeout: 10s
relabelings:
- action: replace
sourceLabels: [__meta_kubernetes_pod_node_name]
targetLabel: node
-96
View File
@@ -1,96 +0,0 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: mtproxy-link-reader
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: mtproxy-link-reader
rules:
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["mtproxy-links", "telemt-links"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: mtproxy-link-reader
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: mtproxy-link-reader
subjects:
- kind: ServiceAccount
name: mtproxy-link-reader
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: mtproxy-link-reader
labels:
app: mtproxy-link-reader
spec:
replicas: 1
selector:
matchLabels:
app: mtproxy-link-reader
template:
metadata:
labels:
app: mtproxy-link-reader
spec:
serviceAccountName: mtproxy-link-reader
nodeSelector:
homelab.hexor.cy/exposure: external
securityContext:
runAsNonRoot: true
runAsUser: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: reader
image: ultradesu/k8s-secrets:0.2.2@sha256:f206942d21ca9dcd131d6e19ba26cc17671815919217aef657b30a9f85854778
imagePullPolicy: IfNotPresent
args:
- --secrets
- mtproxy-links,telemt-links
- --namespace
- mtproxy
- --port
- "3000"
ports:
- name: http
containerPort: 3000
readinessProbe:
httpGet:
path: /health
port: http
livenessProbe:
httpGet:
path: /health
port: http
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 128Mi
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
---
apiVersion: v1
kind: Service
metadata:
name: mtproxy-link-reader
spec:
selector:
app: mtproxy-link-reader
ports:
- name: http
port: 80
targetPort: http
-151
View File
@@ -1,151 +0,0 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: rustdesk-hbbs
annotations:
reloader.stakater.com/auto: "true"
labels:
app: rustdesk-hbbs
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: rustdesk-hbbs
template:
metadata:
labels:
app: rustdesk-hbbs
spec:
nodeSelector:
kubernetes.io/hostname: music
containers:
- name: hbbs
image: rustdesk/rustdesk-server@sha256:8ecdab65deb7c84652a626380e31d11a8f1fbafd97916d57f95c20628f943c00
imagePullPolicy: Always
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "750m"
command: ["hbbs"]
args:
- "--relay-servers"
- "rd.hexor.cy:21117"
- "--port"
- "21116"
ports:
- name: registry
containerPort: 21116
protocol: TCP
- name: registry-udp
containerPort: 21116
protocol: UDP
- name: nat
containerPort: 21115
protocol: TCP
readinessProbe:
tcpSocket:
port: registry
volumeMounts:
- name: keys
mountPath: /data
readOnly: true
- name: data
mountPath: /data-persistent
env:
- name: RUST_LOG
value: "info"
- name: DB_URL
value: "/data-persistent/db_v2.sqlite3"
workingDir: /data
volumes:
- name: keys
secret:
secretName: rustdesk-keys
items:
- key: id_ed25519
path: id_ed25519
mode: 0600
- key: id_ed25519.pub
path: id_ed25519.pub
mode: 0644
- name: data
hostPath:
path: /data/k8s/rustdesk/hbbs
type: Directory
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: rustdesk-hbbr
annotations:
reloader.stakater.com/auto: "true"
labels:
app: rustdesk-hbbr
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: rustdesk-hbbr
template:
metadata:
labels:
app: rustdesk-hbbr
spec:
nodeSelector:
kubernetes.io/hostname: music
containers:
- name: hbbr
image: rustdesk/rustdesk-server@sha256:8ecdab65deb7c84652a626380e31d11a8f1fbafd97916d57f95c20628f943c00
imagePullPolicy: Always
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "750m"
command: ["hbbr"]
args:
- "--port"
- "21117"
ports:
- name: relay
containerPort: 21117
protocol: TCP
readinessProbe:
tcpSocket:
port: relay
volumeMounts:
- name: keys
mountPath: /data
readOnly: true
- name: data
mountPath: /data-persistent
env:
- name: RUST_LOG
value: "info"
workingDir: /data
volumes:
- name: keys
secret:
secretName: rustdesk-keys
items:
- key: id_ed25519
path: id_ed25519
mode: 0600
- key: id_ed25519.pub
path: id_ed25519.pub
mode: 0644
- name: data
hostPath:
path: /data/k8s/rustdesk/hbbr
type: Directory
@@ -1,27 +0,0 @@
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: rustdesk-keys
spec:
refreshInterval: 1h
secretStoreRef:
name: vaultwarden-fields
kind: ClusterSecretStore
target:
name: rustdesk-keys
creationPolicy: Owner
deletionPolicy: Retain
template:
type: Opaque
data:
id_ed25519: "{{ .private_key }}"
id_ed25519.pub: "{{ .public_key }}"
data:
- secretKey: private_key
remoteRef:
key: k8s/rustdesk/secrets
property: Cert
- secretKey: public_key
remoteRef:
key: k8s/rustdesk/secrets
property: Key
-8
View File
@@ -1,8 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: rustdesk
resources:
- external-secrets.yaml
- deployment.yaml
- service.yaml
- network-policy.yaml
-73
View File
@@ -1,73 +0,0 @@
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: rustdesk-network-policy
spec:
podSelector:
matchLabels:
app: rustdesk-hbbs
policyTypes:
- Ingress
- Egress
ingress:
# Allow all incoming connections to RustDesk ports
- from: []
ports:
- protocol: TCP
port: 21115
- protocol: TCP
port: 21116
- protocol: UDP
port: 21116
egress:
# Allow DNS
- to: []
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
# Allow communication between HBBS and HBBR
- to:
- podSelector:
matchLabels:
app: rustdesk-hbbr
ports:
- protocol: TCP
port: 21117
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: rustdesk-hbbr-network-policy
spec:
podSelector:
matchLabels:
app: rustdesk-hbbr
policyTypes:
- Ingress
- Egress
ingress:
# Allow all incoming connections to relay port
- from: []
ports:
- protocol: TCP
port: 21117
# Allow connections from HBBS
- from:
- podSelector:
matchLabels:
app: rustdesk-hbbs
ports:
- protocol: TCP
port: 21117
egress:
# Allow DNS
- to: []
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
-59
View File
@@ -1,59 +0,0 @@
# Preserve client source IPs for RustDesk NAT detection. Public DNS points to music,
# where both servers and their persistent data live.
---
apiVersion: v1
kind: Service
metadata:
name: rustdesk-hbbs
labels:
app: rustdesk-hbbs
spec:
type: LoadBalancer
externalTrafficPolicy: Local
selector:
app: rustdesk-hbbs
ports:
- name: registry-tcp
port: 21116
targetPort: 21116
protocol: TCP
- name: nat
port: 21115
targetPort: 21115
protocol: TCP
---
apiVersion: v1
kind: Service
metadata:
name: rustdesk-hbbs-udp
labels:
app: rustdesk-hbbs
spec:
type: LoadBalancer
externalTrafficPolicy: Local
selector:
app: rustdesk-hbbs
ports:
- name: registry-udp
port: 21116
targetPort: 21116
protocol: UDP
---
apiVersion: v1
kind: Service
metadata:
name: rustdesk-hbbr
labels:
app: rustdesk-hbbr
spec:
type: LoadBalancer
externalTrafficPolicy: Local
selector:
app: rustdesk-hbbr
ports:
- name: relay
port: 21117
targetPort: 21117
protocol: TCP
-18
View File
@@ -1,18 +0,0 @@
# Shadowsocks on spb
One shadowsocks-rust pod runs on `spb`, exposing `8388/TCP` and `8388/UDP`
through host ports. Client settings:
- Server: `spb.hexor.cy`
- Port: `8388`
- Method: `chacha20-ietf-poly1305`
- Password: the `Password` custom field of the Vaultwarden item named
`k8s/shadowsocks/secrets` (exact name, not an item UUID).
Create that item in the server secrets vault accessible to the External Secrets
bot. Use a long random password, for example generated with `openssl rand -base64 32`.
External Secrets creates `shadowsocks-config`; Reloader restarts the deployment
when the Secret changes. No persistent storage is required.
The spb NixOS configuration opens port 8388 for both protocols. Apply that
configuration when deploying this application for the first time.
-71
View File
@@ -1,71 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: shadowsocks
annotations:
reloader.stakater.com/auto: "true"
labels:
app: shadowsocks
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: shadowsocks
template:
metadata:
labels:
app: shadowsocks
spec:
nodeSelector:
kubernetes.io/hostname: spb
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: shadowsocks
# shadowsocks-rust v1.25.0; multi-arch image includes linux/arm64.
image: ghcr.io/shadowsocks/ssserver-rust:v1.25.0@sha256:22240c07d8ce3a36421ede770bd775dcc5fc56d5d829943196957b05452dc36f
imagePullPolicy: IfNotPresent
command: ["/usr/bin/ssserver"]
args:
- --log-without-time
- -c
- /etc/shadowsocks-rust/config.json
ports:
- name: proxy-tcp
containerPort: 8388
hostPort: 8388
protocol: TCP
- name: proxy-udp
containerPort: 8388
hostPort: 8388
protocol: UDP
readinessProbe:
tcpSocket:
port: proxy-tcp
resources:
requests:
cpu: 50m
memory: 32Mi
limits:
memory: 256Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: config
mountPath: /etc/shadowsocks-rust
readOnly: true
volumes:
- name: config
secret:
secretName: shadowsocks-config
defaultMode: 0444
@@ -1,29 +0,0 @@
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: shadowsocks-config
spec:
refreshInterval: 1h
secretStoreRef:
name: vaultwarden-fields
kind: ClusterSecretStore
target:
name: shadowsocks-config
creationPolicy: Owner
deletionPolicy: Retain
template:
type: Opaque
data:
config.json: |
{
"server": "0.0.0.0",
"server_port": 8388,
"method": "chacha20-ietf-poly1305",
"password": {{ .password | toJson }},
"mode": "tcp_and_udp"
}
data:
- secretKey: password
remoteRef:
key: k8s/shadowsocks/secrets
property: Password
@@ -1,6 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: shadowsocks
resources:
- external-secrets.yaml
- deployment.yaml
-54
View File
@@ -1,54 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-petting
labels:
app: web-petting
spec:
replicas: 1
selector:
matchLabels:
app: web-petting
template:
metadata:
labels:
app: web-petting
annotations:
reloader.stakater.com/auto: "true"
spec:
nodeSelector:
homelab.hexor.cy/pool: core
homelab.hexor.cy/exposure: external
containers:
- name: web-petting
image: ultradesu/web-petting:latest
imagePullPolicy: Always
args:
- web-petting
- -l
- 0.0.0.0:3000
ports:
- name: http
containerPort: 3000
env:
- name: RUST_LOG
value: info
- name: WEB_PETTING_DEBUG
value: "false"
- name: WEB_PETTING_DATABASE_URL
valueFrom:
secretKeyRef:
name: web-petting-secrets
key: WEB_PETTING_DATABASE_URL
# Everything persistent is in PostgreSQL and R2; /data is scratch space only.
volumeMounts:
- name: data
mountPath: /data
resources:
requests:
memory: 256Mi
limits:
memory: 1Gi
volumes:
- name: data
emptyDir: {}
-37
View File
@@ -1,37 +0,0 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web-petting
annotations:
cert-manager.io/cluster-issuer: letsencrypt
traefik.ingress.kubernetes.io/router.entrypoints: websecure
spec:
ingressClassName: traefik
rules:
- host: pet.hexor.cy
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web-petting
port:
number: 80
- host: xn--l1acako8eb.xn--p1ai
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web-petting
port:
number: 80
tls:
- secretName: web-petting-tls
hosts:
- pet.hexor.cy
- secretName: web-petting-murnyanya-tls
hosts:
- xn--l1acako8eb.xn--p1ai
@@ -1,8 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- secrets.yaml
- deployment.yaml
- service.yaml
- ingress.yaml
-47
View File
@@ -1,47 +0,0 @@
# Read-only access to the web-petting-db Secret in the psql namespace
# (see postgresql/databases/web-petting.yaml for the other half).
apiVersion: v1
kind: ServiceAccount
metadata:
name: db-credentials-reader
---
apiVersion: external-secrets.io/v1
kind: SecretStore
metadata:
name: psql-credentials
spec:
provider:
kubernetes:
remoteNamespace: psql
server:
caProvider:
type: ConfigMap
name: kube-root-ca.crt
key: ca.crt
auth:
serviceAccount:
name: db-credentials-reader
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: web-petting-secrets
spec:
refreshInterval: 1h
secretStoreRef:
kind: SecretStore
name: psql-credentials
target:
name: web-petting-secrets
template:
data:
WEB_PETTING_DATABASE_URL: "postgresql://{{ .username }}:{{ .password }}@main-rw.psql.svc:5432/web_petting"
data:
- secretKey: username
remoteRef:
key: web-petting-db
property: username
- secretKey: password
remoteRef:
key: web-petting-db
property: password
-15
View File
@@ -1,15 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: web-petting
labels:
app: web-petting
spec:
type: ClusterIP
selector:
app: web-petting
ports:
- name: http
port: 80
targetPort: 3000
protocol: TCP
-27
View File
@@ -1,27 +0,0 @@
#!/usr/bin/env bash
# Brings the whole core state up on an empty cluster:
# k8s-ng/bootstrap/bootstrap.sh <kube-context>
# Installs Argo CD (the only thing that cannot install itself) and applies the
# root application; Argo CD then pulls every core application in wave order.
set -euo pipefail
ctx=${1:?usage: bootstrap.sh <kube-context>}
here=$(cd "$(dirname "$0")" && pwd)
k="kubectl --context $ctx"
# Render in a scratch copy so kustomize does not leave downloaded charts in the repo.
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
cp -r "$here/../core/argocd" "$tmp/argocd"
kubectl kustomize --enable-helm "$tmp/argocd" > "$tmp/argocd.yaml"
$k create namespace argocd --dry-run=client -o yaml | $k apply -f -
# CRDs must exist before the Argo CD objects that use them: apply twice.
$k apply --server-side --force-conflicts -f "$tmp/argocd.yaml" >/dev/null 2>&1 || true
sleep 8
$k apply --server-side --force-conflicts -f "$tmp/argocd.yaml"
$k -n argocd rollout status deploy --timeout=300s
$k -n argocd rollout status statefulset --timeout=300s
$k apply --server-side --force-conflicts -f "$here/root.yaml"
echo "Core state requested. Watch it with: $k -n argocd get app"
-29
View File
@@ -1,29 +0,0 @@
# Root of the core state. Applying this one object makes Argo CD pull every core
# application in wave order (see k8s-ng/core-state/values.yaml).
# Note: there are deliberately no finalizers; deleting this Application keeps all
# deployed services and data.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: core-state
namespace: argocd
spec:
project: core
destination:
server: https://kubernetes.default.svc
namespace: argocd
source:
repoURL: https://gt.hexor.cy/ab/homelab.git
targetRevision: main
path: k8s-ng/core-state
helm:
# Per-cluster overrides of core-state/values.yaml, for example:
# valuesObject:
# targetRevision: some-branch
valuesObject: {}
syncPolicy:
automated:
selfHeal: true
prune: true
syncOptions:
- ServerSideApply=true
-4
View File
@@ -1,4 +0,0 @@
apiVersion: v2
name: core-state
description: App of apps for the core services of the cluster
version: 0.1.0
@@ -1,32 +0,0 @@
{{- range .Values.apps }}
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: {{ .name }}
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: {{ .wave | quote }}
spec:
project: {{ .project | default $.Values.project }}
destination:
server: {{ $.Values.destinationServer }}
namespace: {{ .namespace }}
source:
repoURL: {{ $.Values.repoURL }}
targetRevision: {{ $.Values.targetRevision }}
path: {{ .path }}
syncPolicy:
automated:
selfHeal: true
prune: {{ if hasKey . "prune" }}{{ .prune }}{{ else }}true{{ end }}
syncOptions:
- CreateNamespace=true
- ServerSideApply=true
{{- if and (hasKey . "validate") (not .validate) }}
- Validate=false
{{- end }}
{{- if .skipDryRunOnMissingResource }}
- SkipDryRunOnMissingResource=true
{{- end }}
{{- end }}
-134
View File
@@ -1,134 +0,0 @@
# Per-cluster variables. Override them in k8s-ng/bootstrap/root.yaml
# (spec.source.helm.valuesObject) when the state is deployed to another cluster.
repoURL: https://gt.hexor.cy/ab/homelab.git
targetRevision: main
destinationServer: https://kubernetes.default.svc
project: core
# Core applications. Lower waves are synced first and must be Healthy before the
# next wave starts. Every path is a kustomize (or plain manifest) directory.
# wave 0: Argo CD itself and its projects
# wave 1: operators (their CRDs are used by the later waves)
# wave 2: the database cluster and its databases/users
# wave 3: services that need the database and secrets
apps:
- name: argocd
path: k8s-ng/core/argocd
namespace: argocd
wave: 0
- name: cnpg
path: k8s-ng/core/cnpg
namespace: cnpg-system
wave: 1
- name: traefik-config
path: k8s-ng/core/traefik-config
namespace: kube-system
wave: 1
- name: coredns-config
path: k8s-ng/core/coredns-config
namespace: kube-system
wave: 1
validate: false # partial Deployment: only the fields we manage
- name: reloader
path: k8s-ng/core/reloader
namespace: reloader
wave: 1
- name: gpu-system
path: k8s-ng/core/gpu
namespace: gpu-system
wave: 1
- name: cert-manager
path: k8s-ng/core/cert-manager
namespace: cert-manager
wave: 1
skipDryRunOnMissingResource: true # ClusterIssuers need the cert-manager CRDs
- name: external-secrets
path: k8s-ng/core/external-secrets
namespace: external-secrets
wave: 1
- name: postgresql
path: k8s-ng/core/postgresql
namespace: psql
wave: 2
prune: false # never delete databases because of a manifest change
skipDryRunOnMissingResource: true # CRDs come from the cnpg and external-secrets apps
- name: vaultwarden
path: k8s-ng/core/vaultwarden
namespace: vaultwarden
wave: 3
skipDryRunOnMissingResource: true
- name: keycloak
path: k8s-ng/core/keycloak
namespace: keycloak
wave: 3
skipDryRunOnMissingResource: true # ExternalSecret/Certificate CRDs come from earlier waves
# Not a core service: lives in k8s-ng/apps and belongs to the "apps" project.
- name: furumi
project: apps
path: k8s-ng/apps/furumi
namespace: furumi
wave: 6 # after prom-stack installs the ServiceMonitor CRD
skipDryRunOnMissingResource: true
- name: web-petting
project: apps
path: k8s-ng/apps/web-petting
namespace: web-petting
wave: 5
skipDryRunOnMissingResource: true
- name: k8s-secrets
project: apps
path: k8s-ng/apps/k8s-secrets
namespace: k8s-secret
wave: 5 # after secret-stores and auth-proxy; independent of prom-stack health
skipDryRunOnMissingResource: true
- name: llamacpp
project: apps
path: k8s-ng/apps/llamacpp
namespace: llamacpp
wave: 5 # pinned to the ai node (GPU); must not hold back the other waves
skipDryRunOnMissingResource: true
- name: secret-stores
path: k8s-ng/core/secret-stores
namespace: external-secrets
wave: 4 # reads the Vaultwarden vault, so it comes after vaultwarden
skipDryRunOnMissingResource: true # ClusterSecretStore CRD comes from the external-secrets app
- name: gitea
path: k8s-ng/core/gitea
namespace: gitea
wave: 5 # after secret-stores (its secrets come from the vault); pinned to the music node; the runner to the ai node
prune: false # never delete the instance because of a manifest change
skipDryRunOnMissingResource: true
- name: prom-stack
path: k8s-ng/core/prom-stack
namespace: prometheus
wave: 5 # after secret-stores; installs monitoring before application workloads
skipDryRunOnMissingResource: true # monitoring, cert-manager and ExternalSecret CRDs
- name: immich-db
path: k8s-ng/core/immich-db
namespace: immich
wave: 2
prune: false
skipDryRunOnMissingResource: true
- name: minecraft
project: games
path: k8s-ng/apps/minecraft
namespace: minecraft
wave: 6
- name: rustdesk
project: apps
path: k8s-ng/apps/rustdesk
namespace: rustdesk
wave: 6
skipDryRunOnMissingResource: true
- name: shadowsocks
project: apps
path: k8s-ng/apps/shadowsocks
namespace: shadowsocks
wave: 6
skipDryRunOnMissingResource: true
- name: mtproxy
project: apps
path: k8s-ng/apps/mtproxy
namespace: mtproxy
wave: 6 # after secret-stores and prom-stack
skipDryRunOnMissingResource: true
-66
View File
@@ -1,66 +0,0 @@
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: core
namespace: argocd
spec:
clusterResourceWhitelist:
- group: '*'
kind: '*'
description: Hexor Home Lab Core
destinations:
- namespace: '*'
server: https://kubernetes.default.svc
sourceRepos:
- https://gt.hexor.cy/ab/homelab.git
---
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: apps
namespace: argocd
spec:
clusterResourceWhitelist:
- group: '*'
kind: '*'
description: Hexor Home Lab Apps
destinations:
- namespace: '*'
server: https://kubernetes.default.svc
sourceRepos:
- https://gt.hexor.cy/ab/homelab.git
---
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: games
namespace: argocd
spec:
clusterResourceWhitelist:
- group: '*'
kind: '*'
description: Hexor Home Lab Games
destinations:
- namespace: '*'
server: https://kubernetes.default.svc
sourceRepos:
- https://gt.hexor.cy/ab/homelab.git
---
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: desktop
namespace: argocd
spec:
clusterResourceWhitelist:
- group: '*'
kind: '*'
description: Hexor Home Lab Desktop Apps
destinations:
- namespace: '*'
server: https://kubernetes.default.svc
sourceRepos:
- https://gt.hexor.cy/ab/homelab.git
-26
View File
@@ -1,26 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: argocd-dex-server
namespace: argocd
spec:
template:
spec:
containers:
- name: dex-server
readinessProbe:
tcpSocket:
port: 5556
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
successThreshold: 1
failureThreshold: 3
livenessProbe:
tcpSocket:
port: 5556
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 3
successThreshold: 1
failureThreshold: 3
-25
View File
@@ -1,25 +0,0 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: argocd
namespace: argocd
annotations:
cert-manager.io/cluster-issuer: letsencrypt
traefik.ingress.kubernetes.io/router.entrypoints: websecure
spec:
tls:
- hosts:
- ag.btwiusearch.net
secretName: argocd-server-tls
rules:
- host: ag.btwiusearch.net
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: argocd-server
port:
number: 80
-18
View File
@@ -1,18 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ingress.yaml
- appprojects.yaml
helmCharts:
- name: argo-cd
repo: https://argoproj.github.io/argo-helm
version: 10.6.0
releaseName: argocd
namespace: argocd
valuesFile: values.yaml
includeCRDs: true
patches:
- path: dex-probes-patch.yaml
-118
View File
@@ -1,118 +0,0 @@
# Argo CD Helm Chart Values (k8s-ng cluster)
global:
nodeSelector:
homelab.hexor.cy/pool: core
homelab.hexor.cy/exposure: external
domain: ag.btwiusearch.net
logging:
format: text
level: info
crds:
install: true
keep: true # Recommended to keep true so CRDs are not deleted on uninstall
configs:
params:
server.insecure: "true" # TLS is terminated by the ingress
cm:
create: true
exec.enabled: true
kustomize.buildOptions: --enable-helm
application.instanceLabelKey: argocd.argoproj.io/instance
# Local admin account, kept as a break-glass login next to SSO; the initial
# password is generated by Argo CD into the argocd-initial-admin-secret secret.
# Set to false once SSO is trusted.
admin.enabled: true
statusbadge.enabled: true
timeout.reconciliation: 60s
# Lets sync waves in an app of apps wait for child Applications to be Healthy.
resource.customizations.health.argoproj.io_Application: |
hs = {}
hs.status = "Progressing"
hs.message = ""
if obj.status ~= nil then
if obj.status.health ~= nil then
hs.status = obj.status.health.status
if obj.status.health.message ~= nil then
hs.message = obj.status.health.message
end
end
end
return hs
# SSO through Dex -> Keycloak. The client id/secret come from the oidc-creds
# Secret (labelled app.kubernetes.io/part-of=argocd), created by hand until
# Vaultwarden + External Secrets are the source.
dex.config: |
connectors:
- type: oidc
id: keycloak
name: Keycloak
config:
issuer: https://auth.hexor.cy/auth/realms/hexor
clientID: $oidc-creds:id
clientSecret: $oidc-creds:secret
insecureEnableGroups: true
scopes:
- openid
- profile
- email
- offline_access
getUserInfo: true
rbac:
create: true
policy.default: ""
policy.csv: |
g, game-servers-managers, GameServersManagersRole
# Role permissions
p, GameServersManagersRole, applications, get, games/*, allow
p, GameServersManagersRole, applications, update, games/*, allow
p, GameServersManagersRole, applications, sync, games/*, allow
p, GameServersManagersRole, applications, override, games/*, allow
p, GameServersManagersRole, applications, action/*, games/*, allow
p, GameServersManagersRole, exec, create, games/*, allow
p, GameServersManagersRole, logs, get, games/*, allow
p, GameServersManagersRole, applications, delete, games/*, deny
# Admin policy
g, argocd-admins, role:admin
secret:
createSecret: true
controller:
replicas: 1
# Dex OIDC provider (Keycloak connector configured in configs.cm.dex.config)
dex:
replicas: 1
enabled: true
redis:
enabled: true
redis-ha:
enabled: false
server:
replicas: 1
ingress:
enabled: false
certificate:
enabled: false
certificateSecret:
enabled: false
repoServer:
replicas: 1
livenessProbe:
timeoutSeconds: 10
periodSeconds: 60
applicationSet:
enabled: true
replicas: 1
notifications:
enabled: true
replicas: 1
-44
View File
@@ -1,44 +0,0 @@
# Public certificates through DNS-01 for the zones below and HTTP-01 (Traefik) for
# every other domain, e.g. the .рф one. The credential Secrets are created by hand
# (they will move to Vaultwarden + External Secrets):
# kubectl -n cert-manager create secret generic cloudflare-api-token --from-literal=apiToken=<token>
# (Zone:DNS:Edit on the btwiusearch.net zone)
# kubectl -n cert-manager create secret generic aws-api-key-secret \
# --from-literal=accessKey=<id> --from-literal=secretKey=<secret>
# (Route53 access to the hexor.cy zone)
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: k8s-master@hexor.cy
privateKeySecretRef:
name: letsencrypt-account-key
solvers:
- selector:
dnsZones:
- btwiusearch.net
dns01:
cloudflare:
apiTokenSecretRef:
name: cloudflare-api-token
key: apiToken
- selector:
dnsZones:
- hexor.cy
dns01:
route53:
region: eu-north-1
accessKeyIDSecretRef:
name: aws-api-key-secret
key: accessKey
secretAccessKeySecretRef:
name: aws-api-key-secret
key: secretKey
# Fallback for domains outside the zones above: their A records must point
# at the cluster nodes (Traefik answers on port 80 everywhere).
- http01:
ingress:
ingressClassName: traefik
@@ -1,15 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- issuer.yaml
- self-signed.yaml
helmCharts:
- name: cert-manager
repo: https://charts.jetstack.io
version: 1.21.1
releaseName: cert-manager
namespace: cert-manager
valuesFile: values.yaml
includeCRDs: true
-29
View File
@@ -1,29 +0,0 @@
# Private CA for internal services: issuer "hexor-lab".
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: selfsigned
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: hexor-ca-root
namespace: cert-manager
spec:
isCA: true
duration: 87600h # 10y
commonName: hexor-ca
secretName: hexor-ca-root-secret
issuerRef:
name: selfsigned
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: hexor-lab
spec:
ca:
secretName: hexor-ca-root-secret
-12
View File
@@ -1,12 +0,0 @@
crds:
enabled: true
nodeSelector: &externalCore
homelab.hexor.cy/pool: core
homelab.hexor.cy/exposure: external
webhook:
nodeSelector: *externalCore
cainjector:
nodeSelector: *externalCore
startupapicheck:
nodeSelector: *externalCore
-11
View File
@@ -1,11 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
helmCharts:
- name: cloudnative-pg
repo: https://cloudnative-pg.github.io/charts
version: 0.29.1
releaseName: cnpg
namespace: cnpg-system
valuesFile: values.yaml
includeCRDs: true
-8
View File
@@ -1,8 +0,0 @@
# CloudNativePG operator (chart 0.29.x = operator 1.30.x)
nodeSelector:
homelab.hexor.cy/pool: core
homelab.hexor.cy/exposure: external
crds:
create: true
@@ -1,33 +0,0 @@
# K3s owns the CoreDNS Deployment. Apply only the fields customized here so
# upstream image, probes, Corefile mounts and topology spread stay intact.
apiVersion: apps/v1
kind: Deployment
metadata:
name: coredns
namespace: kube-system
annotations:
argocd.argoproj.io/sync-options: Prune=false,Delete=false
spec:
replicas: 3
template:
spec:
tolerations:
- key: CriticalAddonsOnly
operator: Exists
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
# DNS also belongs on the internal core node used by Actions jobs.
- key: workload
operator: Equal
value: ai
effect: NoSchedule
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: homelab.hexor.cy/pool
operator: In
values:
- core
@@ -1,6 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml
-11
View File
@@ -1,11 +0,0 @@
# Keep DNS on the caller's node when it has a healthy CoreDNS replica. K3s owns
# the rest of this Service; server-side apply preserves its ports and selector.
apiVersion: v1
kind: Service
metadata:
name: kube-dns
namespace: kube-system
annotations:
argocd.argoproj.io/sync-options: Prune=false,Delete=false
spec:
trafficDistribution: PreferSameNode
@@ -1,13 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# The Vaultwarden bridge and the ClusterSecretStores are in ../secret-stores (they need Vaultwarden).
helmCharts:
- name: external-secrets
repo: https://charts.external-secrets.io
version: 2.11.0
releaseName: external-secrets
namespace: external-secrets
valuesFile: values.yaml
includeCRDs: true
-9
View File
@@ -1,9 +0,0 @@
installCRDs: true
nodeSelector: &externalCore
homelab.hexor.cy/pool: core
homelab.hexor.cy/exposure: external
webhook:
nodeSelector: *externalCore
certController:
nodeSelector: *externalCore
-72
View File
@@ -1,72 +0,0 @@
# Gitea with SQLite; everything (repositories, database, SSH host keys) lives in
# /var/lib/gitea on the music node.
# `replicas` is deliberately not set: Argo CD does not manage it, so the instance
# can be scaled to 0 with kubectl during a data migration without being healed back.
apiVersion: apps/v1
kind: Deployment
metadata:
name: gitea
labels:
app: gitea
spec:
selector:
matchLabels:
app: gitea
strategy:
type: Recreate # two instances must never open the same SQLite database
template:
metadata:
labels:
app: gitea
spec:
nodeSelector:
homelab.hexor.cy/pool: core
homelab.hexor.cy/exposure: external
kubernetes.io/hostname: music
volumes:
- name: storage
hostPath:
path: /var/lib/gitea
type: DirectoryOrCreate
containers:
- name: gitea
image: gitea/gitea:28.0.0
resources:
requests:
memory: 1Gi
cpu: "1"
limits:
memory: 4Gi
cpu: "4"
env:
- name: GITEA__service__REGISTER_MANUAL_CONFIRM
value: "true"
- name: GITEA__service__ENABLE_CAPTCHA
value: "true"
- name: GITEA__service__REQUIRE_CAPTCHA_FOR_LOGIN
value: "false"
- name: GITEA__service__REQUIRE_EXTERNAL_REGISTRATION_CAPTCHA
value: "true"
- name: GITEA__service__CAPTCHA_TYPE
value: cfturnstile
- name: GITEA__security__ALLOWED_HOST_LIST
value: "*"
envFrom:
- secretRef:
name: gitea-runner-act-runner-secrets
ports:
- name: http
containerPort: 3000
protocol: TCP
- name: ssh
containerPort: 22
protocol: TCP
readinessProbe:
httpGet:
path: /api/healthz
port: http
periodSeconds: 10
timeoutSeconds: 5
volumeMounts:
- name: storage
mountPath: /data
-44
View File
@@ -1,44 +0,0 @@
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: gitea-runner-token
spec:
refreshInterval: 1m
target:
name: gitea-runner-act-runner-secrets
deletionPolicy: Delete
template:
type: Opaque
data:
token: |-
{{ .password }}
GITEA__service__CF_TURNSTILE_SITEKEY: |-
{{ .CF_TURNSTILE_SITEKEY }}
GITEA__service__CF_TURNSTILE_SECRET: |-
{{ .CF_TURNSTILE_SECRET }}
data:
- secretKey: password
sourceRef:
storeRef:
name: vaultwarden-login
kind: ClusterSecretStore
remoteRef:
key: k8s/gitea/secrets
property: login.password
- secretKey: CF_TURNSTILE_SITEKEY
sourceRef:
storeRef:
name: vaultwarden-login
kind: ClusterSecretStore
remoteRef:
key: k8s/gitea/secrets
property: fields[0].value
- secretKey: CF_TURNSTILE_SECRET
sourceRef:
storeRef:
name: vaultwarden-login
kind: ClusterSecretStore
remoteRef:
key: k8s/gitea/secrets
property: fields[1].value
-23
View File
@@ -1,23 +0,0 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: gitea
annotations:
cert-manager.io/cluster-issuer: letsencrypt
traefik.ingress.kubernetes.io/router.entrypoints: websecure
spec:
rules:
- host: gt.hexor.cy
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: gitea
port:
number: 80
tls:
- secretName: gitea-tls
hosts:
- gt.hexor.cy
-11
View File
@@ -1,11 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- external-secrets.yaml
- deployment.yaml
- service.yaml
- ingress.yaml
- runner.yaml
- runner-rbac.yaml
- user-unban-cronjob.yaml
-41
View File
@@ -1,41 +0,0 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: gitea-runner
---
apiVersion: v1
kind: Secret
metadata:
name: gitea-runner-kubernetes-token
annotations:
kubernetes.io/service-account.name: gitea-runner
type: kubernetes.io/service-account-token
---
# CI may edit the routes of the auth proxy (the ConfigMap lives in the keycloak namespace here).
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: auth-proxy-routes-manager
namespace: keycloak
rules:
- apiGroups: [""]
resources: [configmaps]
resourceNames: [auth-proxy-routes]
verbs: [get, update, patch]
- apiGroups: [""]
resources: [configmaps]
verbs: [create]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: gitea-runner-auth-proxy-routes
namespace: keycloak
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: auth-proxy-routes-manager
subjects:
- kind: ServiceAccount
name: gitea-runner
namespace: gitea
-86
View File
@@ -1,86 +0,0 @@
# Gitea Actions runner on the ai node. Jobs run as containers in the host's Docker
# (virtualisation.docker in hosts/ai of the nix repo) through the mounted socket.
# `replicas` is not set (see deployment.yaml): scale it with kubectl when needed.
apiVersion: v1
kind: ConfigMap
metadata:
name: gitea-runner-config
data:
config.yaml: |
runner:
capacity: 8 # jobs in parallel; the ai node has 32 threads and 124 GiB
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: gitea-runner
labels:
app: gitea-runner
annotations:
reloader.stakater.com/auto: "true"
spec:
selector:
matchLabels:
app: gitea-runner
strategy:
type: Recreate
template:
metadata:
labels:
app: gitea-runner
spec:
serviceAccountName: gitea-runner
dnsConfig:
options:
- name: ndots
value: "2"
nodeSelector:
homelab.hexor.cy/pool: core
homelab.hexor.cy/exposure: internal
workload/ai: "true"
tolerations:
- key: workload
operator: Equal
value: ai
effect: NoSchedule
volumes:
- name: docker-sock
hostPath:
path: /var/run/docker.sock
type: Socket
- name: runner-data
emptyDir:
sizeLimit: 30Gi
- name: config
configMap:
name: gitea-runner-config
containers:
- name: gitea-runner
image: gitea/act_runner:nightly
volumeMounts:
- name: docker-sock
mountPath: /var/run/docker.sock
- name: runner-data
mountPath: /data
- name: config
mountPath: /config.yaml
subPath: config.yaml
env:
- name: CONFIG_FILE
value: /config.yaml
- name: GITEA_INSTANCE_URL
# Job containers run outside the cluster network, so they need the public URL.
value: https://gt.hexor.cy
- name: GITEA_RUNNER_REGISTRATION_TOKEN
valueFrom:
secretKeyRef:
name: gitea-runner-act-runner-secrets
key: token
- name: NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
- name: GITEA_RUNNER_NAME
value: $(NODE_NAME)
- name: GITEA_RUNNER_LABELS
value: ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-latest,ubuntu-22.04:docker://ghcr.io/catthehacker/ubuntu:act-22.04,ubuntu-20.04:docker://ghcr.io/catthehacker/ubuntu:act-20.04
-24
View File
@@ -1,24 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: gitea
spec:
selector:
app: gitea
ports:
- protocol: TCP
port: 80
targetPort: 3000
---
# Git over SSH on every node (servicelb); the node firewall opens 30022 (nix repo, k3s-base.nix).
apiVersion: v1
kind: Service
metadata:
name: gitea-ssh
spec:
type: LoadBalancer
ports:
- port: 30022
targetPort: 22
selector:
app: gitea
-61
View File
@@ -1,61 +0,0 @@
apiVersion: batch/v1
kind: CronJob
metadata:
name: gitea-user-unban
labels:
app: gitea-user-unban
spec:
schedule: "*/10 * * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
template:
metadata:
labels:
app: gitea-user-unban
spec:
restartPolicy: OnFailure
nodeSelector:
homelab.hexor.cy/pool: core
homelab.hexor.cy/exposure: external
kubernetes.io/hostname: music
volumes:
- name: storage
hostPath:
path: /var/lib/gitea
type: Directory
containers:
- name: sqlite-unban
image: gitea/gitea:28.0.0
imagePullPolicy: IfNotPresent
resources:
requests:
memory: 32Mi
cpu: 10m
limits:
memory: 128Mi
cpu: 100m
command:
- /bin/sh
- -ec
- |
sqlite3 -cmd ".timeout 30000" /data/gitea/gitea.db "
UPDATE \"user\"
SET is_active = 1,
prohibit_login = 0,
updated_unix = unixepoch()
WHERE lower(email) = lower('ab@hexor.cy')
AND (is_active <> 1 OR prohibit_login <> 0);
SELECT printf(
'gitea user watchdog: id=%d login=%s email=%s is_active=%d prohibit_login=%d updated_unix=%d',
id, lower_name, email, is_active, prohibit_login, updated_unix
)
FROM \"user\"
WHERE lower(email) = lower('ab@hexor.cy');
"
volumeMounts:
- name: storage
mountPath: /data
-28
View File
@@ -1,28 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
helmCharts:
- name: amd-gpu
repo: https://rocm.github.io/k8s-device-plugin/
version: 0.22.0
releaseName: amd-gpu-device-plugin
namespace: gpu-system
valuesFile: values.yaml
includeCRDs: true
# The chart always adds the NFD label feature.node.kubernetes.io/pci-0300_1002.present
# to the selector; NFD is not installed (and would not match this iGPU), so replace it.
patches:
- target:
group: apps
version: v1
kind: DaemonSet
name: amd-gpu-device-plugin-daemonset
patch: |-
- op: replace
path: /spec/template/spec/nodeSelector
value:
homelab.hexor.cy/pool: core
homelab.hexor.cy/exposure: internal
kubernetes.io/arch: amd64
workload/ai: "true"
-37
View File
@@ -1,37 +0,0 @@
# AMD GPU device plugin: exposes the Strix Halo iGPU of the "ai" node as the
# amd.com/gpu resource (and injects /dev/kfd and /dev/dri into the pod).
nfd:
enabled: false
labeller:
enabled: false
dp:
image:
repository: docker.io/rocm/k8s-device-plugin
tag: "1.31.0.9"
updateStrategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
# The ai node carries the label workload/ai=true and the taint workload=ai:NoSchedule
# (hosts/ai in the nix repo).
tolerations:
- key: workload
operator: Equal
value: ai
effect: NoSchedule
node_selector_enabled: true
node_selector:
homelab.hexor.cy/pool: core
homelab.hexor.cy/exposure: internal
kubernetes.io/arch: amd64
workload/ai: "true"
-47
View File
@@ -1,47 +0,0 @@
# Keep PostgreSQL and both vector extensions identical to the source during
# migration. The application/media migration and any upgrades happen later.
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: immich
annotations:
argocd.argoproj.io/sync-options: Prune=false
spec:
description: Immich database migrated from the old cluster; media remain on NAS
imageName: ghcr.io/tensorchord/cloudnative-pgvecto.rs:14-v0.3.0@sha256:df7848916fea2c486faee8155a481dc5009f30ec513b5561d9ab76d1e101a594
instances: 1
enableSuperuserAccess: true
bootstrap:
initdb:
database: immich
owner: immich
dataChecksums: true
encoding: UTF8
localeCType: C
localeCollate: C
postgresUID: 26
postgresGID: 26
storage:
size: 55Gi
storageClass: immich-local
pvcTemplate:
accessModes: [ReadWriteOnce]
selector:
matchLabels:
immich.hexor.cy/volume: postgres
affinity:
nodeSelector:
homelab.hexor.cy/pool: core
homelab.hexor.cy/exposure: external
kubernetes.io/hostname: music
postgresql:
shared_preload_libraries: [vectors.so]
parameters:
shared_buffers: 512MB
max_connections: "200"
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
memory: 2Gi
-6
View File
@@ -1,6 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: immich
resources:
- storage.yaml
- cluster.yaml
-66
View File
@@ -1,66 +0,0 @@
apiVersion: batch/v1
kind: Job
metadata:
name: immich-db-storage-prepare
annotations:
argocd.argoproj.io/hook: PreSync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation,HookSucceeded
spec:
backoffLimit: 3
template:
spec:
restartPolicy: OnFailure
nodeSelector:
homelab.hexor.cy/pool: core
homelab.hexor.cy/exposure: external
kubernetes.io/hostname: music
containers:
- name: prepare
image: docker.io/busybox:1.37.0
command: [sh, -ec, "mkdir -p /storage/postgresql; chown 26:26 /storage/postgresql; chmod 0700 /storage/postgresql"]
securityContext:
runAsUser: 0
allowPrivilegeEscalation: false
volumeMounts:
- name: storage
mountPath: /storage
volumes:
- name: storage
hostPath:
path: /data/k8s/immich
type: DirectoryOrCreate
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: immich-local
annotations:
argocd.argoproj.io/sync-options: Prune=false
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: WaitForFirstConsumer
reclaimPolicy: Retain
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: immich-postgres-music
labels:
immich.hexor.cy/volume: postgres
annotations:
argocd.argoproj.io/sync-options: Prune=false
spec:
capacity:
storage: 55Gi
volumeMode: Filesystem
accessModes: [ReadWriteOnce]
persistentVolumeReclaimPolicy: Retain
storageClassName: immich-local
local:
path: /data/k8s/immich/postgresql
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values: [music]
-125
View File
@@ -1,125 +0,0 @@
# rsauth2-proxy: forward-auth for services that have no OIDC of their own.
# Needs, besides this file:
# - Secret auth-proxy-creds (AUTH_PROXY_CLIENT_ID, AUTH_PROXY_CLIENT_SECRET,
# AUTH_PROXY_COOKIE_SECRET); the client secret is the one of the Keycloak
# client "rsauth2-proxy". Created by hand until Vaultwarden is the source.
# - ConfigMap auth-proxy-routes, managed by Terraform (terraform/keycloak).
apiVersion: apps/v1
kind: Deployment
metadata:
name: auth-proxy
labels:
app: auth-proxy
spec:
replicas: 1
selector:
matchLabels:
app: auth-proxy
template:
metadata:
labels:
app: auth-proxy
spec:
nodeSelector:
homelab.hexor.cy/pool: core
homelab.hexor.cy/exposure: external
containers:
- name: auth-proxy
image: ultradesu/rsauth2-proxy:v0.1.3
ports:
- containerPort: 8080
name: http
protocol: TCP
envFrom:
- secretRef:
name: auth-proxy-creds
env:
- name: AUTH_PROXY_OIDC_ISSUER
value: "https://auth.hexor.cy/auth/realms/hexor"
- name: AUTH_PROXY_COOKIE_DOMAIN
value: ".hexor.cy"
- name: AUTH_PROXY_CALLBACK_URL
value: "https://oauth.hexor.cy/callback"
- name: AUTH_PROXY_ROUTES_FILE
value: "/config/routes.yaml"
- name: AUTH_PROXY_LOG_LEVEL
value: "debug"
volumeMounts:
- name: routes
mountPath: /config
readOnly: true
resources:
requests:
cpu: 50m
memory: 32Mi
limits:
cpu: 200m
memory: 64Mi
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 3
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 30
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
capabilities:
drop:
- ALL
volumes:
- name: routes
configMap:
name: auth-proxy-routes
---
apiVersion: v1
kind: Service
metadata:
name: auth-proxy
labels:
app: auth-proxy
spec:
ports:
- name: http
port: 80
targetPort: 8080
protocol: TCP
selector:
app: auth-proxy
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: auth-proxy
spec:
entryPoints:
- websecure
routes:
- match: Host(`oauth.hexor.cy`)
kind: Rule
services:
- name: auth-proxy
port: 80
tls:
secretName: auth-proxy-tls
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: auth-proxy-tls
spec:
secretName: auth-proxy-tls
issuerRef:
name: letsencrypt
kind: ClusterIssuer
dnsNames:
- oauth.hexor.cy
-30
View File
@@ -1,30 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- secrets.yaml
- auth-proxy.yaml
# ConfigMap auth-proxy-routes is managed by Terraform (terraform/keycloak, kubernetes_config_map_v1).
configMapGenerator:
- name: keycloak-hexor-lab-theme
namespace: keycloak
files:
- theme.properties=themes/hexor-lab/login/theme.properties
- template.ftl=themes/hexor-lab/login/template.ftl
- hexor.css=themes/hexor-lab/login/resources/css/hexor.css
- keycloak.css=themes/hexor-lab/login/resources/css/keycloak.css
- background.js=themes/hexor-lab/login/resources/js/background.js
- favicon.svg=themes/hexor-lab/login/resources/img/favicon.svg
generatorOptions:
annotations:
argocd.argoproj.io/sync-wave: "-1"
helmCharts:
- name: keycloakx
repo: https://codecentric.github.io/helm-charts
version: 7.3.1
releaseName: keycloak
namespace: keycloak
valuesFile: values.yaml
-110
View File
@@ -1,110 +0,0 @@
/* Preview behavior only. Keycloak will supply the real authentication later. */
"use strict";
const $ = (selector) => document.querySelector(selector);
const screens = {
login: ["Sign in", ""],
otp: ["Verify identity", ""],
reset: ["Reset password", "We’ll send a reset link to your email."],
success: ["Access granted", ""],
};
function notice(message = "", error = false) {
$("#notice").textContent = message;
$("#notice").hidden = !message;
$("#notice").classList.toggle("error", error);
}
function showScreen(requested, focus = false) {
const screen = requested === "error" ? "login" : (Object.hasOwn(screens, requested) ? requested : "login");
const [title, description] = screens[screen];
document.querySelectorAll("[data-view]").forEach((element) => { element.hidden = element.dataset.view !== screen; });
$("#page-title").replaceChildren(document.createTextNode(title));
const cursor = document.createElement("span");
cursor.className = "cursor";
cursor.setAttribute("aria-hidden", "true");
cursor.textContent = "_";
$("#page-title").append(cursor);
$("#page-description").textContent = description;
$("#page-description").hidden = !description;
document.querySelectorAll("[data-screen]").forEach((button) => button.setAttribute("aria-pressed", String(button.dataset.screen === requested)));
document.querySelectorAll(".field-error").forEach((element) => { element.hidden = true; });
document.querySelectorAll("[aria-invalid]").forEach((element) => element.removeAttribute("aria-invalid"));
notice(requested === "error" ? "[ DENIED ] Invalid username or password. Please try again." : "", requested === "error");
$("#password").value = "";
$("#password").type = "password";
$("#reveal-password").textContent = "SHOW";
$("#reveal-password").setAttribute("aria-label", "Show password");
$("#reveal-password").setAttribute("aria-pressed", "false");
$("#otp").value = "";
if (focus) document.querySelector(`[data-view="${screen}"] input`)?.focus();
}
function navigate(screen) {
if (location.hash === `#${screen}`) showScreen(screen, true);
else location.hash = screen;
}
function fieldError(field, output, message) {
field.setAttribute("aria-invalid", "true");
output.textContent = message;
output.hidden = false;
field.focus();
}
document.querySelectorAll("[data-screen]").forEach((button) => button.addEventListener("click", () => navigate(button.dataset.screen)));
window.addEventListener("hashchange", () => showScreen(location.hash.slice(1), true));
$("#login-form").addEventListener("submit", (event) => {
event.preventDefault();
notice();
if (!$("#username").value.trim()) return fieldError($("#username"), $("#login-error"), "Enter your username or email address.");
if (!$("#password").value) return fieldError($("#password"), $("#login-error"), "Enter your password.");
$("#password").value = "";
navigate("otp");
});
$("#otp-form").addEventListener("submit", (event) => {
event.preventDefault();
if (!/^[0-9]{6}$/.test($("#otp").value)) return fieldError($("#otp"), $("#otp-error"), "Enter a 6-digit verification code.");
$("#otp").value = "";
navigate("success");
});
$("#reset-form").addEventListener("submit", (event) => {
event.preventDefault();
if (!$("#reset-email").validity.valid) return fieldError($("#reset-email"), $("#reset-error"), "Enter a valid email address.");
$("#reset-error").hidden = true;
$("#reset-email").removeAttribute("aria-invalid");
notice("[ OK ] Reset-link confirmation preview. No email was sent.");
});
$("#reveal-password").addEventListener("click", () => {
const reveal = $("#password").type === "password";
$("#password").type = reveal ? "text" : "password";
$("#reveal-password").textContent = reveal ? "HIDE" : "SHOW";
$("#reveal-password").setAttribute("aria-label", reveal ? "Hide password" : "Show password");
$("#reveal-password").setAttribute("aria-pressed", String(reveal));
});
$("#passkey").addEventListener("click", () => notice("[ DEMO ] Passkey authentication will be handled by Keycloak. No device prompt is opened here."));
$("#google").addEventListener("click", () => notice("[ DEMO ] Google sign-in will be handled by Keycloak. This preview stays local."));
document.querySelectorAll("input").forEach((input) => input.addEventListener("input", () => {
input.removeAttribute("aria-invalid");
input.closest("form")?.querySelectorAll(".field-error").forEach((element) => { element.hidden = true; });
}));
showScreen(location.hash.slice(1));
// The optional stdlib server reloads the page when HTML/CSS/JS files change.
if (location.protocol === "http:" && ["127.0.0.1", "localhost"].includes(location.hostname)) {
let version;
let checking = false;
setInterval(async () => {
if (checking || document.hidden) return;
checking = true;
try {
const response = await fetch("/__version", { cache: "no-store" });
if (!response.ok || !response.headers.get("content-type")?.includes("application/json")) return;
const current = (await response.json()).version;
if (version !== undefined && current !== version) location.reload();
version = current;
} catch { /* The preview also works without the local server. */ }
finally { checking = false; }
}, 1000);
}
@@ -1 +0,0 @@
../themes/hexor-lab/login/resources/js/background.js
-62
View File
@@ -1,62 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="description" content="Local prototype of the Hexor terminal login theme.">
<title>Hexor · Access terminal</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' fill='%230c100c'/%3E%3Ctext x='8' y='45' fill='%23b9ef70' font-family='monospace' font-size='40'%3E%26gt;_%3C/text%3E%3C/svg%3E">
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="preview.css">
<script src="app.js" defer></script>
<script src="background.js" defer></script>
</head>
<body>
<canvas id="code-background" aria-hidden="true"></canvas>
<header class="site-header">
<a class="wordmark" href="#login">Hexor Lab</a>
</header>
<main class="page-main">
<div class="terminal-wrap">
<section class="terminal" aria-labelledby="page-title">
<div class="titlebar"><span><span aria-hidden="true">&gt;_</span> auth@hexor</span></div>
<div class="terminal-body">
<div class="heading"><h1 id="page-title">Sign in<span class="cursor" aria-hidden="true">_</span></h1><p id="page-description" class="description" hidden></p></div>
<div id="notice" class="notice" role="status" hidden></div>
<form id="login-form" data-view="login" novalidate>
<div class="field"><label for="username"><span class="prompt" aria-hidden="true">&gt;</span> username <span class="label-hint">/ email</span></label><input id="username" name="username" autocomplete="username" autocapitalize="none" spellcheck="false" placeholder="you@hexor.cy" required aria-describedby="login-error"></div>
<div class="field"><label for="password"><span class="prompt" aria-hidden="true">&gt;</span> password</label><div class="password-wrap"><input id="password" name="password" type="password" autocomplete="current-password" placeholder="Enter your password" required aria-describedby="login-error"><button class="reveal" id="reveal-password" type="button" aria-label="Show password" aria-pressed="false">SHOW</button></div></div>
<p class="field-error" id="login-error" role="alert" hidden></p>
<div class="form-options"><label class="checkbox"><input type="checkbox" name="rememberMe"> Keep me signed in</label><a href="#reset">Forgot password?</a></div>
<button class="primary" type="submit"><span>Sign in</span><span aria-hidden="true">↵</span></button>
<div class="divider"><span>OR CONTINUE WITH</span></div>
<div class="alternatives"><button class="secondary" id="passkey" type="button"><span aria-hidden="true">⌘</span> Passkey</button><button class="secondary" id="google" type="button"><span class="google-icon" aria-hidden="true">G</span> Google</button></div>
</form>
<form id="otp-form" data-view="otp" novalidate hidden>
<div class="field"><label for="otp"><span class="prompt" aria-hidden="true">&gt;</span> verification code</label><input id="otp" name="otp" class="otp-input" type="text" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code" placeholder="000000" required aria-describedby="otp-help otp-error"><p id="otp-help" class="field-help">Enter the 6-digit code from your authenticator.</p></div>
<p id="otp-error" class="field-error" role="alert" hidden></p>
<button class="primary" type="submit"><span>Verify identity</span><span aria-hidden="true">↵</span></button><a class="back-link" href="#login">← Back to sign in</a>
</form>
<form id="reset-form" data-view="reset" novalidate hidden>
<div class="field"><label for="reset-email"><span class="prompt" aria-hidden="true">&gt;</span> email address</label><input id="reset-email" name="email" type="email" autocomplete="email" autocapitalize="none" spellcheck="false" placeholder="you@hexor.cy" required aria-describedby="reset-error"></div><p id="reset-error" class="field-error" role="alert" hidden></p>
<button class="primary" type="submit"><span>Send reset link</span><span aria-hidden="true">↵</span></button><a class="back-link" href="#login">← Back to sign in</a>
</form>
<div data-view="success" class="success-panel" hidden><div class="success-mark" aria-hidden="true">[ OK ]</div><p>Identity verified.</p><a class="secondary back-link" href="#login">Back to sign in →</a></div>
</div>
</section>
</div>
</main>
<aside class="preview" aria-label="Настройки локального предпросмотра" lang="ru">
<div class="preview-heading"><span class="preview-label">LOCAL PREVIEW</span><span>Демо · данные никуда не отправляются</span></div>
<div class="preview-controls"><div class="preview-group" role="group" aria-label="Экран"><button type="button" data-screen="login" aria-pressed="true">Логин</button><button type="button" data-screen="otp" aria-pressed="false">OTP</button><button type="button" data-screen="reset" aria-pressed="false">Сброс</button><button type="button" data-screen="error" aria-pressed="false">Ошибка</button></div></div>
</aside>
</body>
</html>
-11
View File
@@ -1,11 +0,0 @@
.preview { border-top: 1px solid #ffffff0c; padding: 18px 24px 21px; color: var(--muted); font-size: 12px; background: var(--bg); }
.preview-heading, .preview-controls { max-width: 760px; margin: 0 auto; display: flex; gap: 15px; flex-wrap: wrap; justify-content: space-between; align-items: center; }
.preview-heading { margin-bottom: 12px; }
.preview-label { color: var(--text); letter-spacing: 1px; font-size: 11px; }
.preview-group { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
.preview button, .preview select { border: 1px solid var(--border); border-radius: 0; background: transparent; padding: 5px 9px; color: var(--muted); font-size: 13px; }
.preview select { background: var(--bg); }
.preview button[aria-pressed="true"] { color: var(--accent); border-color: var(--accent); }
.preview button:hover { color: var(--text); }
.preview .checkbox { margin-left: 8px; }
@media (max-width: 580px) { .preview { padding: 16px; } .preview-controls { gap: 12px; } }
-49
View File
@@ -1,49 +0,0 @@
{
"realm": "hexor-preview",
"enabled": true,
"displayName": "Hexor Lab",
"loginTheme": "hexor-lab",
"sslRequired": "none",
"registrationAllowed": false,
"resetPasswordAllowed": true,
"rememberMe": true,
"loginWithEmailAllowed": true,
"clients": [
{
"clientId": "theme-preview",
"enabled": true,
"publicClient": true,
"standardFlowEnabled": true,
"redirectUris": ["http://127.0.0.1:8765/*"],
"webOrigins": ["http://127.0.0.1:8765"]
}
],
"users": [
{
"username": "demo",
"enabled": true,
"emailVerified": true,
"email": "demo@example.test",
"firstName": "Demo",
"lastName": "User",
"credentials": [{"type": "password", "value": "demo-password", "temporary": false}]
},
{
"username": "demo-otp",
"enabled": true,
"emailVerified": true,
"email": "demo-otp@example.test",
"firstName": "Demo",
"lastName": "OTP",
"credentials": [
{"type": "password", "value": "demo-password", "temporary": false},
{
"type": "otp",
"userLabel": "Local preview",
"secretData": "{\"value\":\"local-preview-secret\"}",
"credentialData": "{\"subType\":\"totp\",\"digits\":6,\"counter\":0,\"period\":30,\"algorithm\":\"HmacSHA1\"}"
}
]
}
]
}
@@ -1,23 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Run the real login theme in an isolated, disposable Keycloak 26.5.6 instance.
# Test users: demo / demo-password and demo-otp / demo-password.
# The existing deployment, Terraform and production realm are not involved.
preview_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
theme_dir="$(cd -- "$preview_dir/../themes/hexor-lab" && pwd)"
printf '%s\n' \
'After Keycloak starts, open:' \
'http://127.0.0.1:8780/realms/hexor-preview/protocol/openid-connect/auth?client_id=theme-preview&redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2F&response_type=code&scope=openid' \
'Test account: demo / demo-password'
docker run --rm --name hexor-lab-preview \
--publish 127.0.0.1:8780:8080 \
--mount "type=bind,source=$theme_dir,target=/opt/keycloak/themes/hexor-lab,readonly" \
--mount "type=bind,source=$preview_dir/realm.json,target=/opt/keycloak/data/import/hexor-preview.json,readonly" \
quay.io/keycloak/keycloak:26.5.6 \
start-dev --import-realm \
--spi-theme--static-max-age=-1 \
--spi-theme--cache-themes=false \
--spi-theme--cache-templates=false
-49
View File
@@ -1,49 +0,0 @@
#!/usr/bin/env python3
"""Local theme preview: python3 serve.py [--port 8765]. No dependencies."""
import argparse
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
import json
from pathlib import Path
from urllib.parse import urlsplit
ROOT = Path(__file__).resolve().parent
ASSETS = ("index.html", "styles.css", "preview.css", "app.js", "background.js")
class PreviewHandler(SimpleHTTPRequestHandler):
def end_headers(self):
self.send_header("Cache-Control", "no-store")
super().end_headers()
def do_GET(self):
if urlsplit(self.path).path == "/__version":
version = ":".join(str((ROOT / name).stat().st_mtime_ns) for name in ASSETS)
body = json.dumps({"version": version}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
else:
super().do_GET()
def log_message(self, format, *args):
if not self.path.startswith("/__version"):
super().log_message(format, *args)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--port", type=int, default=8765)
args = parser.parse_args()
server = ThreadingHTTPServer(("127.0.0.1", args.port), partial(PreviewHandler, directory=str(ROOT)))
print(f"Local: http://127.0.0.1:{server.server_port}", flush=True)
print("Edit index.html, styles.css or app.js; the browser reloads on save. Ctrl+C to stop.", flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
-1
View File
@@ -1 +0,0 @@
../themes/hexor-lab/login/resources/css/hexor.css
-80
View File
@@ -1,80 +0,0 @@
# Read-only access to the keycloak-db Secret in the psql namespace
# (see postgresql/databases/keycloak.yaml for the other half).
apiVersion: v1
kind: ServiceAccount
metadata:
name: db-credentials-reader
---
apiVersion: external-secrets.io/v1
kind: SecretStore
metadata:
name: psql-credentials
spec:
provider:
kubernetes:
remoteNamespace: psql
server:
caProvider:
type: ConfigMap
name: kube-root-ca.crt
key: ca.crt
auth:
serviceAccount:
name: db-credentials-reader
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: keycloak-creds
spec:
refreshInterval: 1h
secretStoreRef:
kind: SecretStore
name: psql-credentials
target:
name: keycloak-creds
template:
data:
KC_DB_USERNAME: "{{ .username }}"
KC_DB_PASSWORD: "{{ .password }}"
data:
- secretKey: username
remoteRef:
key: keycloak-db
property: username
- secretKey: password
remoteRef:
key: keycloak-db
property: password
---
# Bootstrap admin: only used when the database has no admin user yet (a fresh
# install). With the restored database the existing admin accounts are kept.
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
metadata:
name: keycloak-bootstrap
spec:
length: 40
digits: 10
symbols: 0
noUpper: false
allowRepeat: true
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: keycloak-bootstrap
spec:
refreshPolicy: CreatedOnce
target:
name: keycloak-bootstrap
template:
data:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: "{{ .password }}"
dataFrom:
- sourceRef:
generatorRef:
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
name: keycloak-bootstrap
@@ -1,202 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
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.
@@ -1,83 +0,0 @@
/* Start here: all palette and layout settings are ordinary CSS variables. */
:root {
--bg: #0c0f0d;
--surface: #111612;
--input-bg: #0c110d;
--accent: #b9ef70;
--accent-ink: #121c09;
--text: #e1e7dd;
--muted: #8b9989;
--border: #354031;
--error: #ffa595;
--font: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
--terminal-width: 560px;
--code-opacity: .16;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body { margin: 0; min-height: 100svh; color: var(--text); background: var(--bg); font: 16px/1.6 var(--font); display: flex; flex-direction: column; isolation: isolate; }
#code-background { position: fixed; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: -1; opacity: var(--code-opacity); mask-image: linear-gradient(to bottom, transparent, #000 15%, #000 85%, transparent); }
button, input, select { font: inherit; }
button, select { cursor: pointer; }
button, a, input, select { -webkit-tap-highlight-color: transparent; }
button, a, select { touch-action: manipulation; }
a { color: var(--muted); text-underline-offset: 4px; text-decoration-thickness: 1px; }
a:hover { color: var(--accent); }
button:focus-visible, a:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
::selection { background: var(--accent); color: var(--accent-ink); }
.site-header { padding: 27px 38px; display: flex; justify-content: space-between; gap: 20px; align-items: center; }
.wordmark { font: 500 clamp(26px, 4vw, 34px)/1.2 var(--font); letter-spacing: -1px; text-decoration: none; color: var(--text); }
.page-main { flex: 1; display: grid; place-items: center; padding: 35px 24px 64px; }
.terminal-wrap { width: min(100%, var(--terminal-width)); }
.terminal { background: var(--surface); border: 1px solid var(--border); box-shadow: 0 24px 90px #0004; }
.titlebar { display: flex; padding: 12px 20px; border-bottom: 1px solid var(--border); font-size: 14px; color: var(--muted); }
.titlebar span span { color: var(--accent); margin-right: 9px; }
.terminal-body { padding: 30px 36px 34px; }
.heading { margin: 0 0 28px; }
h1 { margin: 0 0 8px; font-size: clamp(28px, 6vw, 34px); line-height: 1.3; font-weight: 500; letter-spacing: -1px; }
.cursor { color: var(--accent); animation: blink 1.2s step-end infinite; }
@keyframes blink { 50% { opacity: 0; } }
.description { color: var(--muted); margin: 0; font-size: 15px; }
.field { margin-bottom: 21px; }
.field > label { display: block; margin-bottom: 8px; font-size: 16px; }
.prompt { color: var(--accent); margin-right: 7px; }
.label-hint { color: var(--muted); font-size: 14px; }
input:not([type="checkbox"]) { width: 100%; min-height: 50px; padding: 12px 14px; background: var(--input-bg); border: 1px solid var(--border); border-radius: 0; color: var(--text); caret-color: var(--accent); outline: none; box-shadow: none; font-size: 16px; }
input:not([type="checkbox"]):hover { border-color: var(--muted); }
input:not([type="checkbox"]):focus { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
input::placeholder { color: var(--muted); opacity: .7; }
input[aria-invalid="true"]:not([type="checkbox"]) { border-color: var(--error); }
input:-webkit-autofill { -webkit-text-fill-color: var(--text); box-shadow: 0 0 0 1000px var(--input-bg) inset; }
.password-wrap { position: relative; }
.password-wrap input { padding-right: 72px; }
.reveal { position: absolute; right: 5px; top: 5px; bottom: 5px; padding: 0 10px; background: transparent; color: var(--muted); border: 0; font-size: 12px; letter-spacing: .5px; }
.reveal:hover { color: var(--accent); }
.form-options { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 10px; margin: 1px 0 26px; font-size: 14px; }
.checkbox { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; color: var(--muted); }
input[type="checkbox"] { width: 16px; height: 16px; margin: 0; accent-color: var(--accent); cursor: pointer; }
.primary { width: 100%; min-height: 50px; display: flex; align-items: center; justify-content: space-between; padding: 11px 16px; background: var(--accent); color: var(--accent-ink); border: 1px solid var(--accent); border-radius: 0; font-weight: bold; transition: filter .15s; }
.primary:hover { filter: brightness(1.1); }
.primary:active { transform: translateY(1px); }
.primary span:last-child { font-size: 20px; font-weight: normal; line-height: 1; }
.divider { display: flex; align-items: center; gap: 15px; margin: 24px 0 18px; color: var(--muted); font-size: 11px; letter-spacing: 1px; }
.divider::before, .divider::after { content: ""; height: 1px; background: var(--border); flex: 1; }
.alternatives { display: flex; gap: 12px; }
.secondary { display: inline-flex; justify-content: center; align-items: center; gap: 10px; min-height: 48px; padding: 10px 15px; background: transparent; color: var(--text); border: 1px solid var(--border); border-radius: 0; font-size: 16px; text-decoration: none; }
.alternatives .secondary { flex: 1; }
.secondary:hover { border-color: var(--accent); color: var(--accent); background: #ffffff03; }
.secondary > span { color: var(--muted); font-size: 16px; }
.google-icon { font-family: sans-serif; font-weight: bold; }
.notice { border-left: 2px solid var(--accent); padding: 10px 13px; background: #ffffff04; color: var(--text); font-size: 15px; margin: 0 0 22px; overflow-wrap: anywhere; }
.notice.error { border-color: var(--error); color: var(--error); }
.field-error { color: var(--error); font-size: 14px; margin: -8px 0 18px; }
.field-help { margin: 10px 0 0; color: var(--muted); font-size: 14px; }
input.otp-input { letter-spacing: 10px; font-size: 24px; padding: 15px; }
.back-link { display: block; text-align: center; margin-top: 24px; font-size: 15px; }
.success-panel { text-align: center; padding: 15px 0; }
.success-mark { color: var(--accent); font-size: 32px; }
@media (max-width: 580px) {
.site-header { padding: 20px; }
.page-main { padding: 25px 16px 40px; }
.terminal-body { padding: 24px; }
}
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }
@@ -1,130 +0,0 @@
/* Adapt the native Keycloak 26.5.6 form components to the shared design. */
html.login-pf {
color-scheme: dark;
--pf-v5-global--FontFamily--text: var(--font);
--pf-v5-global--FontFamily--heading: var(--font);
--pf-v5-global--FontFamily--monospace: var(--font);
--pf-v5-global--FontSize--md: 16px;
--pf-v5-global--Color--100: var(--text);
--pf-v5-global--Color--200: var(--muted);
--pf-v5-global--primary-color--100: var(--accent);
--pf-v5-global--primary-color--200: var(--accent);
--pf-v5-global--link--Color: var(--accent);
--pf-v5-global--link--Color--hover: var(--text);
--pf-v5-global--BackgroundColor--100: var(--surface);
--pf-v5-global--BackgroundColor--200: var(--input-bg);
--pf-v5-global--BorderColor--100: var(--border);
--pf-v5-global--danger-color--100: var(--error);
--pf-v5-global--success-color--100: var(--accent);
}
html.login-pf body#keycloak-bg {
background: var(--bg);
color: var(--text);
font: 16px/1.6 var(--font);
height: auto;
}
.hexor-heading { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding: 30px 36px 0; }
.hexor-title { margin: 0; font: 500 clamp(28px, 6vw, 34px)/1.3 var(--font); letter-spacing: -1px; }
.hexor-title::after { content: "_"; color: var(--accent); animation: blink 1.2s step-end infinite; }
.hexor-body { padding: 28px 36px 34px; }
.hexor-body > :last-child { margin-bottom: 0; }
.hexor-locale { max-width: 100%; }
.hexor-locale select { max-width: 180px; }
.hexor-body .pf-v5-c-form { gap: 21px; }
.hexor-body .pf-v5-c-form__group { gap: 8px; }
.hexor-body .pf-v5-c-form__group-label { padding-bottom: 0 !important; }
.hexor-body .pf-v5-c-form__label { color: var(--text); font-size: 16px; line-height: 1.6; }
.hexor-body .pf-v5-c-form__label-text::before { content: ">"; color: var(--accent); margin-inline-end: 12px; }
.hexor-body .pf-v5-c-form__helper-text { margin-top: 10px; }
.hexor-body .pf-v5-c-helper-text { gap: 10px; flex-wrap: wrap; }
.hexor-body .pf-v5-c-helper-text__item-text { color: var(--muted); font-size: 14px; }
.hexor-body .pf-v5-c-check { color: var(--muted); font-size: 14px; align-items: center; }
.hexor-body .pf-v5-c-check__label { color: var(--muted); font-size: 14px; }
.hexor-body .pf-v5-c-form-control, .hexor-locale .pf-v5-c-form-control {
background: var(--input-bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 0;
--pf-v5-c-form-control--after--BorderBottomWidth: 0;
--pf-v5-c-form-control--before--BorderWidth: 0;
--pf-v5-c-form-control--Height: auto;
}
.hexor-body .pf-v5-c-form-control::before,
.hexor-body .pf-v5-c-form-control::after,
.hexor-locale .pf-v5-c-form-control::before,
.hexor-locale .pf-v5-c-form-control::after { display: none; }
.hexor-body .pf-v5-c-form-control:focus-within { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.hexor-body .pf-v5-c-form-control.pf-m-error { border-color: var(--error); }
.hexor-body .pf-v5-c-form-control > :is(input, textarea, select),
.hexor-locale .pf-v5-c-form-control > select {
border: 0;
border-radius: 0;
padding: 12px 14px;
min-height: 50px;
background: transparent;
color: var(--text);
font: 16px/1.6 var(--font);
box-shadow: none;
}
.hexor-body .pf-v5-c-form-control > input:focus { box-shadow: none; }
.hexor-body .pf-v5-c-form-control input[readonly] { color: var(--muted); }
.hexor-body .pf-v5-c-input-group { gap: 0; }
.hexor-body .pf-v5-c-input-group__item { min-width: 0; }
.hexor-body .pf-v5-c-form-control__utilities { background: transparent; color: var(--muted); }
.hexor-body .pf-v5-c-button {
min-height: 48px;
border: 1px solid var(--border);
border-radius: 0;
padding: 10px 15px;
background: transparent;
color: var(--text);
font: 16px/1.6 var(--font);
box-shadow: none;
text-decoration: none;
white-space: normal;
}
.hexor-body .pf-v5-c-button::after { display: none; }
.hexor-body .pf-v5-c-button:hover { border-color: var(--accent); color: var(--accent); }
.hexor-body .pf-v5-c-button:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
.hexor-body .pf-v5-c-button.pf-m-primary {
width: 100%;
min-height: 50px;
background: var(--accent);
border-color: var(--accent);
color: var(--accent-ink);
font-weight: bold;
}
.hexor-body .pf-v5-c-button.pf-m-primary:hover { filter: brightness(1.1); }
.hexor-body .pf-v5-c-button:disabled, .hexor-body .pf-m-aria-disabled { opacity: .5; pointer-events: none; }
.hexor-body .pf-v5-c-button.pf-m-control { min-width: 46px; height: 100%; color: var(--muted); background: var(--input-bg); }
.hexor-body .pf-v5-c-button.pf-m-link { min-height: auto; border-color: transparent; padding-inline: 0; color: var(--accent); }
.hexor-body .pf-v5-c-form__actions { padding-top: 0 !important; }
.hexor-body a:not(.pf-v5-c-button) { color: var(--muted); }
.hexor-body a:not(.pf-v5-c-button):hover { color: var(--accent); }
.hexor-body .pf-v5-c-alert { border: 0; border-inline-start: 2px solid var(--accent); padding: 10px 13px; background: #ffffff04; color: var(--text); box-shadow: none; }
.hexor-body .pf-v5-c-alert.pf-m-danger { border-color: var(--error); }
.hexor-body .pf-v5-c-alert__icon { display: none; }
.hexor-body .pf-v5-c-alert__title { color: var(--text); font: 15px/1.6 var(--font); }
.hexor-body .pf-v5-c-alert.pf-m-danger .pf-v5-c-alert__title,
.hexor-body .pf-v5-c-helper-text__item.pf-m-error .pf-v5-c-helper-text__item-text { color: var(--error); }
.hexor-footer:empty { display: none; }
.hexor-footer-band { padding: 0; margin-top: 22px; color: var(--muted); font-size: 14px; }
.hexor-divider-label { display: flex; align-items: center; gap: 15px; margin: 24px 0 18px; font-size: 11px; letter-spacing: 1px; }
.hexor-divider-label::before, .hexor-divider-label::after { content: ""; flex: 1; height: 1px; background: var(--border); }
#kc-social-providers ul { display: flex; flex-wrap: wrap; gap: 12px; padding: 0; margin: 0; list-style: none; }
#kc-social-providers li { flex: 1 1 160px; padding: 0; }
#kc-social-providers a { display: flex; justify-content: center !important; align-items: center; gap: 10px; }
#kc-social-providers svg { width: 20px; height: 20px; flex-shrink: 0; fill: currentColor; }
#kc-social-providers svg:not(.google) { filter: none; }
#kc-social-providers .pf-v5-u-m-auto { margin: 0 !important; }
#kc-select-try-another-way-form { margin-top: 20px; }
#kc-info-wrapper, #kc-registration { color: var(--muted); font-size: 14px; }
#kc-otp-login-form #otp { font-size: 24px; letter-spacing: 8px; }
.hexor-body :is(.pf-v5-c-tile, .pf-v5-c-data-list__item, .pf-v5-c-panel) { background: var(--input-bg); color: var(--text); border: 1px solid var(--border); box-shadow: none; }
.hexor-body .pf-v5-c-tile.pf-m-selected { border-color: var(--accent); }
.hexor-body img, .hexor-body canvas, .hexor-body svg { max-width: 100%; }
.hexor-body pre { overflow-wrap: anywhere; white-space: pre-wrap; }
@media (max-width: 580px) {
.hexor-heading { padding: 24px 24px 0; }
.hexor-body { padding: 24px; }
}
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" fill="#0c0f0d"/><text x="7" y="43" fill="#b9ef70" font-family="monospace" font-size="34">&gt;_</text></svg>

Before

Width:  |  Height:  |  Size: 197 B

@@ -1,126 +0,0 @@
/* Decorative code stream. Brightness: --code-opacity in css/hexor.css. */
(() => {
"use strict";
const canvas = document.querySelector("#code-background");
const context = canvas?.getContext("2d");
if (!context) return;
const source = [
"use std::sync::Arc;",
"use tokio::net::TcpListener;",
"",
"async fn main() -> Result<()> {",
" let config = Config::load()?;",
" let state = Arc::new(State::new());",
" let router = routes(state.clone());",
"",
" server::bind(config.address)",
" .serve(router)",
" .await?;",
" Ok(())",
"}",
"",
"impl Session {",
" pub fn verify(&self) -> bool {",
" self.expires_at > clock::now()",
" && self.signature.is_valid()",
" }",
"}",
"",
"fn routes(state: Arc<State>) -> Router {",
" Router::new()",
' .route("/", get(index))',
' .route("/session", post(create))',
" .with_state(state)",
"}",
"",
"match identity.authenticate().await {",
" Ok(session) => session.commit(),",
" Err(error) => trace!(?error),",
"}",
"",
"let transport = Transport::builder()",
" .with_timeout(Duration::from_secs(30))",
" .with_keepalive(true)",
" .connect(endpoint).await?;",
"",
];
const motion = window.matchMedia("(prefers-reduced-motion: reduce)");
const lineHeight = 24;
let streams = [];
let width = 0;
let height = 0;
let color;
function updateColor() {
color = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim();
}
function resize() {
width = window.innerWidth;
height = window.innerHeight;
const ratio = Math.min(window.devicePixelRatio || 1, 1.5);
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
context.setTransform(ratio, 0, 0, ratio, 0, 0);
context.font = '13px "SFMono-Regular", Consolas, "Liberation Mono", monospace';
const columns = Math.max(1, Math.ceil(width / 470));
const rows = Math.ceil(height / lineHeight);
streams = Array.from({ length: columns }, (_, column) => {
const start = Math.floor(Math.random() * source.length);
const count = Math.max(4, rows - 7 + Math.floor(Math.random() * 5));
return {
x: column * (width / columns) + 24,
y: 44 - Math.random() * 80,
lines: Array.from({ length: count }, (_, i) => source[(start + i) % source.length]),
next: (start + count) % source.length,
typed: Math.random() * 12,
pause: 0,
number: 20 + start,
speed: .55 + Math.random() * .6,
};
});
draw(false);
}
function draw(advance) {
context.clearRect(0, 0, width, height);
context.fillStyle = color;
for (const stream of streams) {
const current = source[stream.next];
if (advance) {
if (stream.typed < current.length) stream.typed += stream.speed;
else if (++stream.pause > 8) {
stream.lines.push(current);
stream.next = (stream.next + 1) % source.length;
stream.typed = 0;
stream.pause = 0;
}
// Scroll gently as new lines reach the bottom of the viewport.
const overflow = stream.y + (stream.lines.length + 2) * lineHeight - height;
if (overflow > 0) stream.y -= Math.min(1.5, overflow);
if (stream.y < -lineHeight) {
stream.lines.shift();
stream.y += lineHeight;
stream.number++;
}
}
stream.lines.concat(current.slice(0, Math.floor(stream.typed))).forEach((line, i) => {
const y = stream.y + i * lineHeight;
context.globalAlpha = .3;
context.fillText(String(stream.number + i).padStart(3, "0"), stream.x, y);
context.globalAlpha = i === stream.lines.length ? .95 : .6;
context.fillText(line, stream.x + 42, y);
});
}
context.globalAlpha = 1;
}
updateColor();
resize();
window.addEventListener("resize", resize);
// Ten frames per second is enough for slow typing; no work in hidden tabs.
setInterval(() => {
if (!document.hidden && !motion.matches) draw(true);
}, 100);
})();
@@ -1,247 +0,0 @@
<#-- Based on Keycloak 26.5.6 keycloak.v2/login/template.ftl (Apache-2.0). -->
<#import "field.ftl" as field>
<#import "footer.ftl" as loginFooter>
<#macro username>
<#assign label>
<#if !realm.loginWithEmailAllowed>${msg("username")}<#elseif !realm.registrationEmailAsUsername>${msg("usernameOrEmail")}<#else>${msg("email")}</#if>
</#assign>
<@field.group name="username" label=label>
<div class="${properties.kcInputGroup}">
<div class="${properties.kcInputGroupItemClass} ${properties.kcFill}">
<span class="${properties.kcInputClass} ${properties.kcFormReadOnlyClass}">
<input id="kc-attempted-username" value="${auth.attemptedUsername}" readonly>
</span>
</div>
<div class="${properties.kcInputGroupItemClass}">
<button id="reset-login" class="${properties.kcFormPasswordVisibilityButtonClass} kc-login-tooltip" type="button"
aria-label="${msg('restartLoginTooltip')}" onclick="location.href='${url.loginRestartFlowUrl}'">
<i class="fa-sync-alt fas" aria-hidden="true"></i>
<span class="kc-tooltip-text">${msg("restartLoginTooltip")}</span>
</button>
</div>
</div>
</@field.group>
</#macro>
<#macro registrationLayout bodyClass="" displayInfo=false displayMessage=true displayRequiredFields=false>
<!DOCTYPE html>
<html class="${properties.kcHtmlClass!}" lang="${lang}"<#if realm.internationalizationEnabled> dir="${(locale.rtl)?then('rtl','ltr')}"</#if>>
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="color-scheme" content="dark">
<meta name="viewport" content="width=device-width, initial-scale=1">
<#if properties.meta?has_content>
<#list properties.meta?split(' ') as meta>
<meta name="${meta?split('==')[0]}" content="${meta?split('==')[1]}"/>
</#list>
</#if>
<title>${msg("loginTitle",(realm.displayName!''))}</title>
<link rel="icon" href="${url.resourcesPath}/img/favicon.svg" />
<#if properties.stylesCommon?has_content>
<#list properties.stylesCommon?split(' ') as style>
<link href="${url.resourcesCommonPath}/${style}" rel="stylesheet" />
</#list>
</#if>
<#if properties.styles?has_content>
<#list properties.styles?split(' ') as style>
<link href="${url.resourcesPath}/${style}" rel="stylesheet" />
</#list>
</#if>
<script type="importmap">
{
"imports": {
"rfc4648": "${url.resourcesCommonPath}/vendor/rfc4648/rfc4648.js"
}
}
</script>
<#if properties.scripts?has_content>
<#list properties.scripts?split(' ') as script>
<script src="${url.resourcesPath}/${script}" defer></script>
</#list>
</#if>
<#if scripts??>
<#list scripts as script>
<script src="${script}" type="text/javascript"></script>
</#list>
</#if>
<script type="module" src="${url.resourcesPath}/js/passwordVisibility.js"></script>
<script type="module">
<#outputformat "JavaScript">
import { startSessionPolling } from "${url.resourcesPath}/js/authChecker.js";
startSessionPolling(
${url.ssoLoginInOtherTabsUrl?c}
);
</#outputformat>
</script>
<script type="module">
document.addEventListener("click", (event) => {
const link = event.target.closest("a[data-once-link]");
if (!link) {
return;
}
if (link.getAttribute("aria-disabled") === "true") {
event.preventDefault();
return;
}
const { disabledClass } = link.dataset;
if (disabledClass) {
link.classList.add(...disabledClass.trim().split(/\s+/));
}
link.setAttribute("role", "link");
link.setAttribute("aria-disabled", "true");
});
</script>
<#if authenticationSession??>
<script type="module">
<#outputformat "JavaScript">
import { checkAuthSession } from "${url.resourcesPath}/js/authChecker.js";
checkAuthSession(
${authenticationSession.authSessionIdHash?c}
);
</#outputformat>
</script>
</#if>
<script>
// Workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1404468
const isFirefox = true;
</script>
</head>
<body id="keycloak-bg" class="${properties.kcBodyClass!}" data-page-id="login-${pageId}">
<canvas id="code-background" aria-hidden="true"></canvas>
<header class="site-header"><span class="wordmark">Hexor Lab</span></header>
<div class="${properties.kcLogin!} page-main">
<div class="${properties.kcLoginContainer!}">
<main class="${properties.kcLoginMain!} terminal">
<div class="titlebar"><span><span aria-hidden="true">&gt;_</span> auth@hexor</span></div>
<div class="${properties.kcLoginMainHeader!}">
<h1 class="${properties.kcLoginMainTitle!}" id="kc-page-title"><#nested "header"></h1>
<#if realm.internationalizationEnabled && locale.supported?size gt 1>
<div class="${properties.kcLoginMainHeaderUtilities!}">
<div class="${properties.kcInputClass!}">
<select
aria-label="${msg("languages")}"
id="login-select-toggle"
onchange="if (this.value) window.location.href=this.value"
>
<#list locale.supported?sort_by("label") as l>
<option
value="${l.url}"
${(l.languageTag == locale.currentLanguageTag)?then('selected','')}
>
${l.label}
</option>
</#list>
</select>
<span class="${properties.kcFormControlUtilClass}">
<span class="${properties.kcFormControlToggleIcon!}">
<svg
class="pf-v5-svg"
viewBox="0 0 320 512"
fill="currentColor"
aria-hidden="true"
role="img"
width="1em"
height="1em"
>
<path
d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z"
>
</path>
</svg>
</span>
</span>
</div>
</div>
</#if>
</div>
<div class="${properties.kcLoginMainBody!}">
<#if !(auth?has_content && auth.showUsername() && !auth.showResetCredentials())>
<#if displayRequiredFields>
<div class="${properties.kcContentWrapperClass!}">
<div class="${properties.kcLabelWrapperClass!} subtitle">
<span class="${properties.kcInputHelperTextItemTextClass!}">
<span class="${properties.kcInputRequiredClass!}">*</span> ${msg("requiredFields")}
</span>
</div>
</div>
</#if>
<#else>
<#if displayRequiredFields>
<div class="${properties.kcContentWrapperClass!}">
<div class="${properties.kcLabelWrapperClass!} subtitle">
<span class="${properties.kcInputHelperTextItemTextClass!}">
<span class="${properties.kcInputRequiredClass!}">*</span> ${msg("requiredFields")}
</span>
</div>
<div class="${properties.kcFormClass} ${properties.kcContentWrapperClass}">
<#nested "show-username">
<@username />
</div>
</div>
<#else>
<div class="${properties.kcFormClass} ${properties.kcContentWrapperClass}">
<#nested "show-username">
<@username />
</div>
</#if>
</#if>
<#-- App-initiated actions should not see warning messages about the need to complete the action -->
<#-- during login. -->
<#if displayMessage && message?has_content && (message.type != 'warning' || !isAppInitiatedAction??)>
<div class="${properties.kcAlertClass!} pf-m-${(message.type = 'error')?then('danger', message.type)}">
<div class="${properties.kcAlertIconClass!}">
<#if message.type = 'success'><span class="${properties.kcFeedbackSuccessIcon!}"></span></#if>
<#if message.type = 'warning'><span class="${properties.kcFeedbackWarningIcon!}"></span></#if>
<#if message.type = 'error'><span class="${properties.kcFeedbackErrorIcon!}"></span></#if>
<#if message.type = 'info'><span class="${properties.kcFeedbackInfoIcon!}"></span></#if>
</div>
<span class="${properties.kcAlertTitleClass!} kc-feedback-text">${message.summary}</span>
</div>
</#if>
<#nested "form">
<#if auth?has_content && auth.showTryAnotherWayLink()>
<form id="kc-select-try-another-way-form" action="${url.loginAction}" method="post" novalidate="novalidate">
<input type="hidden" name="tryAnotherWay" value="on"/>
<a id="try-another-way" href="javascript:document.forms['kc-select-try-another-way-form'].requestSubmit()"
class="${properties.kcButtonSecondaryClass} ${properties.kcButtonBlockClass} ${properties.kcMarginTopClass}">
${msg("doTryAnotherWay")}
</a>
</form>
</#if>
<div class="${properties.kcLoginMainFooter!}">
<#nested "socialProviders">
<#if displayInfo>
<div id="kc-info" class="${properties.kcLoginMainFooterBand!} ${properties.kcFormClass}">
<div id="kc-info-wrapper" class="${properties.kcLoginMainFooterBandItem!}">
<#nested "info">
</div>
</div>
</#if>
</div>
</div>
<div class="${properties.kcLoginMainFooter!}">
<@loginFooter.content/>
</div>
</main>
</div>
</div>
</body>
</html>
</#macro>
@@ -1,18 +0,0 @@
parent=keycloak.v2
import=common/keycloak
styles=css/styles.css css/hexor.css css/keycloak.css
scripts=js/background.js
darkMode=false
# Own layout; inherit Keycloak's form fields, authentication and translations.
kcLogin=hexor-page
kcLoginContainer=terminal-wrap
kcLoginMain=hexor-main
kcLoginMainHeader=hexor-heading
kcLoginMainTitle=hexor-title
kcLoginMainBody=hexor-body
kcLoginMainHeaderUtilities=hexor-locale
kcLoginMainFooter=hexor-footer
kcLoginMainFooterBand=hexor-footer-band
kcLoginMainFooterBandItem=hexor-footer-item
kcLoginMainFooterHelperText=hexor-divider-label
-89
View File
@@ -1,89 +0,0 @@
replicas: 1
nodeSelector:
homelab.hexor.cy/pool: core
homelab.hexor.cy/exposure: external
image:
repository: quay.io/keycloak/keycloak
tag: "26.5.6"
command:
- "/opt/keycloak/bin/kc.sh"
- "start"
- "--http-port=8080"
- "--hostname-strict=false"
- "--proxy-headers=xforwarded"
extraEnvFrom: |
- secretRef:
name: keycloak-creds
- secretRef:
name: keycloak-bootstrap
extraEnv: |
- name: KC_HOSTNAME
value: auth.hexor.cy
- name: JAVA_OPTS_APPEND
value: "-Djgroups.dns.query=keycloak-keycloakx-headless.keycloak.svc"
extraVolumes: |
- name: hexor-lab-theme
configMap:
name: keycloak-hexor-lab-theme
items:
- key: theme.properties
path: theme.properties
- key: template.ftl
path: template.ftl
- key: hexor.css
path: resources/css/hexor.css
- key: keycloak.css
path: resources/css/keycloak.css
- key: background.js
path: resources/js/background.js
- key: favicon.svg
path: resources/img/favicon.svg
extraVolumeMounts: |
- name: hexor-lab-theme
mountPath: /opt/keycloak/themes/hexor-lab/login
readOnly: true
dbchecker:
enabled: true
database:
vendor: postgres
hostname: main-rw.psql.svc
port: 5432
database: keycloak
existingSecret: keycloak-creds
existingSecretKey: KC_DB_PASSWORD
service:
type: ClusterIP
# HTTP is redirected to HTTPS by the cluster-wide Traefik default (core/traefik-config).
ingress:
enabled: true
ingressClassName: traefik
annotations:
cert-manager.io/cluster-issuer: letsencrypt
rules:
- host: auth.hexor.cy
paths:
- path: /
pathType: Prefix
tls:
- secretName: keycloak-tls
hosts:
- auth.hexor.cy
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: "3"
memory: 2Gi
-54
View File
@@ -1,54 +0,0 @@
# Main HA PostgreSQL cluster: one primary and one streaming replica, each on its
# own node with a node-local volume. Replication is asynchronous (nodes sit in
# different regions), so a failover can lose the last few transactions.
#
# Layout: the primary runs on the music node (the powerful one), the replica on
# master. A failover swaps the roles; switch back with
# kubectl cnpg promote main <instance on music>
#
# Eligible nodes are core/external and carry workload/postgres=true. To move a
# replica: label the new node, unlabel the old one, then recreate the instance
# (kubectl cnpg destroy main <N>); the operator re-clones it from the primary.
#
# Endpoints inside the cluster: main-rw (primary), main-ro (replicas),
# main-r (any instance) in the psql namespace.
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: main
namespace: psql
spec:
description: Main HA PostgreSQL cluster
imageName: ghcr.io/cloudnative-pg/postgresql:17
instances: 2
enableSuperuserAccess: false
# Roll updates replica-first and hand over by switchover instead of restarting the primary.
primaryUpdateStrategy: unsupervised
primaryUpdateMethod: switchover
storage:
storageClass: local-path
size: 20Gi # local-path volumes cannot be expanded later
affinity:
enablePodAntiAffinity: true
podAntiAffinityType: required
topologyKey: kubernetes.io/hostname
nodeSelector:
homelab.hexor.cy/pool: core
homelab.hexor.cy/exposure: external
workload/postgres: "true"
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
memory: 2Gi
postgresql:
parameters:
max_connections: "200"
shared_buffers: 512MB
effective_cache_size: 1536MB
@@ -1,86 +0,0 @@
# Database "furumi": password, role, database and read access for the app.
# To add another database copy this file and replace the name.
# Generated once; the role and the application both read this Secret.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: furumi-db
namespace: psql
spec:
refreshPolicy: CreatedOnce
target:
name: furumi-db
template:
type: kubernetes.io/basic-auth
metadata:
labels:
cnpg.io/reload: "true"
data:
username: furumi
password: "{{ .password }}"
dataFrom:
- sourceRef:
generatorRef:
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
name: db-password
---
apiVersion: postgresql.cnpg.io/v1
kind: DatabaseRole
metadata:
name: main-furumi
namespace: psql
spec:
cluster:
name: main
name: furumi
login: true
passwordSecret:
name: furumi-db
---
apiVersion: postgresql.cnpg.io/v1
kind: Database
metadata:
name: main-furumi
namespace: psql
spec:
cluster:
name: main
name: furumi
owner: furumi
extensions:
# `ensure` is defaulted by the CRD inside list items; spelled out so Argo CD
# does not see it as drift.
- name: pg_trgm
ensure: present
databaseReclaimPolicy: retain
---
# Lets the furumi namespace read only its own credentials Secret.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: furumi-db-reader
namespace: psql
rules:
- apiGroups: [""]
resources: [secrets]
resourceNames: [furumi-db]
verbs: [get, list, watch]
- apiGroups: [authorization.k8s.io]
resources: [selfsubjectrulesreviews]
verbs: [create]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: furumi-db-reader
namespace: psql
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: furumi-db-reader
subjects:
- kind: ServiceAccount
name: db-credentials-reader
namespace: furumi
Loaded 100 of 283 files, more files were not shown because too many files have changed in this diff. Show more