Set up

Connections

Add them one at a time. For each system: what it lights up, the login to make with the least rights, where the secret goes, and how to check it.

Before you start

Every secret is a file of its own under secrets/, holding one value, readable by its owner only:

printf '%s\n' '<the value>' > secrets/<file>
chmod 600 secrets/<file>

After adding a connection to config.yaml, restart the board and check it:

docker compose restart alertboard
curl -s http://127.0.0.1:8080/api/connections

Each connection gets a row saying whether it is reachable, logged_in, and which rights it is missing. How to read it.

The calendar has no settings of its own: every connection with schedules (AWX, Kubernetes, Proxmox, Harbor, GitLab, Greenbone, OneUptime) is read every 15 minutes, with read-only rights. Each section below says what the calendar needs from that login.

OIDC (login)

Lights up: logging in on the public URL and in the app, and the four groups that decide who may do what. Required.

Create the login

A confidential client (client ID and secret) whose ID token carries a groups claim, and the groups themselves.

The callback (redirect) URI to register is your public_url followed by /auth/callback. With public_url: https://board.example.com that is:

https://board.example.com/auth/callback

It must match exactly: same scheme and host, no trailing slash. The browser and the app both log in through it, so it is the only one you need.

  • Authentik: Applications › Providers › Create › OAuth2/OpenID Provider: client type Confidential, redirect URI <public_url>/auth/callback, scopes openid, profile, email (the default profile mapping includes groups). Then Applications › Create, with that provider and the slug alertboard; the issuer is https://<authentik>/application/o/alertboard/. Under Directory › Groups, create the groups and add the people.
  • Keycloak: Clients › Create client: OpenID Connect, Client authentication on, valid redirect URI <public_url>/auth/callback. Add a Group Membership mapper (claim name groups, Full group path off, Add to ID token on) to the client's dedicated scope. The issuer is https://<keycloak>/realms/<realm>.
oidc:
  issuer: https://login.example.com/application/o/alertboard/
  client_id: alertboard
  client_secret_file: /secrets/oidc_client_secret
  session_key_file: /secrets/session_key
  groups:
    users: alertboard-users
    operators: alertboard-operators

Put the secret in place

The client secret, and a key for the session cookies (changing it logs everyone out):

printf '%s\n' '<client secret>' > secrets/oidc_client_secret
openssl rand -hex 32 > secrets/session_key
chmod 600 secrets/oidc_client_secret secrets/session_key

Check it

curl -s https://login.example.com/application/o/alertboard/.well-known/openid-configuration | head -c 200

Then log in on the public URL. In /api/connections, logged_in is null until someone logs in, and false with invalid_client for a wrong secret. “Your account is not allowed” means someone outside oidc.groups.users, or a token without the groups claim.

Alertmanager

Lights up: the board itself: alerts, acks, silences, maintenance, notification links and alert push. Required. One connection per environment; the first is the default, and each gets its own colour.

Create the login

None, when Alertmanager is reached directly. Behind a proxy that asks for a login, use the proxy's bearer token or basic auth. The board makes acks and silences itself, so the login must be allowed to create and expire silences.

connections:
  alertmanager:
    - name: production          # a-z, 0-9 and -; used in notification links
      url: http://alertmanager:9093
      # token_file: /secrets/am_production_token
      # basic_auth_file: /secrets/am_production_basic
      # ca_file: /secrets/am_ca.crt
    - name: staging
      url: https://alertmanager.staging.example.com

Check it

curl -s http://alertmanager:9093/api/v2/status | head -c 200
# with a token:  -H "Authorization: Bearer $(cat secrets/am_production_token)"

/api/connections has one alertmanager row per environment. logged_in: false with 401 or 403 is the proxy refusing the secret.

Never run two boards against the same Alertmanager: each expires the board silences it does not know, so they would expire each other's.

Prometheus

Lights up: the 24-hour alert history, the Metrics view with a chart per tile, and the hosts in the maintenance picker.

The Metrics view is built in: the board asks Prometheus which metrics it has and shows a section for each exporter it finds. No job names, hosts or interfaces to configure.

SectionExporterAmber / red at
Proxmoxpve-exporter; node-exporter on the Proxmox hosts for the networkCPU 80/90%, RAM and storage 85/95%
Kuberneteskube-state-metrics and node-exporter on the nodesCPU 80/90%, RAM 85/95%
LonghornLonghorn's own metrics80/90%
FirewallsAn OPNsense exporter (WAN in and out)
ProbesThe blackbox exporter (probe_success)Red while a target has no passing probe

Create the login

None when reached directly. Behind a proxy, a login that may only query: the board only reads /api/v1/query, query_range and label/…/values. Every viewer of the board can run queries through it, so give it nothing beyond what they may read.

connections:
  prometheus:
    url: http://prometheus:9090
    # token_file / basic_auth_file / ca_file as for Alertmanager

Check it

curl -s http://prometheus:9090/api/v1/status/buildinfo
curl -s http://prometheus:9090/api/v1/label/__name__/values \
  | grep -oE '"(pve_node_info|kube_node_info|node_uname_info|longhorn_node_storage_usage_bytes|opnsense_interfaces_received_bytes_total|probe_success)"'

A section that does not show is missing one of its metrics. A probe that is meant to fail (a check that a port stays closed) keeps the Probes tile red: rename its result in the scrape config.

metric_relabel_configs:
  - source_labels: [__name__]
    regex: probe_success
    target_label: __name__
    replacement: probe_expected_failure

Push notifications (APNs)

Lights up: push notifications: critical alerts as soon as they fire unacked, to the phones that turned on “Critical alerts”, and new approvals to the phones of those who may decide them.

Apple delivers pushes to the app only when they are signed with the app's own APNs key, so your board sends them through the PiDash push relay, push.pidash.io, which signs them and hands them to Apple. Nothing to create, no Apple account needed:

connections:
  apns:
    relay: https://push.pidash.io

The relay can't read your notifications. When the app registers, it gives your board the public half of a key that only that iPhone holds. The board encrypts every notification for that phone; the relay sees a device token and ciphertext, and Apple delivers a placeholder (“New notification”) that the app replaces with the real text on the phone itself, before it shows. The relay stores nothing. If it is ever down, pushes sent meanwhile are not delivered later, but everything else on the board and in the app keeps working.

  • The board must reach https://push.pidash.io (port 443) outbound.
  • Every phone needs PiDash 1.0.12 or later. An older one gets no pushes, and its test push says to update the app.
  • environment defaults to production, right for the App Store and TestFlight. The board holds no APNs key of its own: key_file, key_id, team_id and topic are refused.

Check it

In the app: More › Settings › Send test notification. The board never pushes just to check, so /api/connections shows the last real push in a push relay row: null until one was sent, then reachable: false when the relay can't be reached, or an HTTP status when the relay or Apple refused it (the test push names the reason, such as BadDeviceToken).

OneUptime

Lights up: incidents and the status page. Without it the board has no incident features.

  • Declare an incident from one or more alerts or a finding. It gets a severity from the alerts' severity label, the monitors named after the alerts' instance, host, node or service, and an internal note with a link back. The alerts are acked. It starts hidden from the status page.
  • Publish (operators) shows it on the status page and turns its monitors degraded.
  • Operators move it through your project's states, post public notes and mark it resolved once every linked alert has resolved. The board adds internal notes when a linked alert resolves or fires again, and never resolves an incident itself.
  • Maintenance started with “Announce on the status page” becomes a scheduled maintenance event, shown on the calendar.

Create the login

A project API key (Project Settings › API Keys › Create) with only these permissions:

PermissionFor
Read Incident, Read Incident State, Read Incident Severity, Read Monitor, Read Monitor StatusReading the project every 30 seconds
Create IncidentDeclare
Edit IncidentPublish
Create Incident State TimelineChange the state, mark resolved
Create Incident Internal NoteInternal notes
Create Incident Status Page NotePublic notes
Read, Create and Edit Scheduled MaintenanceAnnounced maintenance; the calendar

The project's id (a UUID) is in Project Settings.

connections:
  oneuptime:
    url: https://oneuptime.example.com     # https only: the key goes in a header
    project_id: 00000000-0000-0000-0000-000000000000
    api_key_file: /secrets/oneuptime_key

Put the secret in place

printf '%s\n' '<api key>' > secrets/oneuptime_key && chmod 600 secrets/oneuptime_key

Check it

curl -s -X POST -H "APIKey: $(cat secrets/oneuptime_key)" -H 'Content-Type: application/json' \
  'https://oneuptime.example.com/api/incident-severity/get-list?skip=0&limit=10' \
  -d '{"query":{},"select":{"_id":true,"name":true,"order":true}}'

The answer has "data" with your severities. In /api/connections, 401 or 422 is a wrong key or one without the read permissions, which are named under missing.

AWX

Lights up: approvals of chosen workflows, runbooks (job templates operators may launch from the app, with their live output) and AWX schedules on the calendar.

Create the login

A user of its own, pidashboard, with only:

  • Approve on each workflow in ansible.approvals (Templates › the workflow › User Access);
  • Read on the job templates those workflows run, so an approval can show what they found;
  • Execute on each job template in ansible.runbooks.

Then a token for it with scope Write (Users › pidashboard › Tokens › Add): approving and launching are writes.

connections:
  awx:
    url: https://awx.example.com
    token_file: /secrets/awx_token
    # calendar_token_file: /secrets/awx_calendar_token
ansible:
  approvals:
    - "Security - Expedited patch"
  runbooks:
    - "Production - Restart service"

Names must match AWX exactly. Runbooks also need oidc.groups.operators.

Put the secret in place

printf '%s\n' '<token>' > secrets/awx_token && chmod 600 secrets/awx_token

Check it

curl -s -H "Authorization: Bearer $(cat secrets/awx_token)" https://awx.example.com/api/v2/me/ | head -c 200

/api/connections lists under missing each workflow or job template the token cannot see: give the user the role on it, or fix the name.

A calendar login of its own

With only token_file, the calendar sees just the approval workflows and runbooks. To show more schedules, make a second user, pidashboard-calendar, with Read on each template whose schedule should show, and a token with scope Read in calendar_token_file. It is never used to approve or launch.

Kubernetes

Lights up: Kubernetes in the app (namespaces, workloads, pods, events and logs, and restart, scale, delete pod, run a CronJob now, suspend and resume for operators) and the cluster's CronJobs and Longhorn's recurring jobs on the calendar.

config.yaml only names a kubeconfig. The board asks the cluster what that login may do, every 5 minutes: the app shows the namespaces it may list workloads and pods in, and offers exactly the actions its RBAC allows there. To show less or allow less, narrow the Role, not config.yaml.

Create the login

A service account of its own, with a long-lived token:

apiVersion: v1
kind: Namespace
metadata:
  name: pidashboard
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: pidashboard-manage
  namespace: pidashboard
---
apiVersion: v1
kind: Secret
metadata:
  name: pidashboard-manage-token
  namespace: pidashboard
  annotations:
    kubernetes.io/service-account.name: pidashboard-manage
type: kubernetes.io/service-account-token

Then up to three sets of rights, each only if you want what it gives. The calendar, cluster-wide and list only:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: pidashboard-calendar
rules:
  - apiGroups: [batch]
    resources: [cronjobs, jobs]       # jobs: how each run went (optional)
    verbs: [list]
  - apiGroups: [longhorn.io]          # only on a cluster with Longhorn
    resources: [recurringjobs]
    verbs: [list]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: pidashboard-calendar
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: pidashboard-calendar
subjects:
  - kind: ServiceAccount
    name: pidashboard-manage
    namespace: pidashboard

A cluster-wide list on Jobs returns their full spec, env values included: leave jobs out if your Jobs carry secrets in their env.

The namespace list, so the board finds more than the kubeconfig context's namespace. It reveals only the names:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: pidashboard-namespaces
rules:
  - apiGroups: [""]
    resources: [namespaces]
    verbs: [list]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: pidashboard-namespaces
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: pidashboard-namespaces
subjects:
  - kind: ServiceAccount
    name: pidashboard-manage
    namespace: pidashboard

A Role per namespace the app should show, and only those. Keep the reads; add an action block only for each action operators may take there:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: pidashboard-manage
  namespace: web
rules:
  # Reads: always needed.
  - apiGroups: [""]
    resources: [pods]
    verbs: [get, list]
  - apiGroups: [""]
    resources: [pods/log]
    verbs: [get]
  - apiGroups: [""]
    resources: [events]
    verbs: [list]
  - apiGroups: [apps]
    resources: [deployments, statefulsets, daemonsets]
    verbs: [get, list]
  - apiGroups: [batch]
    resources: [cronjobs]
    verbs: [get, list]
  - apiGroups: [batch]
    resources: [jobs]
    verbs: [list]
  # Actions: only those you want to offer.
  - apiGroups: [apps]                 # restart
    resources: [deployments, statefulsets, daemonsets]
    verbs: [patch]
  - apiGroups: [apps]                 # scale
    resources: [deployments/scale, statefulsets/scale]
    verbs: [get, patch]
  - apiGroups: [""]                   # delete a pod
    resources: [pods]
    verbs: [delete]
  - apiGroups: [batch]                # run a CronJob now
    resources: [jobs]
    verbs: [create]
  - apiGroups: [batch]                # suspend and resume
    resources: [cronjobs]
    verbs: [patch]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: pidashboard-manage
  namespace: web
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: pidashboard-manage
subjects:
  - kind: ServiceAccount
    name: pidashboard-manage
    namespace: pidashboard

RBAC can't narrow a patch to the fields the board sends. Whoever holds this token can change what runs in the namespaces it is bound in, so keep the kubeconfig as close as the database password, and grant only the actions you use.

Put the secret in place

A kubeconfig for the service account, with the token and CA from its Secret:

server=https://<the API server, reachable from the board>:6443
kubectl -n pidashboard get secret pidashboard-manage-token -o jsonpath='{.data.ca\.crt}' | base64 -d > /tmp/ca.crt
token=$(kubectl -n pidashboard get secret pidashboard-manage-token -o jsonpath='{.data.token}' | base64 -d)
KUBECONFIG=secrets/kubeconfig kubectl config set-cluster home --server="$server" \
  --certificate-authority=/tmp/ca.crt --embed-certs
KUBECONFIG=secrets/kubeconfig kubectl config set-credentials pidashboard --token="$token"
KUBECONFIG=secrets/kubeconfig kubectl config set-context home --cluster=home --user=pidashboard
KUBECONFIG=secrets/kubeconfig kubectl config use-context home
chmod 600 secrets/kubeconfig; rm /tmp/ca.crt
oidc:
  groups:
    operators: k8s-operators      # required with a Kubernetes connection
connections:
  kubernetes:
    - name: home
      kubeconfig: /secrets/kubeconfig
      # context: home             # default: the kubeconfig's current-context
  • Your own kubeconfig works too if it logs in with a token or client certificate, but it is usually cluster-admin: every namespace shows and every action is offered.
  • Login plugins (exec, auth-provider: EKS, GKE, oidc-login) can't run inside the board and are refused, as is insecure-skip-tls-verify.
  • A server on 127.0.0.1 points at the container itself: use the host's LAN address.

Check it

as=system:serviceaccount:pidashboard:pidashboard-manage
kubectl auth can-i --as=$as list cronjobs.batch -A          # calendar: yes
kubectl auth can-i --as=$as -n web list pods                # reads: yes
kubectl auth can-i --as=$as -n web get pods --subresource=log
kubectl auth can-i --as=$as -n kube-system list pods        # should say no
kubectl auth can-i --as=$as -n web patch deployments.apps   # restart, if granted

/api/connections has one kubernetes row per cluster, with the reads the app misses per namespace under missing.

Proxmox

Lights up: backup jobs on the calendar, the app's Networks, and with terminal_tag the app's SSH terminal on tagged VMs and containers (Terminal and networks).

Create the login

A user with the read-only PVEAuditor role on / and an API token without privilege separation. That is all the board needs, on Proxmox 8 and 9:

pveum user add pidashboard@pve
pveum acl modify / --users pidashboard@pve --roles PVEAuditor
pveum user token add pidashboard@pve board --privsep 0
connections:
  proxmox:
    url: https://pve.example.com:8006
    token_file: /secrets/proxmox_token
    ca_file: /secrets/pve_ca.crt     # Proxmox's own CA
    terminal_tag: terminal           # optional: tagged guests get a terminal

Put the secret in place

The token file holds user@realm!tokenid=secret, with the secret as pveum printed it. Copy Proxmox's CA too, unless its certificate is from a public CA:

printf '%s\n' 'pidashboard@pve!board=<secret>' > secrets/proxmox_token && chmod 600 secrets/proxmox_token
scp root@pve.example.com:/etc/pve/pve-root-ca.pem secrets/pve_ca.crt

Check it

curl -s --cacert secrets/pve_ca.crt -H "Authorization: PVEAPIToken=$(cat secrets/proxmox_token)" \
  https://pve.example.com:8006/api2/json/cluster/backup | head -c 300

In /api/connections, 401 is a wrong token; 403 lists Sys.Audit on / (role PVEAuditor) under missing.

Greenbone

Lights up: Greenbone's findings in the Findings view (the last finished report of every task, medium and up), and its scheduled scan tasks on the calendar.

gvmd must listen on TCP with TLS; by default it listens only on a Unix socket. Start it with gvmd --listen=0.0.0.0 --port=9390 (in the community compose file: the gvmd service's command), allow port 9390 from the board only, and give gvmd a server certificate that names the host the board connects to (gvm-manage-certs). The board checks the certificate and its name, and never connects without.

Create the login

A user with the read-only Observer role (Administration › Users › New User). Add it as an observer on each task to read (the task's Edit dialog, Observers).

connections:
  greenbone:
    url: tls://scanner.example.com:9390
    user: alertboard
    password_file: /secrets/greenbone_password
    ca_file: /secrets/greenbone_ca.crt     # gvmd's CA

Put the secret in place

printf '%s\n' '<password>' > secrets/greenbone_password && chmod 600 secrets/greenbone_password
scp root@scanner.example.com:/var/lib/gvm/CA/cacert.pem secrets/greenbone_ca.crt

Check it

openssl s_client -connect scanner.example.com:9390 -CAfile secrets/greenbone_ca.crt \
  -verify_hostname scanner.example.com -verify_return_error </dev/null | grep 'Verify return code'

It should say Verify return code: 0 (ok). In /api/connections, login refused is a wrong user or password and TLS the certificate or its name.

Wazuh

Lights up: two logins, as Wazuh (4.8 and later) keeps its data in two places:

  • the manager API (port 55000): the agents, in the Metrics view's Wazuh section (active, never connected, and each disconnected agent). Needs no Prometheus.
  • the indexer (port 9200, optional): critical and high vulnerabilities as findings, one per host and package with its CVEs.

Create the logins

Both read-only. The API user gets a role whose policy allows agent:read only (Server management › Security in the dashboard). The indexer user gets a role with index permission read on wazuh-states-vulnerabilities-* only (Indexer management › Security › Roles), mapped to an internal user.

connections:
  wazuh:
    url: https://wazuh.example.com:55000
    user: alertboard
    password_file: /secrets/wazuh_api_password
    ca_file: /secrets/wazuh_ca.crt            # the Wazuh root CA (root-ca.pem)
    indexer:
      url: https://wazuh.example.com:9200
      user: alertboard-reader
      password_file: /secrets/wazuh_indexer_password

Put the secrets in place

printf '%s\n' '<api password>' > secrets/wazuh_api_password
printf '%s\n' '<indexer password>' > secrets/wazuh_indexer_password
chmod 600 secrets/wazuh_api_password secrets/wazuh_indexer_password
scp root@wazuh.example.com:/etc/wazuh-indexer/certs/root-ca.pem secrets/wazuh_ca.crt

The certificates must name the hosts in the URLs.

Check it

/api/connections has a wazuh row and a wazuh indexer row: 401 is a wrong user or password; a 403 names what the login lacks under missing.

Harbor

Lights up: Harbor's schedules on the calendar: garbage collection, scan all, audit log purge, replication and tag retention.

Create the login

A system robot account (Administration › Robot Accounts › New Robot Account, System), with read access only: Garbage Collection, Scan All, Purge Audit: Read; Replication Policy: List and Read; Project: List; and for every project, Tag Retention: Read.

connections:
  harbor:
    url: https://harbor.example.com
    basic_auth_file: /secrets/harbor_robot

Put the secret in place

The robot's full name as Harbor shows it, and its secret. Mind the single quotes around the $:

printf '%s\n' 'robot$pidashboard:<secret>' > secrets/harbor_robot && chmod 600 secrets/harbor_robot

Check it

curl -s -u "$(cat secrets/harbor_robot)" https://harbor.example.com/api/v2.0/system/gc/schedule

GitLab

Lights up: pipeline schedules on the calendar, with how their recent pipelines went.

Create the login

A personal, group or project access token with the read_api scope, for a user who is at least Developer in the projects whose schedules should show. A group access token with role Developer covers the group's projects.

connections:
  gitlab:
    url: https://gitlab.example.com
    token_file: /secrets/gitlab_token

Put the secret in place

printf '%s\n' '<token>' > secrets/gitlab_token && chmod 600 secrets/gitlab_token

Check it

curl -s -H "PRIVATE-TOKEN: $(cat secrets/gitlab_token)" \
  'https://gitlab.example.com/api/v4/projects?membership=true&min_access_level=30&simple=true' | head -c 300

Schedules with a Rails time zone name (“Amsterdam”) instead of an IANA one (“Europe/Amsterdam”) are listed as not understood.

Findings from other scanners

Greenbone and Wazuh above are all the Findings view needs. Only for another scanner (Trivy, say) can it also read a findings.json that a job of your own builds. See Findings › Advanced.