Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33a91223a3 | ||
|
|
87e7bcebcf | ||
|
|
cd8cb1f586 | ||
|
|
729e808b13 | ||
|
|
af717cc530 | ||
|
|
56c4c3871a | ||
|
|
8dd1868c25 | ||
|
|
5eaee8c7b7 | ||
|
|
7e7813bfe0 | ||
|
|
a79c695a40 | ||
|
|
29d95cd853 |
No files matched your search
@@ -0,0 +1,12 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,32 @@
|
||||
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
|
||||
@@ -0,0 +1,38 @@
|
||||
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
|
||||
@@ -0,0 +1,7 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
namespace: k8s-secret
|
||||
resources:
|
||||
- external-secret.yaml
|
||||
- reader.yaml
|
||||
- ingress.yaml
|
||||
@@ -0,0 +1,109 @@
|
||||
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
|
||||
@@ -0,0 +1,23 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,149 @@
|
||||
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
|
||||
@@ -0,0 +1,74 @@
|
||||
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
|
||||
@@ -0,0 +1,38 @@
|
||||
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
|
||||
@@ -0,0 +1,16 @@
|
||||
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
|
||||
@@ -0,0 +1,75 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,108 @@
|
||||
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
|
||||
@@ -0,0 +1,65 @@
|
||||
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
|
||||
@@ -0,0 +1,96 @@
|
||||
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
|
||||
@@ -77,9 +77,9 @@ apps:
|
||||
skipDryRunOnMissingResource: true
|
||||
- name: k8s-secrets
|
||||
project: apps
|
||||
path: k8s/apps/k8s-secrets
|
||||
path: k8s-ng/apps/k8s-secrets
|
||||
namespace: k8s-secret
|
||||
wave: 6 # after secret-stores and auth-proxy
|
||||
wave: 5 # after secret-stores and auth-proxy; independent of prom-stack health
|
||||
skipDryRunOnMissingResource: true
|
||||
- name: llamacpp
|
||||
project: apps
|
||||
@@ -126,3 +126,9 @@ apps:
|
||||
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
|
||||
@@ -1,366 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: keycloak-hacker-theme
|
||||
namespace: keycloak
|
||||
data:
|
||||
theme.properties: |
|
||||
parent=keycloak.v2
|
||||
import=common/keycloak
|
||||
|
||||
styles=css/hacker.css
|
||||
|
||||
hacker.css: |
|
||||
/* ============================================================
|
||||
HEXOR — Hacker Terminal Theme for Keycloak
|
||||
============================================================ */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;600&display=swap');
|
||||
|
||||
:root {
|
||||
--hk-bg: #0a0a0a;
|
||||
--hk-panel: #0d0d0d;
|
||||
--hk-border: #00ff4180;
|
||||
--hk-green: #00ff41;
|
||||
--hk-green-dim: #00cc33;
|
||||
--hk-green-glow: rgba(0, 255, 65, 0.15);
|
||||
--hk-green-glow-strong: rgba(0, 255, 65, 0.35);
|
||||
--hk-cyan: #00e5ff;
|
||||
--hk-red: #ff3333;
|
||||
--hk-text: #b0ffb0;
|
||||
--hk-text-dim: #5a8a5a;
|
||||
--hk-input-bg: #050505;
|
||||
--hk-font: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* -- Force dark always --------------------------------------------------- */
|
||||
html.login-pf,
|
||||
html.login-pf.pf-v5-theme-dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* -- Background: scanlines + CRT ---------------------------------------- */
|
||||
body#keycloak-bg {
|
||||
font-family: var(--hk-font) !important;
|
||||
background: var(--hk-bg) !important;
|
||||
color: var(--hk-text) !important;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body#keycloak-bg::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 255, 65, 0.03) 2px,
|
||||
rgba(0, 255, 65, 0.03) 4px
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
/* -- Login container ----------------------------------------------------- */
|
||||
.pf-v5-c-login {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.pf-v5-c-login__container {
|
||||
max-width: 480px !important;
|
||||
}
|
||||
|
||||
/* -- Header / Brand ------------------------------------------------------ */
|
||||
#kc-header-wrapper {
|
||||
font-family: var(--hk-font) !important;
|
||||
color: var(--hk-green) !important;
|
||||
text-shadow: 0 0 10px var(--hk-green), 0 0 30px var(--hk-green-glow);
|
||||
font-size: 1.5rem !important;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#kc-header-wrapper .kc-logo-text span::before {
|
||||
content: '> ';
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* -- Main card ----------------------------------------------------------- */
|
||||
.pf-v5-c-login__main {
|
||||
background: var(--hk-panel) !important;
|
||||
border: 1px solid var(--hk-border) !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow:
|
||||
0 0 15px var(--hk-green-glow),
|
||||
inset 0 0 30px rgba(0, 0, 0, 0.5) !important;
|
||||
}
|
||||
|
||||
/* -- Title --------------------------------------------------------------- */
|
||||
h1#kc-page-title,
|
||||
.pf-v5-c-title {
|
||||
font-family: var(--hk-font) !important;
|
||||
color: var(--hk-green) !important;
|
||||
text-shadow: 0 0 8px var(--hk-green-glow);
|
||||
font-weight: 600 !important;
|
||||
font-size: 1.25rem !important;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* -- Labels -------------------------------------------------------------- */
|
||||
.pf-v5-c-form__label-text {
|
||||
font-family: var(--hk-font) !important;
|
||||
color: var(--hk-green-dim) !important;
|
||||
font-size: 0.85rem !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
/* -- Input fields -------------------------------------------------------- */
|
||||
.pf-v5-c-form-control {
|
||||
background-color: var(--hk-input-bg) !important;
|
||||
border: 1px solid var(--hk-border) !important;
|
||||
border-radius: 0 !important;
|
||||
color: var(--hk-green) !important;
|
||||
font-family: var(--hk-font) !important;
|
||||
font-size: 0.9rem !important;
|
||||
caret-color: var(--hk-green);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.pf-v5-c-form-control:focus-within,
|
||||
.pf-v5-c-form-control:focus {
|
||||
border-color: var(--hk-green) !important;
|
||||
box-shadow: 0 0 8px var(--hk-green-glow), inset 0 0 4px var(--hk-green-glow) !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.pf-v5-c-form-control input {
|
||||
color: var(--hk-green) !important;
|
||||
font-family: var(--hk-font) !important;
|
||||
}
|
||||
|
||||
.pf-v5-c-form-control input::placeholder {
|
||||
color: var(--hk-text-dim) !important;
|
||||
}
|
||||
|
||||
/* -- Primary button (Sign In) -------------------------------------------- */
|
||||
.pf-v5-c-button.pf-m-primary {
|
||||
background: transparent !important;
|
||||
border: 1px solid var(--hk-green) !important;
|
||||
border-radius: 0 !important;
|
||||
color: var(--hk-green) !important;
|
||||
font-family: var(--hk-font) !important;
|
||||
font-weight: 600 !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 0.9rem !important;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pf-v5-c-button.pf-m-primary:hover {
|
||||
background: var(--hk-green-glow) !important;
|
||||
box-shadow: 0 0 15px var(--hk-green-glow-strong), inset 0 0 15px var(--hk-green-glow) !important;
|
||||
color: #fff !important;
|
||||
text-shadow: 0 0 5px var(--hk-green);
|
||||
}
|
||||
|
||||
/* -- Secondary button (Try Another Way, Passkey) ------------------------- */
|
||||
.pf-v5-c-button.pf-m-secondary {
|
||||
background: transparent !important;
|
||||
border: 1px solid var(--hk-cyan) !important;
|
||||
border-radius: 0 !important;
|
||||
color: var(--hk-cyan) !important;
|
||||
font-family: var(--hk-font) !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
font-size: 0.85rem !important;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.pf-v5-c-button.pf-m-secondary:hover {
|
||||
background: rgba(0, 229, 255, 0.1) !important;
|
||||
box-shadow: 0 0 12px rgba(0, 229, 255, 0.3) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* -- Passkey authenticate button ----------------------------------------- */
|
||||
#authenticateWebAuthnButton {
|
||||
background: transparent !important;
|
||||
border: 1px solid var(--hk-cyan) !important;
|
||||
border-radius: 0 !important;
|
||||
color: var(--hk-cyan) !important;
|
||||
font-family: var(--hk-font) !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
font-size: 0.85rem !important;
|
||||
padding: 0.75rem 1rem;
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
#authenticateWebAuthnButton::before {
|
||||
content: '\f084 ';
|
||||
font-family: 'Font Awesome 6 Free', 'FontAwesome';
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
#authenticateWebAuthnButton:hover {
|
||||
background: rgba(0, 229, 255, 0.1) !important;
|
||||
box-shadow: 0 0 15px rgba(0, 229, 255, 0.3), inset 0 0 10px rgba(0, 229, 255, 0.1) !important;
|
||||
color: #fff !important;
|
||||
text-shadow: 0 0 5px var(--hk-cyan);
|
||||
}
|
||||
|
||||
/* -- Show password button ------------------------------------------------ */
|
||||
.pf-v5-c-button.pf-m-control {
|
||||
background: var(--hk-input-bg) !important;
|
||||
border: 1px solid var(--hk-border) !important;
|
||||
border-left: none !important;
|
||||
border-radius: 0 !important;
|
||||
color: var(--hk-text-dim) !important;
|
||||
}
|
||||
|
||||
.pf-v5-c-button.pf-m-control:hover {
|
||||
color: var(--hk-green) !important;
|
||||
}
|
||||
|
||||
/* -- Social providers (Google, etc) -------------------------------------- */
|
||||
.pf-v5-c-login__main-footer {
|
||||
border-top: 1px solid var(--hk-border) !important;
|
||||
}
|
||||
|
||||
.kc-social-section .pf-v5-c-button.pf-m-secondary {
|
||||
border-color: var(--hk-text-dim) !important;
|
||||
color: var(--hk-text) !important;
|
||||
}
|
||||
|
||||
.kc-social-section .pf-v5-c-button.pf-m-secondary:hover {
|
||||
border-color: var(--hk-green) !important;
|
||||
color: var(--hk-green) !important;
|
||||
box-shadow: 0 0 10px var(--hk-green-glow) !important;
|
||||
}
|
||||
|
||||
/* -- Try another way link ------------------------------------------------ */
|
||||
#try-another-way {
|
||||
color: var(--hk-cyan) !important;
|
||||
text-decoration: none !important;
|
||||
font-family: var(--hk-font) !important;
|
||||
}
|
||||
|
||||
#try-another-way:hover {
|
||||
text-shadow: 0 0 8px rgba(0, 229, 255, 0.5);
|
||||
}
|
||||
|
||||
/* -- Links --------------------------------------------------------------- */
|
||||
a {
|
||||
color: var(--hk-cyan) !important;
|
||||
text-decoration: none;
|
||||
transition: text-shadow 0.2s;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-shadow: 0 0 6px rgba(0, 229, 255, 0.4);
|
||||
}
|
||||
|
||||
/* -- Alerts / messages --------------------------------------------------- */
|
||||
.pf-v5-c-alert {
|
||||
background: rgba(0, 255, 65, 0.05) !important;
|
||||
border: 1px solid var(--hk-border) !important;
|
||||
border-radius: 0 !important;
|
||||
font-family: var(--hk-font) !important;
|
||||
}
|
||||
|
||||
.pf-v5-c-alert.pf-m-danger {
|
||||
border-color: var(--hk-red) !important;
|
||||
background: rgba(255, 51, 51, 0.05) !important;
|
||||
}
|
||||
|
||||
.pf-v5-c-alert__title {
|
||||
color: var(--hk-text) !important;
|
||||
font-family: var(--hk-font) !important;
|
||||
}
|
||||
|
||||
/* -- Checkbox (Remember me) ---------------------------------------------- */
|
||||
.pf-v5-c-check__label {
|
||||
color: var(--hk-text-dim) !important;
|
||||
font-family: var(--hk-font) !important;
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
|
||||
/* -- Helper text (Forgot password, etc.) --------------------------------- */
|
||||
.pf-v5-c-helper-text .pf-v5-c-button.pf-m-link {
|
||||
color: var(--hk-cyan) !important;
|
||||
font-family: var(--hk-font) !important;
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
|
||||
/* -- Select auth list (Try another way options) -------------------------- */
|
||||
.select-auth-container {
|
||||
background: var(--hk-panel) !important;
|
||||
font-family: var(--hk-font) !important;
|
||||
}
|
||||
|
||||
.pf-v5-c-data-list__item {
|
||||
border-color: var(--hk-border) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.pf-v5-c-data-list__item:hover {
|
||||
background: var(--hk-green-glow) !important;
|
||||
}
|
||||
|
||||
.select-auth-box-headline {
|
||||
color: var(--hk-green) !important;
|
||||
}
|
||||
|
||||
.select-auth-box-desc {
|
||||
color: var(--hk-text-dim) !important;
|
||||
}
|
||||
|
||||
/* -- Footer -------------------------------------------------------------- */
|
||||
.pf-v5-c-login__main-footer-band {
|
||||
background: transparent !important;
|
||||
border-top: 1px solid var(--hk-border) !important;
|
||||
}
|
||||
|
||||
/* -- Language selector --------------------------------------------------- */
|
||||
select#login-select-toggle {
|
||||
background: var(--hk-input-bg) !important;
|
||||
color: var(--hk-green) !important;
|
||||
border-color: var(--hk-border) !important;
|
||||
font-family: var(--hk-font) !important;
|
||||
}
|
||||
|
||||
/* -- Blinking cursor animation for header -------------------------------- */
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
#kc-header-wrapper::after {
|
||||
content: '_';
|
||||
animation: blink 1s step-end infinite;
|
||||
color: var(--hk-green);
|
||||
}
|
||||
|
||||
/* -- Subtle CRT flicker ------------------------------------------------- */
|
||||
@keyframes flicker {
|
||||
0% { opacity: 0.97; }
|
||||
5% { opacity: 0.95; }
|
||||
10% { opacity: 0.98; }
|
||||
15% { opacity: 0.96; }
|
||||
20% { opacity: 0.99; }
|
||||
100% { opacity: 0.98; }
|
||||
}
|
||||
|
||||
.pf-v5-c-login__main {
|
||||
animation: flicker 4s infinite;
|
||||
}
|
||||
@@ -3,10 +3,24 @@ kind: Kustomization
|
||||
|
||||
resources:
|
||||
- secrets.yaml
|
||||
- hacker-theme-configmap.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
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/* 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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
../themes/hexor-lab/login/resources/js/background.js
|
||||
@@ -0,0 +1,62 @@
|
||||
<!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">>_</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">></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">></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">></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">></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>
|
||||
@@ -0,0 +1,11 @@
|
||||
.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; } }
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"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\"}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/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
@@ -0,0 +1 @@
|
||||
../themes/hexor-lab/login/resources/css/hexor.css
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,83 @@
|
||||
/* 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; } }
|
||||
@@ -0,0 +1,130 @@
|
||||
/* 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; }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<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">>_</text></svg>
|
||||
|
After Width: | Height: | Size: 197 B |
@@ -0,0 +1,126 @@
|
||||
/* 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);
|
||||
})();
|
||||
@@ -0,0 +1,247 @@
|
||||
<#-- 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">>_</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>
|
||||
@@ -0,0 +1,18 @@
|
||||
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
|
||||
@@ -28,17 +28,27 @@ extraEnv: |
|
||||
value: "-Djgroups.dns.query=keycloak-keycloakx-headless.keycloak.svc"
|
||||
|
||||
extraVolumes: |
|
||||
- name: hacker-theme
|
||||
- name: hexor-lab-theme
|
||||
configMap:
|
||||
name: keycloak-hacker-theme
|
||||
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: hacker-theme
|
||||
mountPath: /opt/keycloak/themes/hacker/login/theme.properties
|
||||
subPath: theme.properties
|
||||
- name: hacker-theme
|
||||
mountPath: /opt/keycloak/themes/hacker/login/css/hacker.css
|
||||
subPath: hacker.css
|
||||
- name: hexor-lab-theme
|
||||
mountPath: /opt/keycloak/themes/hexor-lab/login
|
||||
readOnly: true
|
||||
|
||||
dbchecker:
|
||||
enabled: true
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Database "jacob": 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: jacob-db
|
||||
namespace: psql
|
||||
spec:
|
||||
refreshPolicy: CreatedOnce
|
||||
target:
|
||||
name: jacob-db
|
||||
template:
|
||||
type: kubernetes.io/basic-auth
|
||||
metadata:
|
||||
labels:
|
||||
cnpg.io/reload: "true"
|
||||
data:
|
||||
username: jacob
|
||||
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-jacob
|
||||
namespace: psql
|
||||
spec:
|
||||
cluster:
|
||||
name: main
|
||||
name: jacob
|
||||
login: true
|
||||
passwordSecret:
|
||||
name: jacob-db
|
||||
---
|
||||
apiVersion: postgresql.cnpg.io/v1
|
||||
kind: Database
|
||||
metadata:
|
||||
name: main-jacob
|
||||
namespace: psql
|
||||
spec:
|
||||
cluster:
|
||||
name: main
|
||||
name: jacob
|
||||
owner: jacob
|
||||
databaseReclaimPolicy: retain
|
||||
extensions:
|
||||
- name: vector
|
||||
ensure: present
|
||||
@@ -10,3 +10,4 @@ resources:
|
||||
- databases/web-petting.yaml
|
||||
- databases/meguri.yaml
|
||||
- databases/grafana.yaml
|
||||
- databases/jacob.yaml
|
||||
@@ -274,6 +274,17 @@ kube-state-metrics:
|
||||
|
||||
prometheus-node-exporter:
|
||||
priorityClassName: monitoring-critical
|
||||
# `home` has stopped reporting to the cluster. A DaemonSet pod there keeps
|
||||
# prom-stack Progressing and blocks later core-state sync waves.
|
||||
# Remove this exclusion when the node rejoins.
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/hostname
|
||||
operator: NotIn
|
||||
values: [home]
|
||||
tolerations:
|
||||
- operator: Exists
|
||||
prometheus:
|
||||
|
||||
@@ -26,5 +26,8 @@ spec:
|
||||
value: ai
|
||||
effect: NoSchedule
|
||||
ports:
|
||||
web:
|
||||
# Keep Traefik's NodePort clear of the MTProxy hostPorts (30443/30444).
|
||||
nodePort: 30080
|
||||
websecure:
|
||||
asDefault: true
|
||||
@@ -8,7 +8,7 @@ resource "keycloak_realm" "hexor" {
|
||||
|
||||
display_name = "Hexor"
|
||||
|
||||
login_theme = "keycloak.v2"
|
||||
login_theme = "hexor-lab"
|
||||
account_theme = "keycloak.v3"
|
||||
|
||||
registration_allowed = false
|
||||
|
||||
Reference in new issue
Block a user