Compare commits

..
Author SHA1 Message Date
claude-botandClaude Opus 5.5 7e1b46108a fix(uptime-kuma): fail when a list event is missing after login
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
review-bot reviewed: comment
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Kuma 2.5.6 afterLogin (server/server.js:1839-1848) sends the
notification and API-key lists inside Promise.allSettled and acks the
login regardless. A missing notificationList was read as empty and
would create a second default "email" notification (double mails);
a missing apiKeyList would add a key without removing the old one.
The script now fails the run instead.

Verified against a local 2.5.6: fresh run creates everything, second
run reports no changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:34:24 +02:00
claude-botandClaude Opus 5.5 67aadbc0f9 feat(uptime-kuma): bootstrap settings, email notification, status page and metrics key
kuma-setup.js now logs in after the admin setup and converges on every run:
- settings: Trust Proxy on, Primary Base URL https://kuma.renner.uno.
  Read-modify-write; unset keys get the same defaults the Kuma settings page
  applies before saving (a raw getSettings payload has nscd unset, which makes
  setSettings stop nscd).
- notification "email" (SMTP, default enabled, applied to existing monitors):
  created or updated in place by name, never duplicated. Credentials from
  OpenBao homelab/infrastructure/uptime-kuma/smtp; recipient is the
  Alertmanager receiver admin@renner.uno; 465 means implicit TLS, else STARTTLS.
- status page "renner" / "Renner" with custom domain uptime.renner.uno. Only
  title and domain are enforced; saveStatusPage replaces all groups, so the
  current publicGroupList from /api/status-page/<slug> is sent back unchanged.
  Steady state does not call saveStatusPage at all.
- metrics API key "prometheus": Ansible checks the key stored at
  homelab/infrastructure/uptime-kuma-metrics against /metrics; if it is
  missing or rejected the script deletes all "prometheus" keys, creates a new
  one and hands it over via a 0600 file in the data dir, which Ansible writes
  to OpenBao (vault_kv2_write, community.hashi_vault 7.1.0 in the CI image)
  and then removes. The key never reaches stdout/stderr or the Ansible log.

The script is restructured into small async steps with per-emit timeouts.
The uptime-kuma-vps policy gains read on the smtp path and
read/create/update on the metrics path (must be applied by hand).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:25:43 +02:00
k.renner e614c748b9 Merge pull request 'fix(kuma-sync): create when the previous monitor ID belongs to another owner' (#1354) from fix/kuma-sync-foreign-key-create into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: k.renner/homelab#1354
2026-10-19 06:14:48 +00:00
security-bot 30b0f43911 chore(image-automation): bump nousresearch/hermes-agent:v2026.9.24
Updates managed by image-automation-controller.
2026-10-19 06:13:48 +00:00
k.renner 10819fac62 Merge pull request 'feat(hermes): run Hermes Agent in the cluster' (#1352) from feat/hermes-agent into master
Reviewed-on: k.renner/homelab#1352
2026-10-19 06:13:17 +00:00
claude-bot dd954ce23a feat(hermes): run Hermes Agent in the cluster
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
review-bot reviewed: request changes
Hermes Agent as a pod in its own `hermes` namespace: gateway (messaging
platforms, cron, OpenAI-compatible API) and dashboard, supervised by the
image's s6-overlay, state on a 10Gi ceph-block PVC.

- hermes.renner.uno (LAN): dashboard and Hermes Desktop remote gateway.
  Plain Traefik plus the dashboard's own Authentik OIDC login (public PKCE
  client, Hermes Users group) — a forward-auth gate would break Desktop's
  Bearer-token calls.
- hermes-api.renner.uno (LAN): API server, Bearer key generated in-cluster
  by an ESO Password generator.
- Read-only kubectl via the built-in `view` ClusterRole.
- CNPs: world 443/80, kube-apiserver, Traefik for LAN hostnames, and
  Prometheus/Alertmanager/Loki (with the matching monitoring-side grant).
- Image automation for nousresearch/hermes-agent v-tags.

After deploy: log in to Anthropic inside the pod
(`hermes auth add anthropic --type oauth --no-browser`, then `hermes model`);
platform and Home Assistant tokens go in via the dashboard.
Not yet covered: a Velero schedule for the PVC.
2026-10-19 08:02:56 +02:00
18 changed files with 922 additions and 33 deletions
+2
View File
@@ -5,6 +5,8 @@
# Prerequisites:
# - netbird-server play applied (docker, Traefik on netbird_netbird)
# - OpenBao homelab/infrastructure/uptime-kuma with keys username, password
# - OpenBao homelab/infrastructure/uptime-kuma/smtp with keys server, port, username, token
# - homelab/infrastructure/uptime-kuma-metrics (key api_key) is written by the role
# - VAULT_TOKEN in the environment (policy: uptime-kuma-vps)
# - DNS for kuma.renner.uno pointing at the VPS
+10 -1
View File
@@ -1,11 +1,20 @@
---
uptime_kuma_version: "2.5.6"
uptime_kuma_host: kuma.renner.uno
# Not routed yet; reserved for the public status page.
uptime_kuma_base_url: "https://{{ uptime_kuma_host }}"
# Custom domain of the status page; not routed by Traefik yet.
uptime_kuma_status_host: uptime.renner.uno
uptime_kuma_status_slug: renner
uptime_kuma_status_title: Renner
uptime_kuma_dir: /opt/uptime-kuma
uptime_kuma_traefik_network: netbird_netbird
uptime_kuma_smtp_from: uptime@renner.uno
# Alertmanager email receiver in kubernetes/apps/monitoring/helmrelease-prometheus.yaml.
uptime_kuma_smtp_to: admin@renner.uno
uptime_kuma_openbao_url: https://openbao.renner.uno
uptime_kuma_openbao_mount: homelab
uptime_kuma_openbao_path: infrastructure/uptime-kuma
uptime_kuma_openbao_smtp_path: infrastructure/uptime-kuma/smtp
uptime_kuma_openbao_metrics_path: infrastructure/uptime-kuma-metrics
+207 -28
View File
@@ -1,46 +1,225 @@
// Creates the Uptime Kuma admin user on first start; exits 0 if already set up.
// Credentials come from KUMA_SETUP_USERNAME / KUMA_SETUP_PASSWORD only.
// Bootstraps Uptime Kuma: admin user, general settings, SMTP notification, status page,
// and (when KUMA_METRICS_ROTATE=true) a fresh Prometheus API key written to KUMA_METRICS_KEY_FILE.
// All input comes from environment variables; secrets are never printed.
// Output lines: "admin user created" / "already set up", then "changed: <step>" or "ok: <step>".
const fs = require("fs");
const { io } = require("socket.io-client");
const username = process.env.KUMA_SETUP_USERNAME;
const password = process.env.KUMA_SETUP_PASSWORD;
if (!username || !password) {
console.error("KUMA_SETUP_USERNAME and KUMA_SETUP_PASSWORD must be set");
const KUMA_URL = "http://127.0.0.1:3001";
const EMIT_TIMEOUT_MS = 30000;
const TOTAL_TIMEOUT_MS = 180000;
// Defaults the Kuma settings page applies to unset keys before saving (src/pages/Settings.vue).
const SETTINGS_DEFAULTS = {
checkUpdate: true,
searchEngineIndex: false,
entryPage: "dashboard",
nscd: true,
keepDataPeriodDays: 180,
tlsExpiryNotifyDays: [7, 14, 21],
domainExpiryNotifyDays: [7, 14, 21],
trustProxy: false,
};
const REQUIRED = [
"KUMA_SETUP_USERNAME",
"KUMA_SETUP_PASSWORD",
"KUMA_BASE_URL",
"KUMA_SMTP_HOST",
"KUMA_SMTP_PORT",
"KUMA_SMTP_USERNAME",
"KUMA_SMTP_PASSWORD",
"KUMA_SMTP_FROM",
"KUMA_SMTP_TO",
"KUMA_STATUS_SLUG",
"KUMA_STATUS_TITLE",
"KUMA_STATUS_DOMAIN",
];
const missing = REQUIRED.filter((name) => !process.env[name]);
const rotateMetricsKey = process.env.KUMA_METRICS_ROTATE === "true";
if (rotateMetricsKey && !process.env.KUMA_METRICS_KEY_FILE) {
missing.push("KUMA_METRICS_KEY_FILE");
}
if (missing.length > 0) {
console.error(`missing environment variables: ${missing.join(", ")}`);
process.exit(2);
}
const env = process.env;
const socket = io("http://127.0.0.1:3001", { transports: ["websocket"], reconnection: false });
const timer = setTimeout(() => {
console.error("timeout: no response from Uptime Kuma within 30s");
process.exit(1);
}, 30000);
const socket = io(KUMA_URL, { transports: ["websocket"], reconnection: false });
const lists = {};
const LIST_EVENTS = ["notificationList", "apiKeyList", "statusPageList"];
for (const event of LIST_EVENTS) {
socket.on(event, (data) => {
lists[event] = data;
});
}
let finished = false;
function done(code, msg) {
if (finished) return;
finished = true;
clearTimeout(timer);
(code === 0 ? console.log : console.error)(msg);
clearTimeout(watchdog);
if (msg) (code === 0 ? console.log : console.error)(msg);
socket.close();
process.exit(code);
}
const watchdog = setTimeout(() => done(1, `timeout: bootstrap did not finish within ${TOTAL_TIMEOUT_MS / 1000}s`), TOTAL_TIMEOUT_MS);
socket.on("connect_error", (err) => done(1, `connect_error: ${err.message}`));
socket.on("disconnect", (reason) => done(1, `disconnected before setup finished: ${reason}`));
socket.on("disconnect", (reason) => done(1, `disconnected before bootstrap finished: ${reason}`));
// Kuma registers socket handlers only after emitting info.
socket.once("info", () => {
socket.emit("needSetup", (needSetup) => {
if (!needSetup) {
done(0, "already set up");
return;
}
socket.emit("setup", username, password, (res) => {
if (res && res.ok) {
done(0, "admin user created");
} else {
done(1, `setup failed: ${res && res.msg ? res.msg : JSON.stringify(res)}`);
}
function call(event, ...args) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`timeout waiting for ${event}`)), EMIT_TIMEOUT_MS);
socket.emit(event, ...args, (res) => {
clearTimeout(timer);
resolve(res);
});
});
});
}
async function callOk(event, ...args) {
const res = await call(event, ...args);
if (!res || !res.ok) {
throw new Error(`${event} failed: ${res && res.msg ? res.msg : JSON.stringify(res)}`);
}
return res;
}
function report(step, changed) {
console.log(`${changed ? "changed" : "ok"}: ${step}`);
}
// Kuma registers socket handlers only after emitting info.
function waitForInfo() {
return new Promise((resolve) => socket.once("info", resolve));
}
async function setupAdmin() {
const needSetup = await call("needSetup");
if (!needSetup) {
console.log("already set up");
return;
}
await callOk("setup", env.KUMA_SETUP_USERNAME, env.KUMA_SETUP_PASSWORD);
console.log("admin user created");
}
async function login() {
await callOk("login", { username: env.KUMA_SETUP_USERNAME, password: env.KUMA_SETUP_PASSWORD, token: "" });
// afterLogin sends the lists before the login ack but swallows send failures.
for (const event of LIST_EVENTS) {
if (lists[event] === undefined) {
throw new Error(`${event} not received after login`);
}
}
}
async function ensureSettings() {
const { data } = await callOk("getSettings");
if (data.trustProxy === true && data.primaryBaseURL === env.KUMA_BASE_URL) {
report("settings", false);
return;
}
const updated = { ...SETTINGS_DEFAULTS, ...data, trustProxy: true, primaryBaseURL: env.KUMA_BASE_URL };
await callOk("setSettings", updated, "");
report("settings", true);
}
async function ensureNotification() {
const port = Number(env.KUMA_SMTP_PORT);
const desired = {
name: "email",
type: "smtp",
isDefault: true,
smtpHost: env.KUMA_SMTP_HOST,
smtpPort: port,
// 465 is implicit TLS; any other port uses STARTTLS.
smtpSecure: port === 465,
smtpIgnoreSTARTTLS: false,
smtpUsername: env.KUMA_SMTP_USERNAME,
smtpPassword: env.KUMA_SMTP_PASSWORD,
smtpFrom: env.KUMA_SMTP_FROM,
smtpTo: env.KUMA_SMTP_TO,
};
const existing = (lists.notificationList || []).filter((n) => n.name === desired.name).sort((a, b) => a.id - b.id)[0];
const current = existing ? JSON.parse(existing.config || "{}") : {};
const upToDate =
existing && existing.isDefault === true && Object.keys(desired).every((key) => current[key] === desired[key]);
if (upToDate) {
report("notification", false);
return;
}
await callOk("addNotification", { ...current, ...desired, applyExisting: true }, existing ? existing.id : null);
report("notification", true);
}
async function fetchPublicGroupList(slug) {
const res = await fetch(`${KUMA_URL}/api/status-page/${encodeURIComponent(slug)}`);
if (!res.ok) {
throw new Error(`GET /api/status-page/${slug} returned ${res.status}`);
}
const body = await res.json();
return (body.publicGroupList || []).map((group) => ({
id: group.id,
name: group.name,
monitorList: (group.monitorList || []).map((monitor) => {
const entry = { id: monitor.id };
if (monitor.sendUrl !== undefined) entry.sendUrl = monitor.sendUrl;
if (monitor.url !== undefined) entry.url = monitor.url;
return entry;
}),
}));
}
async function ensureStatusPage() {
const slug = env.KUMA_STATUS_SLUG;
let changed = false;
const pages = Object.values(lists.statusPageList || {});
if (!pages.some((page) => page.slug === slug)) {
await callOk("addStatusPage", env.KUMA_STATUS_TITLE, slug);
changed = true;
}
const { config } = await callOk("getStatusPage", slug);
const domains = config.domainNameList || [];
if (config.title !== env.KUMA_STATUS_TITLE || !domains.includes(env.KUMA_STATUS_DOMAIN)) {
// saveStatusPage replaces all groups, so the current ones are sent back unchanged.
const publicGroupList = await fetchPublicGroupList(slug);
const updated = {
...config,
title: env.KUMA_STATUS_TITLE,
domainNameList: domains.includes(env.KUMA_STATUS_DOMAIN) ? domains : [...domains, env.KUMA_STATUS_DOMAIN],
};
await callOk("saveStatusPage", slug, updated, config.icon, publicGroupList);
changed = true;
}
report("status page", changed);
}
async function rotateMetricsApiKey() {
if (!rotateMetricsKey) {
report("metrics api key", false);
return;
}
for (const key of (lists.apiKeyList || []).filter((k) => k.name === "prometheus")) {
await callOk("deleteAPIKey", key.id);
}
const res = await callOk("addAPIKey", { name: "prometheus", expires: null, active: 1 });
fs.writeFileSync(env.KUMA_METRICS_KEY_FILE, res.key, { mode: 0o600 });
report("metrics api key", true);
}
async function main() {
await waitForInfo();
await setupAdmin();
await login();
await ensureSettings();
await ensureNotification();
await ensureStatusPage();
await rotateMetricsApiKey();
}
main().then(
() => done(0),
(err) => done(1, err.message)
);
+122 -3
View File
@@ -14,6 +14,33 @@
register: _uptime_kuma_secret
no_log: true
- name: Read SMTP credentials from OpenBao
community.hashi_vault.vault_kv2_get:
url: "{{ uptime_kuma_openbao_url }}"
engine_mount_point: "{{ uptime_kuma_openbao_mount }}"
path: "{{ uptime_kuma_openbao_smtp_path }}"
auth_method: token
delegate_to: localhost
become: false
run_once: true
register: _uptime_kuma_smtp
no_log: true
- name: Read metrics API key from OpenBao
community.hashi_vault.vault_kv2_get:
url: "{{ uptime_kuma_openbao_url }}"
engine_mount_point: "{{ uptime_kuma_openbao_mount }}"
path: "{{ uptime_kuma_openbao_metrics_path }}"
auth_method: token
delegate_to: localhost
become: false
run_once: true
register: _uptime_kuma_metrics_secret
failed_when:
- _uptime_kuma_metrics_secret.failed | default(false)
- "'Invalid or missing path' not in (_uptime_kuma_metrics_secret.msg | default(''))"
no_log: true
- name: Ensure Uptime Kuma directories exist
ansible.builtin.file:
path: "{{ item }}"
@@ -63,15 +90,80 @@
retries: 30
delay: 5
- name: Create the admin user if Uptime Kuma is not set up
ansible.builtin.command: docker compose exec -T -e KUMA_SETUP_USERNAME -e KUMA_SETUP_PASSWORD uptime-kuma node /app/kuma-setup.js
- name: Check the stored metrics API key against Uptime Kuma
ansible.builtin.uri:
url: http://127.0.0.1:3001/metrics
headers:
Authorization: "Basic {{ (':' ~ _uptime_kuma_metrics_key) | b64encode }}"
status_code: [200, 401]
vars:
_uptime_kuma_metrics_key: "{{ (_uptime_kuma_metrics_secret.secret | default({})).api_key | default('') }}"
when: _uptime_kuma_metrics_key | length > 0
register: _uptime_kuma_metrics_check
no_log: true
- name: Remove a leftover metrics API key handoff file
ansible.builtin.file:
path: "{{ uptime_kuma_dir }}/data/.metrics-api-key"
state: absent
- name: Bootstrap Uptime Kuma
ansible.builtin.command:
argv:
- docker
- compose
- exec
- -T
- -e
- KUMA_SETUP_USERNAME
- -e
- KUMA_SETUP_PASSWORD
- -e
- KUMA_BASE_URL
- -e
- KUMA_SMTP_HOST
- -e
- KUMA_SMTP_PORT
- -e
- KUMA_SMTP_USERNAME
- -e
- KUMA_SMTP_PASSWORD
- -e
- KUMA_SMTP_FROM
- -e
- KUMA_SMTP_TO
- -e
- KUMA_STATUS_SLUG
- -e
- KUMA_STATUS_TITLE
- -e
- KUMA_STATUS_DOMAIN
- -e
- KUMA_METRICS_ROTATE
- -e
- KUMA_METRICS_KEY_FILE
- uptime-kuma
- node
- /app/kuma-setup.js
args:
chdir: "{{ uptime_kuma_dir }}"
environment:
KUMA_SETUP_USERNAME: "{{ _uptime_kuma_secret.secret.username }}"
KUMA_SETUP_PASSWORD: "{{ _uptime_kuma_secret.secret.password }}"
KUMA_BASE_URL: "{{ uptime_kuma_base_url }}"
KUMA_SMTP_HOST: "{{ _uptime_kuma_smtp.secret.server }}"
KUMA_SMTP_PORT: "{{ _uptime_kuma_smtp.secret.port }}"
KUMA_SMTP_USERNAME: "{{ _uptime_kuma_smtp.secret.username }}"
KUMA_SMTP_PASSWORD: "{{ _uptime_kuma_smtp.secret.token }}"
KUMA_SMTP_FROM: "{{ uptime_kuma_smtp_from }}"
KUMA_SMTP_TO: "{{ uptime_kuma_smtp_to }}"
KUMA_STATUS_SLUG: "{{ uptime_kuma_status_slug }}"
KUMA_STATUS_TITLE: "{{ uptime_kuma_status_title }}"
KUMA_STATUS_DOMAIN: "{{ uptime_kuma_status_host }}"
KUMA_METRICS_ROTATE: "{{ 'false' if (_uptime_kuma_metrics_check.status | default(0)) == 200 else 'true' }}"
KUMA_METRICS_KEY_FILE: /app/data/.metrics-api-key
register: _uptime_kuma_setup
changed_when: "'admin user created' in _uptime_kuma_setup.stdout"
changed_when: "'admin user created' in _uptime_kuma_setup.stdout or 'changed:' in _uptime_kuma_setup.stdout"
failed_when: false
no_log: true
@@ -80,6 +172,33 @@
msg: "kuma-setup.js failed: rc={{ _uptime_kuma_setup.rc | default('undefined') }} stdout={{ _uptime_kuma_setup.stdout | default('') }} stderr={{ _uptime_kuma_setup.stderr | default('') }}"
when: (_uptime_kuma_setup.rc | default(1)) != 0
- name: Store the new metrics API key in OpenBao
when: "'changed: metrics api key' in _uptime_kuma_setup.stdout"
block:
- name: Read the new metrics API key
ansible.builtin.slurp:
src: "{{ uptime_kuma_dir }}/data/.metrics-api-key"
register: _uptime_kuma_new_metrics_key
no_log: true
- name: Write the metrics API key to OpenBao
community.hashi_vault.vault_kv2_write:
url: "{{ uptime_kuma_openbao_url }}"
engine_mount_point: "{{ uptime_kuma_openbao_mount }}"
path: "{{ uptime_kuma_openbao_metrics_path }}"
auth_method: token
data:
api_key: "{{ _uptime_kuma_new_metrics_key.content | b64decode }}"
delegate_to: localhost
become: false
run_once: true
no_log: true
always:
- name: Remove the metrics API key handoff file
ansible.builtin.file:
path: "{{ uptime_kuma_dir }}/data/.metrics-api-key"
state: absent
- name: Record setup completion
ansible.builtin.copy:
content: ""
@@ -974,6 +974,71 @@ data:
negate: false
failure_result: false
# Hermes Agent dashboard (apps/hermes): its self-hosted OIDC login,
# also brokered for Hermes Desktop's native sign-in. Public client +
# PKCE (Hermes does not support confidential clients); RS256 so the
# dashboard can verify the ID token against the JWKS.
- id: hermes-users-group
model: authentik_core.group
identifiers:
name: Hermes Users
state: present
attrs:
is_superuser: false
# Members get the agent, which holds a read-only kubeconfig and
# the model login. Add/remove members HERE.
users:
- !Find [authentik_core.user, [username, k.renner]]
- id: hermes-provider
model: authentik_providers_oauth2.oauth2provider
identifiers:
name: Hermes Dashboard
state: present
attrs:
authentication_flow: null
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
client_type: public
client_id: hermes-dashboard
# See the Matrix (MAS) provider: a blueprint-made provider starts
# with no grant types.
grant_types:
- authorization_code
- refresh_token
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
property_mappings:
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]]
redirect_uris:
- url: https://hermes.renner.uno/auth/callback
matching_mode: strict
# The slug is the issuer path: https://auth.renner.uno/application/o/hermes/
- model: authentik_core.application
identifiers:
slug: hermes
state: present
attrs:
name: Hermes
slug: hermes
provider: !KeyOf hermes-provider
meta_launch_url: https://hermes.renner.uno
meta_description: "Hermes Agent dashboard"
# Only the Hermes Users group passes.
- model: authentik_policies.policybinding
identifiers:
target: !Find [authentik_core.application, [slug, hermes]]
group: !KeyOf hermes-users-group
order: 0
state: present
attrs:
enabled: true
negate: false
failure_result: false
# OpenProject, decommissioned. Application first: Application.provider
# is on_delete=PROTECT. Its policy binding cascades with it.
- model: authentik_core.application
+32
View File
@@ -0,0 +1,32 @@
# Bearer key for the OpenAI-compatible API server (hermes-api.renner.uno).
# Generated once in-cluster, nothing in OpenBao. Read it with
# kubectl -n hermes get secret hermes-api-key -o jsonpath='{.data.API_SERVER_KEY}' | base64 -d
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
metadata:
name: hermes-api-key
namespace: hermes
spec:
length: 64
digits: 16
symbols: 0
allowRepeat: true
noUpper: false
secretKeys:
- API_SERVER_KEY
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: hermes-api-key
namespace: hermes
spec:
refreshPolicy: CreatedOnce
target:
name: hermes-api-key
dataFrom:
- sourceRef:
generatorRef:
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
name: hermes-api-key
+170
View File
@@ -0,0 +1,170 @@
# All Hermes state: config.yaml, .env (keys entered in the dashboard), auth.json
# (the Anthropic OAuth login), sessions, memories, skills, cron. Mounted at
# /opt/data, the image's HERMES_HOME.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: hermes-data
namespace: hermes
spec:
accessModes:
- ReadWriteOnce
storageClassName: ceph-block
resources:
requests:
storage: 10Gi
---
# Hermes Agent: the gateway (messaging platforms, cron, API server on 8642) and
# the dashboard (9119, also the Hermes Desktop remote-gateway backend), both
# supervised by the image's s6-overlay. s6 must be PID 1 and starts as root,
# remapping and dropping to the `hermes` user (10000).
#
# One replica, Recreate: two gateways must never share one data directory.
apiVersion: apps/v1
kind: Deployment
metadata:
name: hermes
namespace: hermes
labels:
app.kubernetes.io/name: hermes
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: hermes
template:
metadata:
labels:
app.kubernetes.io/name: hermes
spec:
serviceAccountName: hermes
securityContext:
fsGroup: 10000
seccompProfile:
type: RuntimeDefault
initContainers:
# The dashboard trusts X-Forwarded-* only from listed peers; Traefik
# connects from the pod network. No env equivalent exists, so it is
# set in config.yaml on every start (idempotent).
- name: config
image: nousresearch/hermes-agent:v2026.9.24 # {"$imagepolicy": "flux-system:hermes"}
command:
- /opt/hermes/.venv/bin/hermes
- config
- set
- dashboard.trusted_proxies
- '["10.244.0.0/16","fd10:244::/48"]'
env:
- name: HERMES_HOME
value: /opt/data
- name: HOME
value: /opt/data
securityContext:
runAsUser: 10000
runAsGroup: 10000
runAsNonRoot: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: data
mountPath: /opt/data
# kubectl for the agent. /opt/data/.local/bin is on the image's PATH;
# an emptyDir there keeps the binary out of the PVC and current with
# this pin.
- name: kubectl
image: docker.io/alpine/kubectl:1.37.1@sha256:b568e8beb6a79aee269c142343078a278b85e7710f1c0e7d8d6b21d1eb566a48
command: ["cp", "/usr/local/bin/kubectl", "/tools/kubectl"]
securityContext:
runAsUser: 10000
runAsGroup: 10000
runAsNonRoot: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tools
mountPath: /tools
containers:
- name: hermes
image: nousresearch/hermes-agent:v2026.9.24 # {"$imagepolicy": "flux-system:hermes"}
args: ["gateway", "run"]
env:
- name: HERMES_DASHBOARD
value: "1"
- name: HERMES_DASHBOARD_PUBLIC_URL
value: https://hermes.renner.uno
# Dashboard login: Authentik OIDC, public PKCE client (blueprint
# in apps/authentik/blueprints-configmap.yaml, group Hermes Users).
- name: HERMES_DASHBOARD_OIDC_ISSUER
value: https://auth.renner.uno/application/o/hermes/
- name: HERMES_DASHBOARD_OIDC_CLIENT_ID
value: hermes-dashboard
- name: API_SERVER_ENABLED
value: "true"
- name: API_SERVER_HOST
value: 0.0.0.0
envFrom:
- secretRef:
name: hermes-api-key
ports:
- name: dashboard
containerPort: 9119
- name: api
containerPort: 8642
# The dashboard is the user-facing surface; /api/status is public.
startupProbe:
httpGet:
path: /api/status
port: dashboard
periodSeconds: 10
failureThreshold: 30
readinessProbe:
httpGet:
path: /api/status
port: dashboard
periodSeconds: 15
livenessProbe:
httpGet:
path: /api/status
port: dashboard
periodSeconds: 60
timeoutSeconds: 10
failureThreshold: 3
resources:
requests:
cpu: 100m
memory: 512Mi
limits:
memory: 4Gi
volumeMounts:
- name: data
mountPath: /opt/data
- name: tools
mountPath: /opt/data/.local/bin
volumes:
- name: data
persistentVolumeClaim:
claimName: hermes-data
- name: tools
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: hermes
namespace: hermes
labels:
app.kubernetes.io/name: hermes
spec:
selector:
app.kubernetes.io/name: hermes
ports:
- name: dashboard
port: 9119
targetPort: dashboard
- name: api
port: 8642
targetPort: api
+59
View File
@@ -0,0 +1,59 @@
# Both hosts are LAN-only (no dns.renner.uno/public or netbird.renner.uno/public
# label).
#
# hermes.renner.uno — dashboard and Hermes Desktop remote gateway. Plain
# Traefik, not the authentik-gate class: Hermes Desktop calls the API with a
# Bearer token from its native (PKCE) sign-in, which a forward-auth gate would
# redirect. Authentik still guards it: the dashboard's own OIDC login against
# the `hermes` application, restricted to the Hermes Users group.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: hermes
namespace: hermes
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
ingressClassName: traefik
tls:
- hosts:
- hermes.renner.uno
secretName: hermes-tls
rules:
- host: hermes.renner.uno
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: hermes
port:
number: 9119
---
# hermes-api.renner.uno — the OpenAI-compatible API server. Hermes checks
# `Authorization: Bearer <API_SERVER_KEY>` itself (secret hermes-api-key).
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: hermes-api
namespace: hermes
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
ingressClassName: traefik
tls:
- hosts:
- hermes-api.renner.uno
secretName: hermes-api-tls
rules:
- host: hermes-api.renner.uno
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: hermes
port:
number: 8642
@@ -0,0 +1,7 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- rbac.yaml
- api-key.yaml
- hermes.yaml
- ingress.yaml
+22
View File
@@ -0,0 +1,22 @@
# Read-only cluster access for the agent's kubectl, as for claude-workstation:
# the built-in `view` ClusterRole, which excludes Secrets. The token is the
# pod's projected ServiceAccount token; kubectl picks it up as in-cluster
# config.
apiVersion: v1
kind: ServiceAccount
metadata:
name: hermes
namespace: hermes
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: hermes-view
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: view
subjects:
- kind: ServiceAccount
name: hermes
namespace: hermes
+18
View File
@@ -0,0 +1,18 @@
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: hermes
namespace: flux-system
spec:
interval: 10m
retryInterval: 2m
timeout: 5m
sourceRef:
kind: GitRepository
name: flux-system
path: ./kubernetes/apps/hermes
prune: true
wait: true
dependsOn:
- name: infrastructure
- name: infrastructure-crds
@@ -0,0 +1,24 @@
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageRepository
metadata:
name: hermes
namespace: flux-system
spec:
image: nousresearch/hermes-agent
interval: 1h
---
# v-prefix tags (v0.22.2). The filter skips rc.*, -desktop and per-arch tags.
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
name: hermes
namespace: flux-system
spec:
imageRepositoryRef:
name: hermes
filterTags:
pattern: '^v(?P<ver>\d+\.\d+\.\d+)$'
extract: '$ver'
policy:
semver:
range: ">=0.22.2"
@@ -10,6 +10,7 @@ resources:
- gatus.yaml
- gitea-mcp.yaml
- gotenberg.yaml
- hermes.yaml
- home-assistant.yaml
- immich.yaml
- kuma-sync.yaml
@@ -294,3 +294,12 @@ apiVersion: v1
kind: Namespace
metadata:
name: kuma-sync
---
# Hermes Agent (kubernetes/apps/hermes). Declared here so it exists before
# infrastructure-crds applies its CNPs. No pod-security labels: the image's
# s6-overlay init starts as root and drops to the `hermes` user, which the
# cluster's default `baseline` enforcement allows.
apiVersion: v1
kind: Namespace
metadata:
name: hermes
@@ -0,0 +1,137 @@
---
# Baseline policy for the `hermes` namespace (Hermes Agent, apps/hermes).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: default-deny
namespace: hermes
spec:
endpointSelector: {}
ingress:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": hermes
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": monitoring
- fromEntities:
- host
- remote-node
- fromEndpoints:
- matchExpressions:
- key: io.kubernetes.pod.namespace
operator: Exists
icmps:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 143, family: IPv6 }
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": hermes
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
# Every LAN hostname (gitea, home, auth for the dashboard's OIDC
# discovery, matrix) via Traefik.
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ingress
app.kubernetes.io/name: traefik
---
# The agent itself: model APIs (api.anthropic.com), web tools, messaging
# platforms, read-only kubectl, and the monitoring read APIs. The matching
# ingress grant on the monitoring side is monitoring-ns.yaml
# (monitoring-allow-hermes).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: hermes-egress
namespace: hermes
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: hermes
egress:
- toEntities:
- world
toPorts:
- ports:
- port: "443"
protocol: TCP
- port: "80"
protocol: TCP
# `kube-apiserver` is its own entity in Cilium; port is the post-DNAT 6443.
- toEntities:
- kube-apiserver
toPorts:
- ports:
- port: "6443"
protocol: TCP
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": monitoring
app.kubernetes.io/name: prometheus
toPorts:
- ports:
- port: "9090"
protocol: TCP
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": monitoring
app.kubernetes.io/name: alertmanager
toPorts:
- ports:
- port: "9093"
protocol: TCP
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": monitoring
app.kubernetes.io/name: loki
toPorts:
- ports:
- port: "3100"
protocol: TCP
---
# Traefik serves the dashboard (9119) and the OpenAI-compatible API (8642).
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: hermes-allow-traefik
namespace: hermes
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: hermes
ingress:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ingress
app.kubernetes.io/name: traefik
toPorts:
- ports:
- port: "9119"
protocol: TCP
- port: "8642"
protocol: TCP
@@ -34,5 +34,6 @@ resources:
- uptime-monitor-ns.yaml
- kuma-sync-ns.yaml
- renovate-ns.yaml
- hermes-ns.yaml
- allow-cilium-health.yaml
- kube-system-hubble-relay.yaml
@@ -57,3 +57,30 @@ spec:
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 143, family: IPv6 }
---
# Hermes Agent (hermes ns) reads metrics, alerts and logs: the Prometheus,
# Alertmanager and Loki HTTP APIs, scoped to those pods and ports.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: monitoring-allow-hermes
namespace: monitoring
spec:
endpointSelector:
matchExpressions:
- key: app.kubernetes.io/name
operator: In
values: [prometheus, alertmanager, loki]
ingress:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": hermes
app.kubernetes.io/name: hermes
toPorts:
- ports:
- port: "9090"
protocol: TCP
- port: "9093"
protocol: TCP
- port: "3100"
protocol: TCP
@@ -1,10 +1,18 @@
# OpenBao policy: uptime-kuma-vps
# Read-only on the Uptime Kuma admin credentials; self-renewal for the CI token. NOT applied by Flux.
# Read on the Uptime Kuma admin and SMTP credentials; read/write on the metrics API key; self-renewal for the CI token. NOT applied by Flux.
path "homelab/data/infrastructure/uptime-kuma" {
capabilities = ["read"]
}
path "homelab/data/infrastructure/uptime-kuma/smtp" {
capabilities = ["read"]
}
path "homelab/data/infrastructure/uptime-kuma-metrics" {
capabilities = ["read", "create", "update"]
}
path "auth/token/renew-self" {
capabilities = ["update"]
}