Repository files navigation

pyobs-web-admin

A web-based administration interface for pyobs, the robotic telescope framework. It lets you start, stop, and restart modules, tail and filter their logs, and view and edit their configuration files — all from a browser.

Dashboard showing modules grouped under Stopped and Deactivated headings, with summary tiles and per-row quick-action buttons

Features

  • Dashboard — sortable list view of all modules with:
    • Running / stopped / total summary counts plus total CPU and RAM
    • Per-module status badge, RAM, CPU, and uptime columns — click any header to sort, reset icon restores default grouping
    • Modules grouped under Running / Stopped / Deactivated headers when sorted by status
    • Warning/error log counts for the last 24 h (highlighted in colour if non-zero)
    • Quick start, restart, stop, and activate/deactivate buttons per module
    • Start All, Restart All, and Stop All bulk actions
    • Inactive modules (prefixed with _) are excluded from bulk start/restart
    • Outdated badge on any running module whose loaded pyobs-* versions lag the installed ones (parsed from pyobs-core's startup "Loaded pyobs packages:" log line — comm-independent, works for comm-less modules too), plus a Restart outdated bulk action that restarts only those
    • Responsive: on small screens the table collapses to status dot + name + log counts + actions
  • Module detail — per-module view with four tabs:
    • Overview — current status, PID, uptime, CPU and memory usage, running pyobs-* package versions (flagged when they lag the installed set), per-level log message counts (last 24 h), XMPP connection state (if enabled), start/restart/stop/activate/deactivate control
    • Logs — live log tail with text filter, time-range filter (set a start date to load all logs since that instant, or click a line to set it), colour-coded by severity, auto-refresh; scrolling to the top auto-loads older entries (journald-backed modules, or file-backed modules once a start date is set)
    • Config — YAML editor with syntax highlighting and colour-coded {include} lines; included shared configs are shown as clickable links
    • ACL — point-and-click editor for the module's acl: block: click to allow/deny known modules, add other callers, toggle enforce/log mode
  • New module — a "+" next to the sidebar's Modules section creates a brand-new <name>.yaml config (a minimal starter with just a class: key) and takes you straight to its Config tab to fill in the rest
  • Shared configs*.shared.yaml config fragments listed in a separate sidebar section with a YAML-highlighted config editor (no start/stop controls)
  • Packages (/packages/) — every installed pyobs-* package (plus anything else listed in PYOBS_MANAGED_PACKAGES) with its installed and latest-PyPI version, and a one-click Update button; git/URL-installed packages get a Reinstall action instead (see Package management)
  • Overview (/overview/) — fleet-wide summary, one row per configured host: reachable or not, running/stopped/total counts, aggregate CPU/RAM, linking into that host's own Dashboard, plus a package-version matrix (one row per pyobs-* package, one column per host) so version drift across the fleet is visible at a glance. Deliberately no bulk or per-module actions — those stay on the per-host Dashboard, since a fleet-wide "Stop All" from one button is a real footgun
  • All Logs (/logs/) — fleet-wide live log tail across every module on every configured host, same filtering and scroll-to-load-older behaviour as a module's own Logs tab
  • ACL Matrix (/acl/) — fleet-wide read-only matrix of which module can call which, merged across every configured host
  • Hub mode — control multiple remote pyobs hosts from a single browser tab; remote hosts are listed in the sidebar and all actions are proxied transparently
  • ejabberd / XMPP status (optional) — dashboard summary tile and per-module connected/not-connected indicator, plus a session/last-seen/registered-account block on each module's own page, for modules with a comm.user in their config — closes the gap between "the process is running" and "the module is actually reachable over XMPP" (see ejabberd integration)
  • ejabberd / XMPP user management (optional, builds on the above) — register, reset password, ban/unban, unregister, and kick XMPP accounts, either from a module's own Overview tab or from a fleet-wide Users page (/xmpp-users/) listing every registered account across every host, cross-referenced against which module(s) use it and which one is actually running. Safe by design for an identity shared across more than one module's comm.user — a password reset writes back to every module sharing it, and destructive actions name which other modules are affected before you confirm (see ejabberd user management)
  • Responsive — works on mobile with a slide-in sidebar
  • No pyobs-core dependency — communicates with pyobs directly via subprocess; no Python imports from pyobs-core
  • Keycloak login (optional) — SSO on top of the default shared admin/password login, with per-person access granted/revoked via Keycloak group membership (see Keycloak login)

Technology

LayerChoice
BackendPython 3.13, Django 6, psutil
WSGI serverGunicorn
FrontendBootstrap 5 (CDN), CodeMirror 5 (CDN), vanilla JS
Package manageruv
AuthShared admin/password login plus optional Keycloak SSO; both share one SQLite db (sessions table for both, User table for Keycloak-linked accounts only)
Hub authPre-shared token in X-Hub-Token header; CSRF bypassed for hub requests

Development setup

git clone https://github.com/pyobs/pyobs-web-admin.git
cd pyobs-web-admin
uv sync
uv run python manage.py migrate
uv run python manage.py runserver

Create pyobs_web_admin/local_settings.py (see Configuration below).


Production setup

1. Install the app

cd /opt/pyobs
git clone https://github.com/pyobs/pyobs-web-admin.git
cd pyobs-web-admin
uv sync

2. Configure

Copy and edit the local settings file:

cp pyobs_web_admin/local_settings.py.example pyobs_web_admin/local_settings.py # or create from scratch$EDITOR pyobs_web_admin/local_settings.py

Minimum required settings for production (see Configuration):

DEBUG=FalseSECRET_KEY="..."# generate belowALLOWED_HOSTS= ["your-hostname-or-ip"]
ADMIN_USERNAME="admin"ADMIN_PASSWORD_HASH="..."# generate belowPYOBS_EXEC="/opt/pyobs/venv/bin/pyobs"

Generate a secret key:

DJANGO_SETTINGS_MODULE=pyobs_web_admin.settings uv run python -c \
"from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"

Generate a password hash:

DJANGO_SETTINGS_MODULE=pyobs_web_admin.settings uv run python -c \
"from django.contrib.auth.hashers import make_password; print(make_password('yourpassword'))"

3. Install the systemd service

cp deploy/pyobs-web-admin.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now pyobs-web-admin

Check that it started:

systemctl status pyobs-web-admin
journalctl -u pyobs-web-admin -f

4. Configure nginx

Add a site configuration that proxies to gunicorn on port 8765:

server{listen80;server_name your-hostname-or-ip;location / {proxy_passhttp://127.0.0.1:8765;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;proxy_set_header X-Forwarded-Proto $scheme;}}

If you add TLS (strongly recommended for any non-private network), also set in local_settings.py:

SESSION_COOKIE_SECURE=TrueCSRF_COOKIE_SECURE=True

Configuration

All runtime configuration lives in pyobs_web_admin/local_settings.py, which is not committed to version control. A full reference:

# DjangoSECRET_KEY="..."# required in productionDEBUG=False# set True only in developmentALLOWED_HOSTS= ["*"] # restrict to hostname/IP in production# HTTPS (enable once TLS is in place)# SESSION_COOKIE_SECURE = True# CSRF_COOKIE_SECURE = True# AuthenticationADMIN_USERNAME="admin"ADMIN_PASSWORD_HASH="pbkdf2_sha256$..."# see generation command above# Keycloak login (optional — see Keycloak login section)# PYOBS_AUTH = {# "SERVER_URL": "https://keycloak.example.org",# "REALM": "pyobs",# "CLIENT_ID": "web-admin",# "CLIENT_SECRET": "",# "REDIRECT_URI": "https://your.domain.com/accounts/keycloak/callback/",# "POST_LOGOUT_REDIRECT_URI": "https://your.domain.com/",# # Optional one-click IdP login: IDP_HINT is passed to Keycloak as kc_idp_hint (skips its# # login/IdP-selection page, going straight to that identity provider, e.g. GWDG SSO);# # IDP_LABEL is the button label on the login page. Leave both unset for the plain# # single "Log in with Keycloak" button.# "IDP_HINT": "gwdg",# "IDP_LABEL": "GWDG",# "USER_RESOLVER": "pyobs_web_admin.authentication.keycloak.resolve_user",# # Only members of this Keycloak group are authorized to use web-admin - create it (and add# # people to it) in the Keycloak admin console before anyone logs in.# "REQUIRED_GROUPS": ["/pyobs-web-admin"],# # Keycloak-independent kill switch, layered on top of REQUIRED_GROUPS above: an admin can# # deactivate a specific local User (Django admin) regardless of their Keycloak group# # membership.# "ENFORCE_LOCAL_ACTIVE": True,# }# pyobs pathsPYOBS_EXEC="/opt/pyobs/venv/bin/pyobs"# path to the pyobs executablePYOBS_CONFIG_DIR="/opt/pyobs/config"# directory containing *.yaml module configsPYOBS_LOG_DIR="/opt/pyobs/log"# directory containing *.log filesPYOBS_RUN_DIR="/opt/pyobs/run"# directory for PID filesPYOBS_LOG_LEVEL="info"# log level passed to pyobs on startPYOBS_LOG_BACKEND=None# None (default): auto-detect from pyobsd's own# config; "file" or "journald" to override. If# "journald": the account running pyobs-web-admin# needs journal read access — `usermod -aG# systemd-journal <account>` (preferred over `adm`,# which grants broader log access than needed) —# otherwise logs come back silently empty, no error.# Packages page (optional — see Package management section)PYOBS_MANAGED_PACKAGES= [] # e.g. ["pyobs-core[full]", "my-custom-driver",# "pyobs-iagvt[gui] @ git+ssh://git@gitlab.example.org/iagvt/pyobs-iagvt.git"]# Hub (optional — see Hub mode section)HUB_TOKEN=""# deprecated, single-token form -- see HUB_CLIENTSHUB_CLIENTS= [] # named tokens for external callers (hub, scripts, ...)HUB_HOSTS= [] # remote hosts this instance controls# ejabberd integration (optional — see ejabberd integration section)EJABBERD_ENABLED=False# show XMPP status on the dashboard/module pagesEJABBERD_HOST="localhost"# which host runs ejabberd -- "localhost" or a HUB_HOSTS nameEJABBERD_DOMAIN=""# the XMPP vhost ejabberd servesEJABBERD_API_URL="http://127.0.0.1:5281/api"# mod_http_api base URLEJABBERDCTL="ejabberdctl"# required for user management writes (register/# reset/ban/unregister/kick); also a read fallback# if EJABBERD_API_URL can't be reached -- see# ejabberd user management section below

Keycloak login

The shared admin/password login above is the default and always available — Keycloak is an additive option on top of it, not a replacement, so it stays working as a break-glass fallback if Keycloak itself is unreachable. Unlike the shared account, a Keycloak-linked person's access is granted or revoked individually via Keycloak group membership, without touching anyone else's access or handing out the shared password.

Enabling it

  1. Register a client for this instance in your Keycloak realm (redirect URI https://your.domain.com/accounts/keycloak/callback/, post-logout redirect URI https://your.domain.com/).
  2. Add the PYOBS_AUTH block shown in Configuration to local_settings.py, filling in SERVER_URL, CLIENT_ID, and CLIENT_SECRET. REQUIRED_GROUPS defaults to ["/pyobs-web-admin"] — create that group in the realm and add whoever should have access to it (Keycloak admin console) before anyone tries to log in, or every login is refused as "not authorized".
  3. Run manage.py migrate (creates db.sqlite3, used for the Keycloak-linked User table and, as of this version, sessions too — SESSION_ENGINE is now database-backed rather than signed cookies, since a Keycloak session carries a refresh token that shouldn't be serialized into the browser. This applies to every session app-wide, including the shared admin/password login's — it's no longer fully DB-free, but it's the one Django setting shared by both login paths, so it can't be split per-path). Expired sessions aren't purged automatically — run manage.py clearsessions periodically (e.g. a daily cron/systemd timer) or django_session grows unbounded in a long-lived deployment.

The login page then shows a "Log in with Keycloak" button. A first-time Keycloak login mints a local User (linked to an existing one by email, falling back to username, if either matches), active immediately — whether they're actually let in is decided by REQUIRED_GROUPS above, not by anything in this app's own database.

Setting IDP_HINT (plus IDP_LABEL for the button text) switches the login page to two buttons: "Log in with <IDP_LABEL>" goes straight to that identity provider (skipping Keycloak's own login/IdP-selection page), and "Log in with local Keycloak account" keeps the local-account path reachable for anyone without that IdP's identity. Leave IDP_HINT unset for the single-button behavior above.

Granting or revoking a person's access

This is now done in the Keycloak admin console, not this app's Django admin: add or remove the person from the /pyobs-web-admin group (or whatever REQUIRED_GROUPS is set to). The change takes effect at their next login. Django's /admin/ site is still mounted (the shared admin/password account and any other Django superuser can still get in there for other reasons), with the ENFORCE_LOCAL_ACTIVE setting above (recommended, and the example config's default), toggling a Keycloak-linked account's Active flag there still works too, as an additional, Keycloak-independent kill switch on top of the group gate. Leave ENFORCE_LOCAL_ACTIVE unset if you'd rather Keycloak group membership be the only thing that matters.

The shared admin/password account works at /admin/ directly (no prior visit to /login/ needed first) because manage.py migrate syncs a matching superuser User automatically — see pyobs_web_admin.authentication.admin_sync, and Configuration for the ADMIN_USERNAME/ADMIN_PASSWORD_HASH settings that drive it.


Hub mode

pyobs-web-admin can act as a hub to control multiple remote pyobs hosts from a single browser session. When remote hosts are configured, a Hosts section appears at the top of the sidebar. Clicking a host switches the active context — all subsequent actions (start/stop/logs/config) are transparently proxied to that host's API.

Setting up the hub

On the hub (the machine you browse to), add to local_settings.py:

HUB_HOSTS= [
{"name": "obs1", "url": "http://obs1:8765", "token": "shared-secret"},
{"name": "obs2", "url": "http://obs2:8765", "token": "another-secret"},
]

On each remote host, give it a named client entry matching the token the hub sends:

HUB_CLIENTS= [
{"name": "hub", "token": "shared-secret"}, # must match HUB_HOSTS' token above
]

The hub — or any other external caller, such as a script calling the API directly — authenticates to remote instances via an X-Hub-Token header. Remote instances check that header against every entry in HUB_CLIENTS; a match bypasses the normal browser session/CSRF check, so the caller can invoke the API without a login session. Give each caller its own named entry so it can be revoked or rotated independently. Tokens are plain pre-shared strings — use long random values and keep them secret.

The older HUB_TOKEN setting (a single unnamed token) still works for backwards compatibility, equivalent to a HUB_CLIENTS entry named "default".

There's no separate "external" API — every endpoint the hub calls (start/stop, logs, config, ACL, packages, ejabberd user management, ...) is the same plain JSON API any X-Hub-Token-authenticated caller can use directly, e.g. from a script. See docs/source/api_endpoints.rst for the full endpoint reference.


ejabberd integration

If the ejabberd server pyobs's XMPP comm layer connects through runs on the same host, pyobs-web-admin can show live connection state alongside the process status it already tracks — closing the gap between "the module's process is running" and "the module is actually reachable over XMPP." When enabled: the dashboard gets a summary tile (how many of this installation's own modules, identified by their config's comm.user, are currently XMPP-connected) plus a small icon per module row; each module's own page gets a session/last-seen/registered-account block in its Overview tab. A module with no comm: block in its config (e.g. a pure HTTP module) is skipped entirely — there's nothing for it to connect to.

Enabling it

In local_settings.py:

EJABBERD_ENABLED=TrueEJABBERD_HOST="localhost"# or a HUB_HOSTS name, if ejabberd runs on a different hostEJABBERD_DOMAIN="your-xmpp-domain"# the vhost ejabberd serves, e.g. "pyobs.example.org"EJABBERD_API_URL="http://127.0.0.1:5281/api"

If EJABBERD_HOST names a HUB_HOSTS entry instead of "localhost", every instance in the fleet transparently proxies its ejabberd queries to that one host — only that host needs EJABBERD_API_URL actually pointed at a real ejabberd; every other instance just needs EJABBERD_HOST set to its name.

ejabberd-side configuration

This talks to ejabberd's HTTP admin API (mod_http_api), not ejabberdctl — about 50–60x faster per call, since it hits the already-running node directly instead of spawning a new Erlang VM per invocation (ejabberdctl is used as a fallback only if EJABBERD_API_URL can't be reached). Add this to ejabberd's own config:

listen:
-
port: 5281ip: "127.0.0.1"# loopback only -- see security note belowmodule: ejabberd_httprequest_handlers:
/api: mod_http_api # add this to an *existing* listener's request_handlers if one's# already on this port (e.g. for BOSH/WebSocket) -- ejabberd only# allows one listener per portmodules:
mod_http_api: {}api_permissions:
"console commands":
from: [ejabberd_ctl]who: allwhat: "*""pyobs-web-admin readonly":
from: [mod_http_api]who:
access:
allow:
- acl: loopbackwhat:
- "status"
- "stats"
- "connected_users_info"
- "registered_users"
- "user_sessions_info"
- "get_last"
- "check_account"

Reload ejabberd's config after adding this (ejabberdctl reload_config, or a restart if that doesn't pick up the new listener). The what: list is a deliberate whitelist — leave it as-is; mod_http_api can also expose account-management commands (register/unregister/change_password) that should never be reachable here.

Security note. Access is IP-based, not credential-based — any request from loopback is trusted, no password or token is involved. This blocks the network (a request from outside the host is rejected), but not other processes on the same machine, which get the same access pyobs-web-admin does. That's an accepted tradeoff for a dedicated, single-purpose observatory control host, not a shared one — reassess if that's not your deployment.


ejabberd user management

Builds on ejabberd integration above (requires EJABBERD_ENABLED = True) to add write actions on top of the read-only status it already shows: register, reset password, ban / unban, unregister, and kick (force-disconnect one session without touching the account) for any module's comm.user. Reversible actions get a single confirmation dialog; unregister — the one action with no undo — requires retyping the account's username first. An identity shared by more than one module's comm.user (a real, supported scenario — e.g. a test copy of a module reusing a real module's identity) is handled safely: a password reset writes the new password back into every module sharing it, not just the one the action was triggered from, and destructive actions (ban/unregister) name every other module affected before you can confirm.

This surfaces in two places:

  • The module detail page's existing ejabberd block (Overview tab) — register when the account isn't registered yet, reset/ban/unregister when it is.
  • A dedicated Users page (/xmpp-users/), linked from the sidebar whenever EJABBERD_ENABLED = True — every registered XMPP account across every configured host, in one fleet-wide, mobile-friendly list. Unlike the module page, this also covers accounts with no owning module at all (e.g. admin) via a manual "register account" form, and accounts shared by more than one module show a status dot marking which one is actually the connected session.

Transport: ejabberdctl, not mod_http_api

Unlike the read path above, writes always go through the ejabberdctl CLI, never mod_http_api — a write's cost is dominated by a human clicking a confirmation dialog, not command latency, so the ~50–60x speed advantage HTTP has for reads doesn't matter here. This also means no api_permissions change is needed for user management specifically — but see the security note below, since ejabberdctl itself is far more powerful than the read-only HTTP whitelist above.

ejabberdctl normally refuses to run as anything other than root or the ejabberd system user ("can only be run by root or the user ejabberd"), which only matters here since writes always need it (the read path mostly avoids it via mod_http_api). If pyobs-web-admin runs as its own service user (e.g. pyobs, per deploy/pyobs-web-admin.service), give that user a narrowly-scoped passwordless sudo rule for just this one binary:

# /etc/sudoers.d/pyobs-web-admin-ejabberdctl
pyobs ALL=(root) NOPASSWD: /usr/sbin/ejabberdctl

(adjust the username and binary path for your setup — check with which ejabberdctl), then point EJABBERDCTL in local_settings.py at the wrapper script committed at the repo root:

EJABBERDCTL="/opt/pyobs/pyobs-web-admin/ejabberdctl-sudo.sh"

ejabberdctl-sudo.sh is a two-line wrapper (exec sudo -n ejabberdctl "$@") — the -n flag makes sudo fail fast instead of hanging on a password prompt if the sudoers rule above isn't in place. Not needed at all if pyobs-web-admin already runs as root or ejabberd.

Security note. This is a materially bigger trust step than the read-only integration above: ejabberdctl can do anything an ejabberd administrator can do, not just the small read-only whitelist mod_http_api's api_permissions enforces. There is no OS-level or ejabberd-level restriction narrowing what the sudo rule allows beyond "run ejabberdctl as root at all" — this app's own tiered confirmation dialogs are the only safety net between a logged-in admin and any ejabberdctl subcommand this app happens to call. Acceptable for the same reason as the read path's IP-based trust: a dedicated, single-purpose observatory control host with one admin identity, not a shared or multi-tenant one.


Package management

The Packages page (/packages/) lists every installed pyobs-* package (plus anything extra listed in PYOBS_MANAGED_PACKAGES) alongside its latest release on PyPI, and lets you update any of them with one click. It always reflects pip's own view of the environment PYOBS_EXEC runs in (via the sibling pip next to it) — nothing here is invented or cached. The fleet-wide Overview page (/overview/) additionally shows a package-version matrix across every configured host, so a package that's drifted out of sync on one host is easy to spot.

PYOBS_MANAGED_PACKAGES

pip never remembers how a package was originally installed — only what's currently there — so a bare pip install --upgrade pyobs-core would silently drop a [full] extra forever, and a package that isn't on PyPI at all has nothing for the Packages page to even find. PYOBS_MANAGED_PACKAGES fills in what pip's own installed-environment metadata can't recover:

PYOBS_MANAGED_PACKAGES= [
"pyobs-core[full]", # keep using this extra on every future upgrade"my-custom-driver", # a non-"pyobs"-prefixed package, shown/managed alongside pyobs-*"pyobs-iagvt[gui] @ git+ssh://git@gitlab.example.org/iagvt/pyobs-iagvt.git", # git-installed
]
  • Extras — list the full name[extra] spec and future updates keep using it, instead of reverting to a bare install.
  • Non-pyobs-prefixed packages — a bare name makes it show up on the Packages page and be upgradable through it too.
  • Git/URL-installed packages — a PEP 508 direct reference (name[extras] @ <url>) for a package that isn't published on PyPI. The Packages page skips the (futile) PyPI version check for these and shows a Reinstall action instead, which just re-runs pip install --upgrade <spec> to pick up whatever's newest at that URL/ref.

Malformed entries are skipped rather than raising, so a typo here can't break the whole page.

Installing a private git-hosted package

For a private repository (e.g. an institute-internal driver like pyobs-iagvt above), pip running non-interactively needs its own credentials — an SSH deploy key is the recommended way:

  1. Generate a passwordless key for the OS user that actually runs pyobs-web-admin's own process (gunicorn/the systemd service user — not necessarily the pyobs service account, since the Packages page's pip install inherits this process's environment, not pyobs's):
    ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N ""
  2. Add the public key as a GitLab Deploy Key on the private project (Settings → Repository → Deploy keys), read-only access is enough. Scoped to just that repo and revocable independently of any person's account — prefer this over a personal SSH key.
  3. Pre-seed known_hosts for that user, so the first non-interactive install doesn't hang waiting on a host-key prompt:
    ssh-keyscan gitlab.example.org >>~/.ssh/known_hosts
  4. Use git+ssh://, not git+https://, in PYOBS_MANAGED_PACKAGES:
    PYOBS_MANAGED_PACKAGES= [
    "pyobs-iagvt[gui] @ git+ssh://git@gitlab.example.org/iagvt/pyobs-iagvt.git",
    ]
  5. In hub mode, repeat this on every host that's expected to manage the package — package updates run on whichever host is currently active, not just the hub, so each of them needs its own copy of the key (or the same key deployed to all of them).

An SSH key is preferred over embedding a GitLab Deploy Token in an HTTPS URL: the token would end up as a literal pip install command-line argument, visible to any other local user via ps aux for the few seconds the install runs. An SSH key file avoids that exposure.


How modules are managed

  • Discovery — all *.yaml files in PYOBS_CONFIG_DIR (excluding *.shared.yaml) are treated as modules. *.shared.yaml files are listed separately as shared configs.
  • Creating a module — the "New module" button writes a fresh <name>.yaml with a minimal starter (class: key only); PYOBS_CONFIG_DIR is created automatically if it doesn't exist yet.
  • Activate / Deactivate — deactivating a module renames its config from name.yaml to _name.yaml (stopping it first if running); activating renames it back. Deactivated modules are excluded from Start All and Restart All.
  • Start — runs pyobs --pid-file <run>/<name>.pid --log-file <log>/<name>.log --log-level <level> <config>. pyobs daemonises itself via python-daemon. If the effective log backend is "journald" (see below), --syslog is passed instead of --log-file — pyobs then logs directly to the systemd journal, tagged SYSLOG_IDENTIFIER=pyobs and PYOBS_MODULE=<name>.
  • Stop — sends SIGTERM to the PID in the PID file; falls back to SIGKILL after 5 s.
  • Restart — stop followed by start.
  • Status — checks whether the process with the stored PID is alive (os.kill(pid, 0)).
  • Resource usage — uptime, CPU %, and RSS memory read via psutil on every status poll.
  • Logs — read from PYOBS_LOG_DIR's flat files by default, or from the systemd journal via journalctl if the effective log backend is "journald"; the log viewer and per-level counts work identically either way. The effective backend is PYOBS_LOG_BACKEND if set explicitly, otherwise auto-detected from pyobsd's own config file (~/.config/pyobs.yaml, /etc/pyobs.yaml, or /opt/pyobs/storage/pyobs.yaml, first found wins) — the same file pyobsd (pyobs-core's daemon manager) reads to decide whether it starts modules with --syslog, so this can't silently drift out of sync with it. Scrolling a log window to the top auto-loads older entries via journalctl's --until, for journald-backed modules only — the file backend's plain tail -n has no seek/offset to page further back with, so it reports nothing older available instead.
  • Log counts — per-level message counts (DEBUG / INFO / WARNING / ERROR / CRITICAL) for the last 24 h, using binary search on the log file to avoid reading the whole file.

Project layout

pyobs_web_admin/
settings.py Django project settings
local_settings.py.example Template for local overrides (not committed)
urls.py URL config
modules/
services.py All pyobs process and filesystem logic
views.py HTML pages + JSON API endpoints
proxy.py HTTP client for hub → remote host calls
ejabberd.py mod_http_api (status) + ejabberdctl (user management) client
middleware.py Login-required redirect + hub token auth
context_processors.py
deploy/
pyobs-web-admin.service systemd unit file
ejabberdctl-sudo.sh sudo wrapper for EJABBERDCTL -- see ejabberd user management
templates/
base.html Bootstrap 5 layout with responsive sidebar
modules/
dashboard.html
detail.html
shared_detail.html Config editor for *.shared.yaml files
new_module.html "Create a new module" form
packages.html Package list + Update/Reinstall actions
fleet_overview.html Fleet-wide host summary + package-version matrix
all_logs.html Fleet-wide live log tail
acl_matrix.html Fleet-wide ACL matrix
xmpp_users.html Fleet-wide XMPP account list + write actions
registration/
login.html

About

Web GUI for managing pyobs modules

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

pyobs-web-admin

A web-based administration interface for pyobs, the robotic telescope framework. It lets you start, stop, and restart modules, tail and filter their logs, and view and edit their configuration files — all from a browser.

Dashboard showing modules grouped under Stopped and Deactivated headings, with summary tiles and per-row quick-action buttons

Features

  • Dashboard — sortable list view of all modules with:
    • Running / stopped / total summary counts plus total CPU and RAM
    • Per-module status badge, RAM, CPU, and uptime columns — click any header to sort, reset icon restores default grouping
    • Modules grouped under Running / Stopped / Deactivated headers when sorted by status
    • Warning/error log counts for the last 24 h (highlighted in colour if non-zero)
    • Quick start, restart, stop, and activate/deactivate buttons per module
    • Start All, Restart All, and Stop All bulk actions
    • Inactive modules (prefixed with _) are excluded from bulk start/restart
    • Outdated badge on any running module whose loaded pyobs-* versions lag the installed ones (parsed from pyobs-core's startup "Loaded pyobs packages:" log line — comm-independent, works for comm-less modules too), plus a Restart outdated bulk action that restarts only those
    • Responsive: on small screens the table collapses to status dot + name + log counts + actions
  • Module detail — per-module view with four tabs:
    • Overview — current status, PID, uptime, CPU and memory usage, running pyobs-* package versions (flagged when they lag the installed set), per-level log message counts (last 24 h), XMPP connection state (if enabled), start/restart/stop/activate/deactivate control
    • Logs — live log tail with text filter, time-range filter (set a start date to load all logs since that instant, or click a line to set it), colour-coded by severity, auto-refresh; scrolling to the top auto-loads older entries (journald-backed modules, or file-backed modules once a start date is set)
    • Config — YAML editor with syntax highlighting and colour-coded {include} lines; included shared configs are shown as clickable links
    • ACL — point-and-click editor for the module's acl: block: click to allow/deny known modules, add other callers, toggle enforce/log mode
  • New module — a "+" next to the sidebar's Modules section creates a brand-new <name>.yaml config (a minimal starter with just a class: key) and takes you straight to its Config tab to fill in the rest
  • Shared configs*.shared.yaml config fragments listed in a separate sidebar section with a YAML-highlighted config editor (no start/stop controls)
  • Packages (/packages/) — every installed pyobs-* package (plus anything else listed in PYOBS_MANAGED_PACKAGES) with its installed and latest-PyPI version, and a one-click Update button; git/URL-installed packages get a Reinstall action instead (see Package management)
  • Overview (/overview/) — fleet-wide summary, one row per configured host: reachable or not, running/stopped/total counts, aggregate CPU/RAM, linking into that host's own Dashboard, plus a package-version matrix (one row per pyobs-* package, one column per host) so version drift across the fleet is visible at a glance. Deliberately no bulk or per-module actions — those stay on the per-host Dashboard, since a fleet-wide "Stop All" from one button is a real footgun
  • All Logs (/logs/) — fleet-wide live log tail across every module on every configured host, same filtering and scroll-to-load-older behaviour as a module's own Logs tab
  • ACL Matrix (/acl/) — fleet-wide read-only matrix of which module can call which, merged across every configured host
  • Hub mode — control multiple remote pyobs hosts from a single browser tab; remote hosts are listed in the sidebar and all actions are proxied transparently
  • ejabberd / XMPP status (optional) — dashboard summary tile and per-module connected/not-connected indicator, plus a session/last-seen/registered-account block on each module's own page, for modules with a comm.user in their config — closes the gap between "the process is running" and "the module is actually reachable over XMPP" (see ejabberd integration)
  • ejabberd / XMPP user management (optional, builds on the above) — register, reset password, ban/unban, unregister, and kick XMPP accounts, either from a module's own Overview tab or from a fleet-wide Users page (/xmpp-users/) listing every registered account across every host, cross-referenced against which module(s) use it and which one is actually running. Safe by design for an identity shared across more than one module's comm.user — a password reset writes back to every module sharing it, and destructive actions name which other modules are affected before you confirm (see ejabberd user management)
  • Responsive — works on mobile with a slide-in sidebar
  • No pyobs-core dependency — communicates with pyobs directly via subprocess; no Python imports from pyobs-core
  • Keycloak login (optional) — SSO on top of the default shared admin/password login, with per-person access granted/revoked via Keycloak group membership (see Keycloak login)

Technology

LayerChoice
BackendPython 3.13, Django 6, psutil
WSGI serverGunicorn
FrontendBootstrap 5 (CDN), CodeMirror 5 (CDN), vanilla JS
Package manageruv
AuthShared admin/password login plus optional Keycloak SSO; both share one SQLite db (sessions table for both, User table for Keycloak-linked accounts only)
Hub authPre-shared token in X-Hub-Token header; CSRF bypassed for hub requests

Development setup

git clone https://github.com/pyobs/pyobs-web-admin.git
cd pyobs-web-admin
uv sync
uv run python manage.py migrate
uv run python manage.py runserver

Create pyobs_web_admin/local_settings.py (see Configuration below).


Production setup

1. Install the app

cd /opt/pyobs
git clone https://github.com/pyobs/pyobs-web-admin.git
cd pyobs-web-admin
uv sync

2. Configure

Copy and edit the local settings file:

cp pyobs_web_admin/local_settings.py.example pyobs_web_admin/local_settings.py # or create from scratch$EDITOR pyobs_web_admin/local_settings.py

Minimum required settings for production (see Configuration):

DEBUG=FalseSECRET_KEY="..."# generate belowALLOWED_HOSTS= ["your-hostname-or-ip"]
ADMIN_USERNAME="admin"ADMIN_PASSWORD_HASH="..."# generate belowPYOBS_EXEC="/opt/pyobs/venv/bin/pyobs"

Generate a secret key:

DJANGO_SETTINGS_MODULE=pyobs_web_admin.settings uv run python -c \
"from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"

Generate a password hash:

DJANGO_SETTINGS_MODULE=pyobs_web_admin.settings uv run python -c \
"from django.contrib.auth.hashers import make_password; print(make_password('yourpassword'))"

3. Install the systemd service

cp deploy/pyobs-web-admin.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now pyobs-web-admin

Check that it started:

systemctl status pyobs-web-admin
journalctl -u pyobs-web-admin -f

4. Configure nginx

Add a site configuration that proxies to gunicorn on port 8765:

server{listen80;server_name your-hostname-or-ip;location / {proxy_passhttp://127.0.0.1:8765;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;proxy_set_header X-Forwarded-Proto $scheme;}}

If you add TLS (strongly recommended for any non-private network), also set in local_settings.py:

SESSION_COOKIE_SECURE=TrueCSRF_COOKIE_SECURE=True

Configuration

All runtime configuration lives in pyobs_web_admin/local_settings.py, which is not committed to version control. A full reference:

# DjangoSECRET_KEY="..."# required in productionDEBUG=False# set True only in developmentALLOWED_HOSTS= ["*"] # restrict to hostname/IP in production# HTTPS (enable once TLS is in place)# SESSION_COOKIE_SECURE = True# CSRF_COOKIE_SECURE = True# AuthenticationADMIN_USERNAME="admin"ADMIN_PASSWORD_HASH="pbkdf2_sha256$..."# see generation command above# Keycloak login (optional — see Keycloak login section)# PYOBS_AUTH = {# "SERVER_URL": "https://keycloak.example.org",# "REALM": "pyobs",# "CLIENT_ID": "web-admin",# "CLIENT_SECRET": "",# "REDIRECT_URI": "https://your.domain.com/accounts/keycloak/callback/",# "POST_LOGOUT_REDIRECT_URI": "https://your.domain.com/",# # Optional one-click IdP login: IDP_HINT is passed to Keycloak as kc_idp_hint (skips its# # login/IdP-selection page, going straight to that identity provider, e.g. GWDG SSO);# # IDP_LABEL is the button label on the login page. Leave both unset for the plain# # single "Log in with Keycloak" button.# "IDP_HINT": "gwdg",# "IDP_LABEL": "GWDG",# "USER_RESOLVER": "pyobs_web_admin.authentication.keycloak.resolve_user",# # Only members of this Keycloak group are authorized to use web-admin - create it (and add# # people to it) in the Keycloak admin console before anyone logs in.# "REQUIRED_GROUPS": ["/pyobs-web-admin"],# # Keycloak-independent kill switch, layered on top of REQUIRED_GROUPS above: an admin can# # deactivate a specific local User (Django admin) regardless of their Keycloak group# # membership.# "ENFORCE_LOCAL_ACTIVE": True,# }# pyobs pathsPYOBS_EXEC="/opt/pyobs/venv/bin/pyobs"# path to the pyobs executablePYOBS_CONFIG_DIR="/opt/pyobs/config"# directory containing *.yaml module configsPYOBS_LOG_DIR="/opt/pyobs/log"# directory containing *.log filesPYOBS_RUN_DIR="/opt/pyobs/run"# directory for PID filesPYOBS_LOG_LEVEL="info"# log level passed to pyobs on startPYOBS_LOG_BACKEND=None# None (default): auto-detect from pyobsd's own# config; "file" or "journald" to override. If# "journald": the account running pyobs-web-admin# needs journal read access — `usermod -aG# systemd-journal <account>` (preferred over `adm`,# which grants broader log access than needed) —# otherwise logs come back silently empty, no error.# Packages page (optional — see Package management section)PYOBS_MANAGED_PACKAGES= [] # e.g. ["pyobs-core[full]", "my-custom-driver",# "pyobs-iagvt[gui] @ git+ssh://git@gitlab.example.org/iagvt/pyobs-iagvt.git"]# Hub (optional — see Hub mode section)HUB_TOKEN=""# deprecated, single-token form -- see HUB_CLIENTSHUB_CLIENTS= [] # named tokens for external callers (hub, scripts, ...)HUB_HOSTS= [] # remote hosts this instance controls# ejabberd integration (optional — see ejabberd integration section)EJABBERD_ENABLED=False# show XMPP status on the dashboard/module pagesEJABBERD_HOST="localhost"# which host runs ejabberd -- "localhost" or a HUB_HOSTS nameEJABBERD_DOMAIN=""# the XMPP vhost ejabberd servesEJABBERD_API_URL="http://127.0.0.1:5281/api"# mod_http_api base URLEJABBERDCTL="ejabberdctl"# required for user management writes (register/# reset/ban/unregister/kick); also a read fallback# if EJABBERD_API_URL can't be reached -- see# ejabberd user management section below

Keycloak login

The shared admin/password login above is the default and always available — Keycloak is an additive option on top of it, not a replacement, so it stays working as a break-glass fallback if Keycloak itself is unreachable. Unlike the shared account, a Keycloak-linked person's access is granted or revoked individually via Keycloak group membership, without touching anyone else's access or handing out the shared password.

Enabling it

  1. Register a client for this instance in your Keycloak realm (redirect URI https://your.domain.com/accounts/keycloak/callback/, post-logout redirect URI https://your.domain.com/).
  2. Add the PYOBS_AUTH block shown in Configuration to local_settings.py, filling in SERVER_URL, CLIENT_ID, and CLIENT_SECRET. REQUIRED_GROUPS defaults to ["/pyobs-web-admin"] — create that group in the realm and add whoever should have access to it (Keycloak admin console) before anyone tries to log in, or every login is refused as "not authorized".
  3. Run manage.py migrate (creates db.sqlite3, used for the Keycloak-linked User table and, as of this version, sessions too — SESSION_ENGINE is now database-backed rather than signed cookies, since a Keycloak session carries a refresh token that shouldn't be serialized into the browser. This applies to every session app-wide, including the shared admin/password login's — it's no longer fully DB-free, but it's the one Django setting shared by both login paths, so it can't be split per-path). Expired sessions aren't purged automatically — run manage.py clearsessions periodically (e.g. a daily cron/systemd timer) or django_session grows unbounded in a long-lived deployment.

The login page then shows a "Log in with Keycloak" button. A first-time Keycloak login mints a local User (linked to an existing one by email, falling back to username, if either matches), active immediately — whether they're actually let in is decided by REQUIRED_GROUPS above, not by anything in this app's own database.

Setting IDP_HINT (plus IDP_LABEL for the button text) switches the login page to two buttons: "Log in with <IDP_LABEL>" goes straight to that identity provider (skipping Keycloak's own login/IdP-selection page), and "Log in with local Keycloak account" keeps the local-account path reachable for anyone without that IdP's identity. Leave IDP_HINT unset for the single-button behavior above.

Granting or revoking a person's access

This is now done in the Keycloak admin console, not this app's Django admin: add or remove the person from the /pyobs-web-admin group (or whatever REQUIRED_GROUPS is set to). The change takes effect at their next login. Django's /admin/ site is still mounted (the shared admin/password account and any other Django superuser can still get in there for other reasons), with the ENFORCE_LOCAL_ACTIVE setting above (recommended, and the example config's default), toggling a Keycloak-linked account's Active flag there still works too, as an additional, Keycloak-independent kill switch on top of the group gate. Leave ENFORCE_LOCAL_ACTIVE unset if you'd rather Keycloak group membership be the only thing that matters.

The shared admin/password account works at /admin/ directly (no prior visit to /login/ needed first) because manage.py migrate syncs a matching superuser User automatically — see pyobs_web_admin.authentication.admin_sync, and Configuration for the ADMIN_USERNAME/ADMIN_PASSWORD_HASH settings that drive it.


Hub mode

pyobs-web-admin can act as a hub to control multiple remote pyobs hosts from a single browser session. When remote hosts are configured, a Hosts section appears at the top of the sidebar. Clicking a host switches the active context — all subsequent actions (start/stop/logs/config) are transparently proxied to that host's API.

Setting up the hub

On the hub (the machine you browse to), add to local_settings.py:

HUB_HOSTS= [
{"name": "obs1", "url": "http://obs1:8765", "token": "shared-secret"},
{"name": "obs2", "url": "http://obs2:8765", "token": "another-secret"},
]

On each remote host, give it a named client entry matching the token the hub sends:

HUB_CLIENTS= [
{"name": "hub", "token": "shared-secret"}, # must match HUB_HOSTS' token above
]

The hub — or any other external caller, such as a script calling the API directly — authenticates to remote instances via an X-Hub-Token header. Remote instances check that header against every entry in HUB_CLIENTS; a match bypasses the normal browser session/CSRF check, so the caller can invoke the API without a login session. Give each caller its own named entry so it can be revoked or rotated independently. Tokens are plain pre-shared strings — use long random values and keep them secret.

The older HUB_TOKEN setting (a single unnamed token) still works for backwards compatibility, equivalent to a HUB_CLIENTS entry named "default".

There's no separate "external" API — every endpoint the hub calls (start/stop, logs, config, ACL, packages, ejabberd user management, ...) is the same plain JSON API any X-Hub-Token-authenticated caller can use directly, e.g. from a script. See docs/source/api_endpoints.rst for the full endpoint reference.


ejabberd integration

If the ejabberd server pyobs's XMPP comm layer connects through runs on the same host, pyobs-web-admin can show live connection state alongside the process status it already tracks — closing the gap between "the module's process is running" and "the module is actually reachable over XMPP." When enabled: the dashboard gets a summary tile (how many of this installation's own modules, identified by their config's comm.user, are currently XMPP-connected) plus a small icon per module row; each module's own page gets a session/last-seen/registered-account block in its Overview tab. A module with no comm: block in its config (e.g. a pure HTTP module) is skipped entirely — there's nothing for it to connect to.

Enabling it

In local_settings.py:

EJABBERD_ENABLED=TrueEJABBERD_HOST="localhost"# or a HUB_HOSTS name, if ejabberd runs on a different hostEJABBERD_DOMAIN="your-xmpp-domain"# the vhost ejabberd serves, e.g. "pyobs.example.org"EJABBERD_API_URL="http://127.0.0.1:5281/api"

If EJABBERD_HOST names a HUB_HOSTS entry instead of "localhost", every instance in the fleet transparently proxies its ejabberd queries to that one host — only that host needs EJABBERD_API_URL actually pointed at a real ejabberd; every other instance just needs EJABBERD_HOST set to its name.

ejabberd-side configuration

This talks to ejabberd's HTTP admin API (mod_http_api), not ejabberdctl — about 50–60x faster per call, since it hits the already-running node directly instead of spawning a new Erlang VM per invocation (ejabberdctl is used as a fallback only if EJABBERD_API_URL can't be reached). Add this to ejabberd's own config:

listen:
-
port: 5281ip: "127.0.0.1"# loopback only -- see security note belowmodule: ejabberd_httprequest_handlers:
/api: mod_http_api # add this to an *existing* listener's request_handlers if one's# already on this port (e.g. for BOSH/WebSocket) -- ejabberd only# allows one listener per portmodules:
mod_http_api: {}api_permissions:
"console commands":
from: [ejabberd_ctl]who: allwhat: "*""pyobs-web-admin readonly":
from: [mod_http_api]who:
access:
allow:
- acl: loopbackwhat:
- "status"
- "stats"
- "connected_users_info"
- "registered_users"
- "user_sessions_info"
- "get_last"
- "check_account"

Reload ejabberd's config after adding this (ejabberdctl reload_config, or a restart if that doesn't pick up the new listener). The what: list is a deliberate whitelist — leave it as-is; mod_http_api can also expose account-management commands (register/unregister/change_password) that should never be reachable here.

Security note. Access is IP-based, not credential-based — any request from loopback is trusted, no password or token is involved. This blocks the network (a request from outside the host is rejected), but not other processes on the same machine, which get the same access pyobs-web-admin does. That's an accepted tradeoff for a dedicated, single-purpose observatory control host, not a shared one — reassess if that's not your deployment.


ejabberd user management

Builds on ejabberd integration above (requires EJABBERD_ENABLED = True) to add write actions on top of the read-only status it already shows: register, reset password, ban / unban, unregister, and kick (force-disconnect one session without touching the account) for any module's comm.user. Reversible actions get a single confirmation dialog; unregister — the one action with no undo — requires retyping the account's username first. An identity shared by more than one module's comm.user (a real, supported scenario — e.g. a test copy of a module reusing a real module's identity) is handled safely: a password reset writes the new password back into every module sharing it, not just the one the action was triggered from, and destructive actions (ban/unregister) name every other module affected before you can confirm.

This surfaces in two places:

  • The module detail page's existing ejabberd block (Overview tab) — register when the account isn't registered yet, reset/ban/unregister when it is.
  • A dedicated Users page (/xmpp-users/), linked from the sidebar whenever EJABBERD_ENABLED = True — every registered XMPP account across every configured host, in one fleet-wide, mobile-friendly list. Unlike the module page, this also covers accounts with no owning module at all (e.g. admin) via a manual "register account" form, and accounts shared by more than one module show a status dot marking which one is actually the connected session.

Transport: ejabberdctl, not mod_http_api

Unlike the read path above, writes always go through the ejabberdctl CLI, never mod_http_api — a write's cost is dominated by a human clicking a confirmation dialog, not command latency, so the ~50–60x speed advantage HTTP has for reads doesn't matter here. This also means no api_permissions change is needed for user management specifically — but see the security note below, since ejabberdctl itself is far more powerful than the read-only HTTP whitelist above.

ejabberdctl normally refuses to run as anything other than root or the ejabberd system user ("can only be run by root or the user ejabberd"), which only matters here since writes always need it (the read path mostly avoids it via mod_http_api). If pyobs-web-admin runs as its own service user (e.g. pyobs, per deploy/pyobs-web-admin.service), give that user a narrowly-scoped passwordless sudo rule for just this one binary:

# /etc/sudoers.d/pyobs-web-admin-ejabberdctl
pyobs ALL=(root) NOPASSWD: /usr/sbin/ejabberdctl

(adjust the username and binary path for your setup — check with which ejabberdctl), then point EJABBERDCTL in local_settings.py at the wrapper script committed at the repo root:

EJABBERDCTL="/opt/pyobs/pyobs-web-admin/ejabberdctl-sudo.sh"

ejabberdctl-sudo.sh is a two-line wrapper (exec sudo -n ejabberdctl "$@") — the -n flag makes sudo fail fast instead of hanging on a password prompt if the sudoers rule above isn't in place. Not needed at all if pyobs-web-admin already runs as root or ejabberd.

Security note. This is a materially bigger trust step than the read-only integration above: ejabberdctl can do anything an ejabberd administrator can do, not just the small read-only whitelist mod_http_api's api_permissions enforces. There is no OS-level or ejabberd-level restriction narrowing what the sudo rule allows beyond "run ejabberdctl as root at all" — this app's own tiered confirmation dialogs are the only safety net between a logged-in admin and any ejabberdctl subcommand this app happens to call. Acceptable for the same reason as the read path's IP-based trust: a dedicated, single-purpose observatory control host with one admin identity, not a shared or multi-tenant one.


Package management

The Packages page (/packages/) lists every installed pyobs-* package (plus anything extra listed in PYOBS_MANAGED_PACKAGES) alongside its latest release on PyPI, and lets you update any of them with one click. It always reflects pip's own view of the environment PYOBS_EXEC runs in (via the sibling pip next to it) — nothing here is invented or cached. The fleet-wide Overview page (/overview/) additionally shows a package-version matrix across every configured host, so a package that's drifted out of sync on one host is easy to spot.

PYOBS_MANAGED_PACKAGES

pip never remembers how a package was originally installed — only what's currently there — so a bare pip install --upgrade pyobs-core would silently drop a [full] extra forever, and a package that isn't on PyPI at all has nothing for the Packages page to even find. PYOBS_MANAGED_PACKAGES fills in what pip's own installed-environment metadata can't recover:

PYOBS_MANAGED_PACKAGES= [
"pyobs-core[full]", # keep using this extra on every future upgrade"my-custom-driver", # a non-"pyobs"-prefixed package, shown/managed alongside pyobs-*"pyobs-iagvt[gui] @ git+ssh://git@gitlab.example.org/iagvt/pyobs-iagvt.git", # git-installed
]
  • Extras — list the full name[extra] spec and future updates keep using it, instead of reverting to a bare install.
  • Non-pyobs-prefixed packages — a bare name makes it show up on the Packages page and be upgradable through it too.
  • Git/URL-installed packages — a PEP 508 direct reference (name[extras] @ <url>) for a package that isn't published on PyPI. The Packages page skips the (futile) PyPI version check for these and shows a Reinstall action instead, which just re-runs pip install --upgrade <spec> to pick up whatever's newest at that URL/ref.

Malformed entries are skipped rather than raising, so a typo here can't break the whole page.

Installing a private git-hosted package

For a private repository (e.g. an institute-internal driver like pyobs-iagvt above), pip running non-interactively needs its own credentials — an SSH deploy key is the recommended way:

  1. Generate a passwordless key for the OS user that actually runs pyobs-web-admin's own process (gunicorn/the systemd service user — not necessarily the pyobs service account, since the Packages page's pip install inherits this process's environment, not pyobs's):
    ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N ""
  2. Add the public key as a GitLab Deploy Key on the private project (Settings → Repository → Deploy keys), read-only access is enough. Scoped to just that repo and revocable independently of any person's account — prefer this over a personal SSH key.
  3. Pre-seed known_hosts for that user, so the first non-interactive install doesn't hang waiting on a host-key prompt:
    ssh-keyscan gitlab.example.org >>~/.ssh/known_hosts
  4. Use git+ssh://, not git+https://, in PYOBS_MANAGED_PACKAGES:
    PYOBS_MANAGED_PACKAGES= [
    "pyobs-iagvt[gui] @ git+ssh://git@gitlab.example.org/iagvt/pyobs-iagvt.git",
    ]
  5. In hub mode, repeat this on every host that's expected to manage the package — package updates run on whichever host is currently active, not just the hub, so each of them needs its own copy of the key (or the same key deployed to all of them).

An SSH key is preferred over embedding a GitLab Deploy Token in an HTTPS URL: the token would end up as a literal pip install command-line argument, visible to any other local user via ps aux for the few seconds the install runs. An SSH key file avoids that exposure.


How modules are managed

  • Discovery — all *.yaml files in PYOBS_CONFIG_DIR (excluding *.shared.yaml) are treated as modules. *.shared.yaml files are listed separately as shared configs.
  • Creating a module — the "New module" button writes a fresh <name>.yaml with a minimal starter (class: key only); PYOBS_CONFIG_DIR is created automatically if it doesn't exist yet.
  • Activate / Deactivate — deactivating a module renames its config from name.yaml to _name.yaml (stopping it first if running); activating renames it back. Deactivated modules are excluded from Start All and Restart All.
  • Start — runs pyobs --pid-file <run>/<name>.pid --log-file <log>/<name>.log --log-level <level> <config>. pyobs daemonises itself via python-daemon. If the effective log backend is "journald" (see below), --syslog is passed instead of --log-file — pyobs then logs directly to the systemd journal, tagged SYSLOG_IDENTIFIER=pyobs and PYOBS_MODULE=<name>.
  • Stop — sends SIGTERM to the PID in the PID file; falls back to SIGKILL after 5 s.
  • Restart — stop followed by start.
  • Status — checks whether the process with the stored PID is alive (os.kill(pid, 0)).
  • Resource usage — uptime, CPU %, and RSS memory read via psutil on every status poll.
  • Logs — read from PYOBS_LOG_DIR's flat files by default, or from the systemd journal via journalctl if the effective log backend is "journald"; the log viewer and per-level counts work identically either way. The effective backend is PYOBS_LOG_BACKEND if set explicitly, otherwise auto-detected from pyobsd's own config file (~/.config/pyobs.yaml, /etc/pyobs.yaml, or /opt/pyobs/storage/pyobs.yaml, first found wins) — the same file pyobsd (pyobs-core's daemon manager) reads to decide whether it starts modules with --syslog, so this can't silently drift out of sync with it. Scrolling a log window to the top auto-loads older entries via journalctl's --until, for journald-backed modules only — the file backend's plain tail -n has no seek/offset to page further back with, so it reports nothing older available instead.
  • Log counts — per-level message counts (DEBUG / INFO / WARNING / ERROR / CRITICAL) for the last 24 h, using binary search on the log file to avoid reading the whole file.

Project layout

pyobs_web_admin/
settings.py Django project settings
local_settings.py.example Template for local overrides (not committed)
urls.py URL config
modules/
services.py All pyobs process and filesystem logic
views.py HTML pages + JSON API endpoints
proxy.py HTTP client for hub → remote host calls
ejabberd.py mod_http_api (status) + ejabberdctl (user management) client
middleware.py Login-required redirect + hub token auth
context_processors.py
deploy/
pyobs-web-admin.service systemd unit file
ejabberdctl-sudo.sh sudo wrapper for EJABBERDCTL -- see ejabberd user management
templates/
base.html Bootstrap 5 layout with responsive sidebar
modules/
dashboard.html
detail.html
shared_detail.html Config editor for *.shared.yaml files
new_module.html "Create a new module" form
packages.html Package list + Update/Reinstall actions
fleet_overview.html Fleet-wide host summary + package-version matrix
all_logs.html Fleet-wide live log tail
acl_matrix.html Fleet-wide ACL matrix
xmpp_users.html Fleet-wide XMPP account list + write actions
registration/
login.html

About

Web GUI for managing pyobs modules

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

pyobs-web-admin

A web-based administration interface for pyobs, the robotic telescope framework. It lets you start, stop, and restart modules, tail and filter their logs, and view and edit their configuration files — all from a browser.

Dashboard showing modules grouped under Stopped and Deactivated headings, with summary tiles and per-row quick-action buttons

Features

  • Dashboard — sortable list view of all modules with:
    • Running / stopped / total summary counts plus total CPU and RAM
    • Per-module status badge, RAM, CPU, and uptime columns — click any header to sort, reset icon restores default grouping
    • Modules grouped under Running / Stopped / Deactivated headers when sorted by status
    • Warning/error log counts for the last 24 h (highlighted in colour if non-zero)
    • Quick start, restart, stop, and activate/deactivate buttons per module
    • Start All, Restart All, and Stop All bulk actions
    • Inactive modules (prefixed with _) are excluded from bulk start/restart
    • Outdated badge on any running module whose loaded pyobs-* versions lag the installed ones (parsed from pyobs-core's startup "Loaded pyobs packages:" log line — comm-independent, works for comm-less modules too), plus a Restart outdated bulk action that restarts only those
    • Responsive: on small screens the table collapses to status dot + name + log counts + actions
  • Module detail — per-module view with four tabs:
    • Overview — current status, PID, uptime, CPU and memory usage, running pyobs-* package versions (flagged when they lag the installed set), per-level log message counts (last 24 h), XMPP connection state (if enabled), start/restart/stop/activate/deactivate control
    • Logs — live log tail with text filter, time-range filter (set a start date to load all logs since that instant, or click a line to set it), colour-coded by severity, auto-refresh; scrolling to the top auto-loads older entries (journald-backed modules, or file-backed modules once a start date is set)
    • Config — YAML editor with syntax highlighting and colour-coded {include} lines; included shared configs are shown as clickable links
    • ACL — point-and-click editor for the module's acl: block: click to allow/deny known modules, add other callers, toggle enforce/log mode
  • New module — a "+" next to the sidebar's Modules section creates a brand-new <name>.yaml config (a minimal starter with just a class: key) and takes you straight to its Config tab to fill in the rest
  • Shared configs*.shared.yaml config fragments listed in a separate sidebar section with a YAML-highlighted config editor (no start/stop controls)
  • Packages (/packages/) — every installed pyobs-* package (plus anything else listed in PYOBS_MANAGED_PACKAGES) with its installed and latest-PyPI version, and a one-click Update button; git/URL-installed packages get a Reinstall action instead (see Package management)
  • Overview (/overview/) — fleet-wide summary, one row per configured host: reachable or not, running/stopped/total counts, aggregate CPU/RAM, linking into that host's own Dashboard, plus a package-version matrix (one row per pyobs-* package, one column per host) so version drift across the fleet is visible at a glance. Deliberately no bulk or per-module actions — those stay on the per-host Dashboard, since a fleet-wide "Stop All" from one button is a real footgun
  • All Logs (/logs/) — fleet-wide live log tail across every module on every configured host, same filtering and scroll-to-load-older behaviour as a module's own Logs tab
  • ACL Matrix (/acl/) — fleet-wide read-only matrix of which module can call which, merged across every configured host
  • Hub mode — control multiple remote pyobs hosts from a single browser tab; remote hosts are listed in the sidebar and all actions are proxied transparently
  • ejabberd / XMPP status (optional) — dashboard summary tile and per-module connected/not-connected indicator, plus a session/last-seen/registered-account block on each module's own page, for modules with a comm.user in their config — closes the gap between "the process is running" and "the module is actually reachable over XMPP" (see ejabberd integration)
  • ejabberd / XMPP user management (optional, builds on the above) — register, reset password, ban/unban, unregister, and kick XMPP accounts, either from a module's own Overview tab or from a fleet-wide Users page (/xmpp-users/) listing every registered account across every host, cross-referenced against which module(s) use it and which one is actually running. Safe by design for an identity shared across more than one module's comm.user — a password reset writes back to every module sharing it, and destructive actions name which other modules are affected before you confirm (see ejabberd user management)
  • Responsive — works on mobile with a slide-in sidebar
  • No pyobs-core dependency — communicates with pyobs directly via subprocess; no Python imports from pyobs-core
  • Keycloak login (optional) — SSO on top of the default shared admin/password login, with per-person access granted/revoked via Keycloak group membership (see Keycloak login)

Technology

LayerChoice
BackendPython 3.13, Django 6, psutil
WSGI serverGunicorn
FrontendBootstrap 5 (CDN), CodeMirror 5 (CDN), vanilla JS
Package manageruv
AuthShared admin/password login plus optional Keycloak SSO; both share one SQLite db (sessions table for both, User table for Keycloak-linked accounts only)
Hub authPre-shared token in X-Hub-Token header; CSRF bypassed for hub requests

Development setup

git clone https://github.com/pyobs/pyobs-web-admin.git
cd pyobs-web-admin
uv sync
uv run python manage.py migrate
uv run python manage.py runserver

Create pyobs_web_admin/local_settings.py (see Configuration below).


Production setup

1. Install the app

cd /opt/pyobs
git clone https://github.com/pyobs/pyobs-web-admin.git
cd pyobs-web-admin
uv sync

2. Configure

Copy and edit the local settings file:

cp pyobs_web_admin/local_settings.py.example pyobs_web_admin/local_settings.py # or create from scratch$EDITOR pyobs_web_admin/local_settings.py

Minimum required settings for production (see Configuration):

DEBUG=FalseSECRET_KEY="..."# generate belowALLOWED_HOSTS= ["your-hostname-or-ip"]
ADMIN_USERNAME="admin"ADMIN_PASSWORD_HASH="..."# generate belowPYOBS_EXEC="/opt/pyobs/venv/bin/pyobs"

Generate a secret key:

DJANGO_SETTINGS_MODULE=pyobs_web_admin.settings uv run python -c \
"from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"

Generate a password hash:

DJANGO_SETTINGS_MODULE=pyobs_web_admin.settings uv run python -c \
"from django.contrib.auth.hashers import make_password; print(make_password('yourpassword'))"

3. Install the systemd service

cp deploy/pyobs-web-admin.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now pyobs-web-admin

Check that it started:

systemctl status pyobs-web-admin
journalctl -u pyobs-web-admin -f

4. Configure nginx

Add a site configuration that proxies to gunicorn on port 8765:

server{listen80;server_name your-hostname-or-ip;location / {proxy_passhttp://127.0.0.1:8765;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;proxy_set_header X-Forwarded-Proto $scheme;}}

If you add TLS (strongly recommended for any non-private network), also set in local_settings.py:

SESSION_COOKIE_SECURE=TrueCSRF_COOKIE_SECURE=True

Configuration

All runtime configuration lives in pyobs_web_admin/local_settings.py, which is not committed to version control. A full reference:

# DjangoSECRET_KEY="..."# required in productionDEBUG=False# set True only in developmentALLOWED_HOSTS= ["*"] # restrict to hostname/IP in production# HTTPS (enable once TLS is in place)# SESSION_COOKIE_SECURE = True# CSRF_COOKIE_SECURE = True# AuthenticationADMIN_USERNAME="admin"ADMIN_PASSWORD_HASH="pbkdf2_sha256$..."# see generation command above# Keycloak login (optional — see Keycloak login section)# PYOBS_AUTH = {# "SERVER_URL": "https://keycloak.example.org",# "REALM": "pyobs",# "CLIENT_ID": "web-admin",# "CLIENT_SECRET": "",# "REDIRECT_URI": "https://your.domain.com/accounts/keycloak/callback/",# "POST_LOGOUT_REDIRECT_URI": "https://your.domain.com/",# # Optional one-click IdP login: IDP_HINT is passed to Keycloak as kc_idp_hint (skips its# # login/IdP-selection page, going straight to that identity provider, e.g. GWDG SSO);# # IDP_LABEL is the button label on the login page. Leave both unset for the plain# # single "Log in with Keycloak" button.# "IDP_HINT": "gwdg",# "IDP_LABEL": "GWDG",# "USER_RESOLVER": "pyobs_web_admin.authentication.keycloak.resolve_user",# # Only members of this Keycloak group are authorized to use web-admin - create it (and add# # people to it) in the Keycloak admin console before anyone logs in.# "REQUIRED_GROUPS": ["/pyobs-web-admin"],# # Keycloak-independent kill switch, layered on top of REQUIRED_GROUPS above: an admin can# # deactivate a specific local User (Django admin) regardless of their Keycloak group# # membership.# "ENFORCE_LOCAL_ACTIVE": True,# }# pyobs pathsPYOBS_EXEC="/opt/pyobs/venv/bin/pyobs"# path to the pyobs executablePYOBS_CONFIG_DIR="/opt/pyobs/config"# directory containing *.yaml module configsPYOBS_LOG_DIR="/opt/pyobs/log"# directory containing *.log filesPYOBS_RUN_DIR="/opt/pyobs/run"# directory for PID filesPYOBS_LOG_LEVEL="info"# log level passed to pyobs on startPYOBS_LOG_BACKEND=None# None (default): auto-detect from pyobsd's own# config; "file" or "journald" to override. If# "journald": the account running pyobs-web-admin# needs journal read access — `usermod -aG# systemd-journal <account>` (preferred over `adm`,# which grants broader log access than needed) —# otherwise logs come back silently empty, no error.# Packages page (optional — see Package management section)PYOBS_MANAGED_PACKAGES= [] # e.g. ["pyobs-core[full]", "my-custom-driver",# "pyobs-iagvt[gui] @ git+ssh://git@gitlab.example.org/iagvt/pyobs-iagvt.git"]# Hub (optional — see Hub mode section)HUB_TOKEN=""# deprecated, single-token form -- see HUB_CLIENTSHUB_CLIENTS= [] # named tokens for external callers (hub, scripts, ...)HUB_HOSTS= [] # remote hosts this instance controls# ejabberd integration (optional — see ejabberd integration section)EJABBERD_ENABLED=False# show XMPP status on the dashboard/module pagesEJABBERD_HOST="localhost"# which host runs ejabberd -- "localhost" or a HUB_HOSTS nameEJABBERD_DOMAIN=""# the XMPP vhost ejabberd servesEJABBERD_API_URL="http://127.0.0.1:5281/api"# mod_http_api base URLEJABBERDCTL="ejabberdctl"# required for user management writes (register/# reset/ban/unregister/kick); also a read fallback# if EJABBERD_API_URL can't be reached -- see# ejabberd user management section below

Keycloak login

The shared admin/password login above is the default and always available — Keycloak is an additive option on top of it, not a replacement, so it stays working as a break-glass fallback if Keycloak itself is unreachable. Unlike the shared account, a Keycloak-linked person's access is granted or revoked individually via Keycloak group membership, without touching anyone else's access or handing out the shared password.

Enabling it

  1. Register a client for this instance in your Keycloak realm (redirect URI https://your.domain.com/accounts/keycloak/callback/, post-logout redirect URI https://your.domain.com/).
  2. Add the PYOBS_AUTH block shown in Configuration to local_settings.py, filling in SERVER_URL, CLIENT_ID, and CLIENT_SECRET. REQUIRED_GROUPS defaults to ["/pyobs-web-admin"] — create that group in the realm and add whoever should have access to it (Keycloak admin console) before anyone tries to log in, or every login is refused as "not authorized".
  3. Run manage.py migrate (creates db.sqlite3, used for the Keycloak-linked User table and, as of this version, sessions too — SESSION_ENGINE is now database-backed rather than signed cookies, since a Keycloak session carries a refresh token that shouldn't be serialized into the browser. This applies to every session app-wide, including the shared admin/password login's — it's no longer fully DB-free, but it's the one Django setting shared by both login paths, so it can't be split per-path). Expired sessions aren't purged automatically — run manage.py clearsessions periodically (e.g. a daily cron/systemd timer) or django_session grows unbounded in a long-lived deployment.

The login page then shows a "Log in with Keycloak" button. A first-time Keycloak login mints a local User (linked to an existing one by email, falling back to username, if either matches), active immediately — whether they're actually let in is decided by REQUIRED_GROUPS above, not by anything in this app's own database.

Setting IDP_HINT (plus IDP_LABEL for the button text) switches the login page to two buttons: "Log in with <IDP_LABEL>" goes straight to that identity provider (skipping Keycloak's own login/IdP-selection page), and "Log in with local Keycloak account" keeps the local-account path reachable for anyone without that IdP's identity. Leave IDP_HINT unset for the single-button behavior above.

Granting or revoking a person's access

This is now done in the Keycloak admin console, not this app's Django admin: add or remove the person from the /pyobs-web-admin group (or whatever REQUIRED_GROUPS is set to). The change takes effect at their next login. Django's /admin/ site is still mounted (the shared admin/password account and any other Django superuser can still get in there for other reasons), with the ENFORCE_LOCAL_ACTIVE setting above (recommended, and the example config's default), toggling a Keycloak-linked account's Active flag there still works too, as an additional, Keycloak-independent kill switch on top of the group gate. Leave ENFORCE_LOCAL_ACTIVE unset if you'd rather Keycloak group membership be the only thing that matters.

The shared admin/password account works at /admin/ directly (no prior visit to /login/ needed first) because manage.py migrate syncs a matching superuser User automatically — see pyobs_web_admin.authentication.admin_sync, and Configuration for the ADMIN_USERNAME/ADMIN_PASSWORD_HASH settings that drive it.


Hub mode

pyobs-web-admin can act as a hub to control multiple remote pyobs hosts from a single browser session. When remote hosts are configured, a Hosts section appears at the top of the sidebar. Clicking a host switches the active context — all subsequent actions (start/stop/logs/config) are transparently proxied to that host's API.

Setting up the hub

On the hub (the machine you browse to), add to local_settings.py:

HUB_HOSTS= [
{"name": "obs1", "url": "http://obs1:8765", "token": "shared-secret"},
{"name": "obs2", "url": "http://obs2:8765", "token": "another-secret"},
]

On each remote host, give it a named client entry matching the token the hub sends:

HUB_CLIENTS= [
{"name": "hub", "token": "shared-secret"}, # must match HUB_HOSTS' token above
]

The hub — or any other external caller, such as a script calling the API directly — authenticates to remote instances via an X-Hub-Token header. Remote instances check that header against every entry in HUB_CLIENTS; a match bypasses the normal browser session/CSRF check, so the caller can invoke the API without a login session. Give each caller its own named entry so it can be revoked or rotated independently. Tokens are plain pre-shared strings — use long random values and keep them secret.

The older HUB_TOKEN setting (a single unnamed token) still works for backwards compatibility, equivalent to a HUB_CLIENTS entry named "default".

There's no separate "external" API — every endpoint the hub calls (start/stop, logs, config, ACL, packages, ejabberd user management, ...) is the same plain JSON API any X-Hub-Token-authenticated caller can use directly, e.g. from a script. See docs/source/api_endpoints.rst for the full endpoint reference.


ejabberd integration

If the ejabberd server pyobs's XMPP comm layer connects through runs on the same host, pyobs-web-admin can show live connection state alongside the process status it already tracks — closing the gap between "the module's process is running" and "the module is actually reachable over XMPP." When enabled: the dashboard gets a summary tile (how many of this installation's own modules, identified by their config's comm.user, are currently XMPP-connected) plus a small icon per module row; each module's own page gets a session/last-seen/registered-account block in its Overview tab. A module with no comm: block in its config (e.g. a pure HTTP module) is skipped entirely — there's nothing for it to connect to.

Enabling it

In local_settings.py:

EJABBERD_ENABLED=TrueEJABBERD_HOST="localhost"# or a HUB_HOSTS name, if ejabberd runs on a different hostEJABBERD_DOMAIN="your-xmpp-domain"# the vhost ejabberd serves, e.g. "pyobs.example.org"EJABBERD_API_URL="http://127.0.0.1:5281/api"

If EJABBERD_HOST names a HUB_HOSTS entry instead of "localhost", every instance in the fleet transparently proxies its ejabberd queries to that one host — only that host needs EJABBERD_API_URL actually pointed at a real ejabberd; every other instance just needs EJABBERD_HOST set to its name.

ejabberd-side configuration

This talks to ejabberd's HTTP admin API (mod_http_api), not ejabberdctl — about 50–60x faster per call, since it hits the already-running node directly instead of spawning a new Erlang VM per invocation (ejabberdctl is used as a fallback only if EJABBERD_API_URL can't be reached). Add this to ejabberd's own config:

listen:
-
port: 5281ip: "127.0.0.1"# loopback only -- see security note belowmodule: ejabberd_httprequest_handlers:
/api: mod_http_api # add this to an *existing* listener's request_handlers if one's# already on this port (e.g. for BOSH/WebSocket) -- ejabberd only# allows one listener per portmodules:
mod_http_api: {}api_permissions:
"console commands":
from: [ejabberd_ctl]who: allwhat: "*""pyobs-web-admin readonly":
from: [mod_http_api]who:
access:
allow:
- acl: loopbackwhat:
- "status"
- "stats"
- "connected_users_info"
- "registered_users"
- "user_sessions_info"
- "get_last"
- "check_account"

Reload ejabberd's config after adding this (ejabberdctl reload_config, or a restart if that doesn't pick up the new listener). The what: list is a deliberate whitelist — leave it as-is; mod_http_api can also expose account-management commands (register/unregister/change_password) that should never be reachable here.

Security note. Access is IP-based, not credential-based — any request from loopback is trusted, no password or token is involved. This blocks the network (a request from outside the host is rejected), but not other processes on the same machine, which get the same access pyobs-web-admin does. That's an accepted tradeoff for a dedicated, single-purpose observatory control host, not a shared one — reassess if that's not your deployment.


ejabberd user management

Builds on ejabberd integration above (requires EJABBERD_ENABLED = True) to add write actions on top of the read-only status it already shows: register, reset password, ban / unban, unregister, and kick (force-disconnect one session without touching the account) for any module's comm.user. Reversible actions get a single confirmation dialog; unregister — the one action with no undo — requires retyping the account's username first. An identity shared by more than one module's comm.user (a real, supported scenario — e.g. a test copy of a module reusing a real module's identity) is handled safely: a password reset writes the new password back into every module sharing it, not just the one the action was triggered from, and destructive actions (ban/unregister) name every other module affected before you can confirm.

This surfaces in two places:

  • The module detail page's existing ejabberd block (Overview tab) — register when the account isn't registered yet, reset/ban/unregister when it is.
  • A dedicated Users page (/xmpp-users/), linked from the sidebar whenever EJABBERD_ENABLED = True — every registered XMPP account across every configured host, in one fleet-wide, mobile-friendly list. Unlike the module page, this also covers accounts with no owning module at all (e.g. admin) via a manual "register account" form, and accounts shared by more than one module show a status dot marking which one is actually the connected session.

Transport: ejabberdctl, not mod_http_api

Unlike the read path above, writes always go through the ejabberdctl CLI, never mod_http_api — a write's cost is dominated by a human clicking a confirmation dialog, not command latency, so the ~50–60x speed advantage HTTP has for reads doesn't matter here. This also means no api_permissions change is needed for user management specifically — but see the security note below, since ejabberdctl itself is far more powerful than the read-only HTTP whitelist above.

ejabberdctl normally refuses to run as anything other than root or the ejabberd system user ("can only be run by root or the user ejabberd"), which only matters here since writes always need it (the read path mostly avoids it via mod_http_api). If pyobs-web-admin runs as its own service user (e.g. pyobs, per deploy/pyobs-web-admin.service), give that user a narrowly-scoped passwordless sudo rule for just this one binary:

# /etc/sudoers.d/pyobs-web-admin-ejabberdctl
pyobs ALL=(root) NOPASSWD: /usr/sbin/ejabberdctl

(adjust the username and binary path for your setup — check with which ejabberdctl), then point EJABBERDCTL in local_settings.py at the wrapper script committed at the repo root:

EJABBERDCTL="/opt/pyobs/pyobs-web-admin/ejabberdctl-sudo.sh"

ejabberdctl-sudo.sh is a two-line wrapper (exec sudo -n ejabberdctl "$@") — the -n flag makes sudo fail fast instead of hanging on a password prompt if the sudoers rule above isn't in place. Not needed at all if pyobs-web-admin already runs as root or ejabberd.

Security note. This is a materially bigger trust step than the read-only integration above: ejabberdctl can do anything an ejabberd administrator can do, not just the small read-only whitelist mod_http_api's api_permissions enforces. There is no OS-level or ejabberd-level restriction narrowing what the sudo rule allows beyond "run ejabberdctl as root at all" — this app's own tiered confirmation dialogs are the only safety net between a logged-in admin and any ejabberdctl subcommand this app happens to call. Acceptable for the same reason as the read path's IP-based trust: a dedicated, single-purpose observatory control host with one admin identity, not a shared or multi-tenant one.


Package management

The Packages page (/packages/) lists every installed pyobs-* package (plus anything extra listed in PYOBS_MANAGED_PACKAGES) alongside its latest release on PyPI, and lets you update any of them with one click. It always reflects pip's own view of the environment PYOBS_EXEC runs in (via the sibling pip next to it) — nothing here is invented or cached. The fleet-wide Overview page (/overview/) additionally shows a package-version matrix across every configured host, so a package that's drifted out of sync on one host is easy to spot.

PYOBS_MANAGED_PACKAGES

pip never remembers how a package was originally installed — only what's currently there — so a bare pip install --upgrade pyobs-core would silently drop a [full] extra forever, and a package that isn't on PyPI at all has nothing for the Packages page to even find. PYOBS_MANAGED_PACKAGES fills in what pip's own installed-environment metadata can't recover:

PYOBS_MANAGED_PACKAGES= [
"pyobs-core[full]", # keep using this extra on every future upgrade"my-custom-driver", # a non-"pyobs"-prefixed package, shown/managed alongside pyobs-*"pyobs-iagvt[gui] @ git+ssh://git@gitlab.example.org/iagvt/pyobs-iagvt.git", # git-installed
]
  • Extras — list the full name[extra] spec and future updates keep using it, instead of reverting to a bare install.
  • Non-pyobs-prefixed packages — a bare name makes it show up on the Packages page and be upgradable through it too.
  • Git/URL-installed packages — a PEP 508 direct reference (name[extras] @ <url>) for a package that isn't published on PyPI. The Packages page skips the (futile) PyPI version check for these and shows a Reinstall action instead, which just re-runs pip install --upgrade <spec> to pick up whatever's newest at that URL/ref.

Malformed entries are skipped rather than raising, so a typo here can't break the whole page.

Installing a private git-hosted package

For a private repository (e.g. an institute-internal driver like pyobs-iagvt above), pip running non-interactively needs its own credentials — an SSH deploy key is the recommended way:

  1. Generate a passwordless key for the OS user that actually runs pyobs-web-admin's own process (gunicorn/the systemd service user — not necessarily the pyobs service account, since the Packages page's pip install inherits this process's environment, not pyobs's):
    ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N ""
  2. Add the public key as a GitLab Deploy Key on the private project (Settings → Repository → Deploy keys), read-only access is enough. Scoped to just that repo and revocable independently of any person's account — prefer this over a personal SSH key.
  3. Pre-seed known_hosts for that user, so the first non-interactive install doesn't hang waiting on a host-key prompt:
    ssh-keyscan gitlab.example.org >>~/.ssh/known_hosts
  4. Use git+ssh://, not git+https://, in PYOBS_MANAGED_PACKAGES:
    PYOBS_MANAGED_PACKAGES= [
    "pyobs-iagvt[gui] @ git+ssh://git@gitlab.example.org/iagvt/pyobs-iagvt.git",
    ]
  5. In hub mode, repeat this on every host that's expected to manage the package — package updates run on whichever host is currently active, not just the hub, so each of them needs its own copy of the key (or the same key deployed to all of them).

An SSH key is preferred over embedding a GitLab Deploy Token in an HTTPS URL: the token would end up as a literal pip install command-line argument, visible to any other local user via ps aux for the few seconds the install runs. An SSH key file avoids that exposure.


How modules are managed

  • Discovery — all *.yaml files in PYOBS_CONFIG_DIR (excluding *.shared.yaml) are treated as modules. *.shared.yaml files are listed separately as shared configs.
  • Creating a module — the "New module" button writes a fresh <name>.yaml with a minimal starter (class: key only); PYOBS_CONFIG_DIR is created automatically if it doesn't exist yet.
  • Activate / Deactivate — deactivating a module renames its config from name.yaml to _name.yaml (stopping it first if running); activating renames it back. Deactivated modules are excluded from Start All and Restart All.
  • Start — runs pyobs --pid-file <run>/<name>.pid --log-file <log>/<name>.log --log-level <level> <config>. pyobs daemonises itself via python-daemon. If the effective log backend is "journald" (see below), --syslog is passed instead of --log-file — pyobs then logs directly to the systemd journal, tagged SYSLOG_IDENTIFIER=pyobs and PYOBS_MODULE=<name>.
  • Stop — sends SIGTERM to the PID in the PID file; falls back to SIGKILL after 5 s.
  • Restart — stop followed by start.
  • Status — checks whether the process with the stored PID is alive (os.kill(pid, 0)).
  • Resource usage — uptime, CPU %, and RSS memory read via psutil on every status poll.
  • Logs — read from PYOBS_LOG_DIR's flat files by default, or from the systemd journal via journalctl if the effective log backend is "journald"; the log viewer and per-level counts work identically either way. The effective backend is PYOBS_LOG_BACKEND if set explicitly, otherwise auto-detected from pyobsd's own config file (~/.config/pyobs.yaml, /etc/pyobs.yaml, or /opt/pyobs/storage/pyobs.yaml, first found wins) — the same file pyobsd (pyobs-core's daemon manager) reads to decide whether it starts modules with --syslog, so this can't silently drift out of sync with it. Scrolling a log window to the top auto-loads older entries via journalctl's --until, for journald-backed modules only — the file backend's plain tail -n has no seek/offset to page further back with, so it reports nothing older available instead.
  • Log counts — per-level message counts (DEBUG / INFO / WARNING / ERROR / CRITICAL) for the last 24 h, using binary search on the log file to avoid reading the whole file.

Project layout

pyobs_web_admin/
settings.py Django project settings
local_settings.py.example Template for local overrides (not committed)
urls.py URL config
modules/
services.py All pyobs process and filesystem logic
views.py HTML pages + JSON API endpoints
proxy.py HTTP client for hub → remote host calls
ejabberd.py mod_http_api (status) + ejabberdctl (user management) client
middleware.py Login-required redirect + hub token auth
context_processors.py
deploy/
pyobs-web-admin.service systemd unit file
ejabberdctl-sudo.sh sudo wrapper for EJABBERDCTL -- see ejabberd user management
templates/
base.html Bootstrap 5 layout with responsive sidebar
modules/
dashboard.html
detail.html
shared_detail.html Config editor for *.shared.yaml files
new_module.html "Create a new module" form
packages.html Package list + Update/Reinstall actions
fleet_overview.html Fleet-wide host summary + package-version matrix
all_logs.html Fleet-wide live log tail
acl_matrix.html Fleet-wide ACL matrix
xmpp_users.html Fleet-wide XMPP account list + write actions
registration/
login.html

About

Web GUI for managing pyobs modules

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

pyobs-web-admin

A web-based administration interface for pyobs, the robotic telescope framework. It lets you start, stop, and restart modules, tail and filter their logs, and view and edit their configuration files — all from a browser.

Dashboard showing modules grouped under Stopped and Deactivated headings, with summary tiles and per-row quick-action buttons

Features

  • Dashboard — sortable list view of all modules with:
    • Running / stopped / total summary counts plus total CPU and RAM
    • Per-module status badge, RAM, CPU, and uptime columns — click any header to sort, reset icon restores default grouping
    • Modules grouped under Running / Stopped / Deactivated headers when sorted by status
    • Warning/error log counts for the last 24 h (highlighted in colour if non-zero)
    • Quick start, restart, stop, and activate/deactivate buttons per module
    • Start All, Restart All, and Stop All bulk actions
    • Inactive modules (prefixed with _) are excluded from bulk start/restart
    • Outdated badge on any running module whose loaded pyobs-* versions lag the installed ones (parsed from pyobs-core's startup "Loaded pyobs packages:" log line — comm-independent, works for comm-less modules too), plus a Restart outdated bulk action that restarts only those
    • Responsive: on small screens the table collapses to status dot + name + log counts + actions
  • Module detail — per-module view with four tabs:
    • Overview — current status, PID, uptime, CPU and memory usage, running pyobs-* package versions (flagged when they lag the installed set), per-level log message counts (last 24 h), XMPP connection state (if enabled), start/restart/stop/activate/deactivate control
    • Logs — live log tail with text filter, time-range filter (set a start date to load all logs since that instant, or click a line to set it), colour-coded by severity, auto-refresh; scrolling to the top auto-loads older entries (journald-backed modules, or file-backed modules once a start date is set)
    • Config — YAML editor with syntax highlighting and colour-coded {include} lines; included shared configs are shown as clickable links
    • ACL — point-and-click editor for the module's acl: block: click to allow/deny known modules, add other callers, toggle enforce/log mode
  • New module — a "+" next to the sidebar's Modules section creates a brand-new <name>.yaml config (a minimal starter with just a class: key) and takes you straight to its Config tab to fill in the rest
  • Shared configs*.shared.yaml config fragments listed in a separate sidebar section with a YAML-highlighted config editor (no start/stop controls)
  • Packages (/packages/) — every installed pyobs-* package (plus anything else listed in PYOBS_MANAGED_PACKAGES) with its installed and latest-PyPI version, and a one-click Update button; git/URL-installed packages get a Reinstall action instead (see Package management)
  • Overview (/overview/) — fleet-wide summary, one row per configured host: reachable or not, running/stopped/total counts, aggregate CPU/RAM, linking into that host's own Dashboard, plus a package-version matrix (one row per pyobs-* package, one column per host) so version drift across the fleet is visible at a glance. Deliberately no bulk or per-module actions — those stay on the per-host Dashboard, since a fleet-wide "Stop All" from one button is a real footgun
  • All Logs (/logs/) — fleet-wide live log tail across every module on every configured host, same filtering and scroll-to-load-older behaviour as a module's own Logs tab
  • ACL Matrix (/acl/) — fleet-wide read-only matrix of which module can call which, merged across every configured host
  • Hub mode — control multiple remote pyobs hosts from a single browser tab; remote hosts are listed in the sidebar and all actions are proxied transparently
  • ejabberd / XMPP status (optional) — dashboard summary tile and per-module connected/not-connected indicator, plus a session/last-seen/registered-account block on each module's own page, for modules with a comm.user in their config — closes the gap between "the process is running" and "the module is actually reachable over XMPP" (see ejabberd integration)
  • ejabberd / XMPP user management (optional, builds on the above) — register, reset password, ban/unban, unregister, and kick XMPP accounts, either from a module's own Overview tab or from a fleet-wide Users page (/xmpp-users/) listing every registered account across every host, cross-referenced against which module(s) use it and which one is actually running. Safe by design for an identity shared across more than one module's comm.user — a password reset writes back to every module sharing it, and destructive actions name which other modules are affected before you confirm (see ejabberd user management)
  • Responsive — works on mobile with a slide-in sidebar
  • No pyobs-core dependency — communicates with pyobs directly via subprocess; no Python imports from pyobs-core
  • Keycloak login (optional) — SSO on top of the default shared admin/password login, with per-person access granted/revoked via Keycloak group membership (see Keycloak login)

Technology

LayerChoice
BackendPython 3.13, Django 6, psutil
WSGI serverGunicorn
FrontendBootstrap 5 (CDN), CodeMirror 5 (CDN), vanilla JS
Package manageruv
AuthShared admin/password login plus optional Keycloak SSO; both share one SQLite db (sessions table for both, User table for Keycloak-linked accounts only)
Hub authPre-shared token in X-Hub-Token header; CSRF bypassed for hub requests

Development setup

git clone https://github.com/pyobs/pyobs-web-admin.git
cd pyobs-web-admin
uv sync
uv run python manage.py migrate
uv run python manage.py runserver

Create pyobs_web_admin/local_settings.py (see Configuration below).


Production setup

1. Install the app

cd /opt/pyobs
git clone https://github.com/pyobs/pyobs-web-admin.git
cd pyobs-web-admin
uv sync

2. Configure

Copy and edit the local settings file:

cp pyobs_web_admin/local_settings.py.example pyobs_web_admin/local_settings.py # or create from scratch$EDITOR pyobs_web_admin/local_settings.py

Minimum required settings for production (see Configuration):

DEBUG=FalseSECRET_KEY="..."# generate belowALLOWED_HOSTS= ["your-hostname-or-ip"]
ADMIN_USERNAME="admin"ADMIN_PASSWORD_HASH="..."# generate belowPYOBS_EXEC="/opt/pyobs/venv/bin/pyobs"

Generate a secret key:

DJANGO_SETTINGS_MODULE=pyobs_web_admin.settings uv run python -c \
"from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"

Generate a password hash:

DJANGO_SETTINGS_MODULE=pyobs_web_admin.settings uv run python -c \
"from django.contrib.auth.hashers import make_password; print(make_password('yourpassword'))"

3. Install the systemd service

cp deploy/pyobs-web-admin.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now pyobs-web-admin

Check that it started:

systemctl status pyobs-web-admin
journalctl -u pyobs-web-admin -f

4. Configure nginx

Add a site configuration that proxies to gunicorn on port 8765:

server{listen80;server_name your-hostname-or-ip;location / {proxy_passhttp://127.0.0.1:8765;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;proxy_set_header X-Forwarded-Proto $scheme;}}

If you add TLS (strongly recommended for any non-private network), also set in local_settings.py:

SESSION_COOKIE_SECURE=TrueCSRF_COOKIE_SECURE=True

Configuration

All runtime configuration lives in pyobs_web_admin/local_settings.py, which is not committed to version control. A full reference:

# DjangoSECRET_KEY="..."# required in productionDEBUG=False# set True only in developmentALLOWED_HOSTS= ["*"] # restrict to hostname/IP in production# HTTPS (enable once TLS is in place)# SESSION_COOKIE_SECURE = True# CSRF_COOKIE_SECURE = True# AuthenticationADMIN_USERNAME="admin"ADMIN_PASSWORD_HASH="pbkdf2_sha256$..."# see generation command above# Keycloak login (optional — see Keycloak login section)# PYOBS_AUTH = {# "SERVER_URL": "https://keycloak.example.org",# "REALM": "pyobs",# "CLIENT_ID": "web-admin",# "CLIENT_SECRET": "",# "REDIRECT_URI": "https://your.domain.com/accounts/keycloak/callback/",# "POST_LOGOUT_REDIRECT_URI": "https://your.domain.com/",# # Optional one-click IdP login: IDP_HINT is passed to Keycloak as kc_idp_hint (skips its# # login/IdP-selection page, going straight to that identity provider, e.g. GWDG SSO);# # IDP_LABEL is the button label on the login page. Leave both unset for the plain# # single "Log in with Keycloak" button.# "IDP_HINT": "gwdg",# "IDP_LABEL": "GWDG",# "USER_RESOLVER": "pyobs_web_admin.authentication.keycloak.resolve_user",# # Only members of this Keycloak group are authorized to use web-admin - create it (and add# # people to it) in the Keycloak admin console before anyone logs in.# "REQUIRED_GROUPS": ["/pyobs-web-admin"],# # Keycloak-independent kill switch, layered on top of REQUIRED_GROUPS above: an admin can# # deactivate a specific local User (Django admin) regardless of their Keycloak group# # membership.# "ENFORCE_LOCAL_ACTIVE": True,# }# pyobs pathsPYOBS_EXEC="/opt/pyobs/venv/bin/pyobs"# path to the pyobs executablePYOBS_CONFIG_DIR="/opt/pyobs/config"# directory containing *.yaml module configsPYOBS_LOG_DIR="/opt/pyobs/log"# directory containing *.log filesPYOBS_RUN_DIR="/opt/pyobs/run"# directory for PID filesPYOBS_LOG_LEVEL="info"# log level passed to pyobs on startPYOBS_LOG_BACKEND=None# None (default): auto-detect from pyobsd's own# config; "file" or "journald" to override. If# "journald": the account running pyobs-web-admin# needs journal read access — `usermod -aG# systemd-journal <account>` (preferred over `adm`,# which grants broader log access than needed) —# otherwise logs come back silently empty, no error.# Packages page (optional — see Package management section)PYOBS_MANAGED_PACKAGES= [] # e.g. ["pyobs-core[full]", "my-custom-driver",# "pyobs-iagvt[gui] @ git+ssh://git@gitlab.example.org/iagvt/pyobs-iagvt.git"]# Hub (optional — see Hub mode section)HUB_TOKEN=""# deprecated, single-token form -- see HUB_CLIENTSHUB_CLIENTS= [] # named tokens for external callers (hub, scripts, ...)HUB_HOSTS= [] # remote hosts this instance controls# ejabberd integration (optional — see ejabberd integration section)EJABBERD_ENABLED=False# show XMPP status on the dashboard/module pagesEJABBERD_HOST="localhost"# which host runs ejabberd -- "localhost" or a HUB_HOSTS nameEJABBERD_DOMAIN=""# the XMPP vhost ejabberd servesEJABBERD_API_URL="http://127.0.0.1:5281/api"# mod_http_api base URLEJABBERDCTL="ejabberdctl"# required for user management writes (register/# reset/ban/unregister/kick); also a read fallback# if EJABBERD_API_URL can't be reached -- see# ejabberd user management section below

Keycloak login

The shared admin/password login above is the default and always available — Keycloak is an additive option on top of it, not a replacement, so it stays working as a break-glass fallback if Keycloak itself is unreachable. Unlike the shared account, a Keycloak-linked person's access is granted or revoked individually via Keycloak group membership, without touching anyone else's access or handing out the shared password.

Enabling it

  1. Register a client for this instance in your Keycloak realm (redirect URI https://your.domain.com/accounts/keycloak/callback/, post-logout redirect URI https://your.domain.com/).
  2. Add the PYOBS_AUTH block shown in Configuration to local_settings.py, filling in SERVER_URL, CLIENT_ID, and CLIENT_SECRET. REQUIRED_GROUPS defaults to ["/pyobs-web-admin"] — create that group in the realm and add whoever should have access to it (Keycloak admin console) before anyone tries to log in, or every login is refused as "not authorized".
  3. Run manage.py migrate (creates db.sqlite3, used for the Keycloak-linked User table and, as of this version, sessions too — SESSION_ENGINE is now database-backed rather than signed cookies, since a Keycloak session carries a refresh token that shouldn't be serialized into the browser. This applies to every session app-wide, including the shared admin/password login's — it's no longer fully DB-free, but it's the one Django setting shared by both login paths, so it can't be split per-path). Expired sessions aren't purged automatically — run manage.py clearsessions periodically (e.g. a daily cron/systemd timer) or django_session grows unbounded in a long-lived deployment.

The login page then shows a "Log in with Keycloak" button. A first-time Keycloak login mints a local User (linked to an existing one by email, falling back to username, if either matches), active immediately — whether they're actually let in is decided by REQUIRED_GROUPS above, not by anything in this app's own database.

Setting IDP_HINT (plus IDP_LABEL for the button text) switches the login page to two buttons: "Log in with <IDP_LABEL>" goes straight to that identity provider (skipping Keycloak's own login/IdP-selection page), and "Log in with local Keycloak account" keeps the local-account path reachable for anyone without that IdP's identity. Leave IDP_HINT unset for the single-button behavior above.

Granting or revoking a person's access

This is now done in the Keycloak admin console, not this app's Django admin: add or remove the person from the /pyobs-web-admin group (or whatever REQUIRED_GROUPS is set to). The change takes effect at their next login. Django's /admin/ site is still mounted (the shared admin/password account and any other Django superuser can still get in there for other reasons), with the ENFORCE_LOCAL_ACTIVE setting above (recommended, and the example config's default), toggling a Keycloak-linked account's Active flag there still works too, as an additional, Keycloak-independent kill switch on top of the group gate. Leave ENFORCE_LOCAL_ACTIVE unset if you'd rather Keycloak group membership be the only thing that matters.

The shared admin/password account works at /admin/ directly (no prior visit to /login/ needed first) because manage.py migrate syncs a matching superuser User automatically — see pyobs_web_admin.authentication.admin_sync, and Configuration for the ADMIN_USERNAME/ADMIN_PASSWORD_HASH settings that drive it.


Hub mode

pyobs-web-admin can act as a hub to control multiple remote pyobs hosts from a single browser session. When remote hosts are configured, a Hosts section appears at the top of the sidebar. Clicking a host switches the active context — all subsequent actions (start/stop/logs/config) are transparently proxied to that host's API.

Setting up the hub

On the hub (the machine you browse to), add to local_settings.py:

HUB_HOSTS= [
{"name": "obs1", "url": "http://obs1:8765", "token": "shared-secret"},
{"name": "obs2", "url": "http://obs2:8765", "token": "another-secret"},
]

On each remote host, give it a named client entry matching the token the hub sends:

HUB_CLIENTS= [
{"name": "hub", "token": "shared-secret"}, # must match HUB_HOSTS' token above
]

The hub — or any other external caller, such as a script calling the API directly — authenticates to remote instances via an X-Hub-Token header. Remote instances check that header against every entry in HUB_CLIENTS; a match bypasses the normal browser session/CSRF check, so the caller can invoke the API without a login session. Give each caller its own named entry so it can be revoked or rotated independently. Tokens are plain pre-shared strings — use long random values and keep them secret.

The older HUB_TOKEN setting (a single unnamed token) still works for backwards compatibility, equivalent to a HUB_CLIENTS entry named "default".

There's no separate "external" API — every endpoint the hub calls (start/stop, logs, config, ACL, packages, ejabberd user management, ...) is the same plain JSON API any X-Hub-Token-authenticated caller can use directly, e.g. from a script. See docs/source/api_endpoints.rst for the full endpoint reference.


ejabberd integration

If the ejabberd server pyobs's XMPP comm layer connects through runs on the same host, pyobs-web-admin can show live connection state alongside the process status it already tracks — closing the gap between "the module's process is running" and "the module is actually reachable over XMPP." When enabled: the dashboard gets a summary tile (how many of this installation's own modules, identified by their config's comm.user, are currently XMPP-connected) plus a small icon per module row; each module's own page gets a session/last-seen/registered-account block in its Overview tab. A module with no comm: block in its config (e.g. a pure HTTP module) is skipped entirely — there's nothing for it to connect to.

Enabling it

In local_settings.py:

EJABBERD_ENABLED=TrueEJABBERD_HOST="localhost"# or a HUB_HOSTS name, if ejabberd runs on a different hostEJABBERD_DOMAIN="your-xmpp-domain"# the vhost ejabberd serves, e.g. "pyobs.example.org"EJABBERD_API_URL="http://127.0.0.1:5281/api"

If EJABBERD_HOST names a HUB_HOSTS entry instead of "localhost", every instance in the fleet transparently proxies its ejabberd queries to that one host — only that host needs EJABBERD_API_URL actually pointed at a real ejabberd; every other instance just needs EJABBERD_HOST set to its name.

ejabberd-side configuration

This talks to ejabberd's HTTP admin API (mod_http_api), not ejabberdctl — about 50–60x faster per call, since it hits the already-running node directly instead of spawning a new Erlang VM per invocation (ejabberdctl is used as a fallback only if EJABBERD_API_URL can't be reached). Add this to ejabberd's own config:

listen:
-
port: 5281ip: "127.0.0.1"# loopback only -- see security note belowmodule: ejabberd_httprequest_handlers:
/api: mod_http_api # add this to an *existing* listener's request_handlers if one's# already on this port (e.g. for BOSH/WebSocket) -- ejabberd only# allows one listener per portmodules:
mod_http_api: {}api_permissions:
"console commands":
from: [ejabberd_ctl]who: allwhat: "*""pyobs-web-admin readonly":
from: [mod_http_api]who:
access:
allow:
- acl: loopbackwhat:
- "status"
- "stats"
- "connected_users_info"
- "registered_users"
- "user_sessions_info"
- "get_last"
- "check_account"

Reload ejabberd's config after adding this (ejabberdctl reload_config, or a restart if that doesn't pick up the new listener). The what: list is a deliberate whitelist — leave it as-is; mod_http_api can also expose account-management commands (register/unregister/change_password) that should never be reachable here.

Security note. Access is IP-based, not credential-based — any request from loopback is trusted, no password or token is involved. This blocks the network (a request from outside the host is rejected), but not other processes on the same machine, which get the same access pyobs-web-admin does. That's an accepted tradeoff for a dedicated, single-purpose observatory control host, not a shared one — reassess if that's not your deployment.


ejabberd user management

Builds on ejabberd integration above (requires EJABBERD_ENABLED = True) to add write actions on top of the read-only status it already shows: register, reset password, ban / unban, unregister, and kick (force-disconnect one session without touching the account) for any module's comm.user. Reversible actions get a single confirmation dialog; unregister — the one action with no undo — requires retyping the account's username first. An identity shared by more than one module's comm.user (a real, supported scenario — e.g. a test copy of a module reusing a real module's identity) is handled safely: a password reset writes the new password back into every module sharing it, not just the one the action was triggered from, and destructive actions (ban/unregister) name every other module affected before you can confirm.

This surfaces in two places:

  • The module detail page's existing ejabberd block (Overview tab) — register when the account isn't registered yet, reset/ban/unregister when it is.
  • A dedicated Users page (/xmpp-users/), linked from the sidebar whenever EJABBERD_ENABLED = True — every registered XMPP account across every configured host, in one fleet-wide, mobile-friendly list. Unlike the module page, this also covers accounts with no owning module at all (e.g. admin) via a manual "register account" form, and accounts shared by more than one module show a status dot marking which one is actually the connected session.

Transport: ejabberdctl, not mod_http_api

Unlike the read path above, writes always go through the ejabberdctl CLI, never mod_http_api — a write's cost is dominated by a human clicking a confirmation dialog, not command latency, so the ~50–60x speed advantage HTTP has for reads doesn't matter here. This also means no api_permissions change is needed for user management specifically — but see the security note below, since ejabberdctl itself is far more powerful than the read-only HTTP whitelist above.

ejabberdctl normally refuses to run as anything other than root or the ejabberd system user ("can only be run by root or the user ejabberd"), which only matters here since writes always need it (the read path mostly avoids it via mod_http_api). If pyobs-web-admin runs as its own service user (e.g. pyobs, per deploy/pyobs-web-admin.service), give that user a narrowly-scoped passwordless sudo rule for just this one binary:

# /etc/sudoers.d/pyobs-web-admin-ejabberdctl
pyobs ALL=(root) NOPASSWD: /usr/sbin/ejabberdctl

(adjust the username and binary path for your setup — check with which ejabberdctl), then point EJABBERDCTL in local_settings.py at the wrapper script committed at the repo root:

EJABBERDCTL="/opt/pyobs/pyobs-web-admin/ejabberdctl-sudo.sh"

ejabberdctl-sudo.sh is a two-line wrapper (exec sudo -n ejabberdctl "$@") — the -n flag makes sudo fail fast instead of hanging on a password prompt if the sudoers rule above isn't in place. Not needed at all if pyobs-web-admin already runs as root or ejabberd.

Security note. This is a materially bigger trust step than the read-only integration above: ejabberdctl can do anything an ejabberd administrator can do, not just the small read-only whitelist mod_http_api's api_permissions enforces. There is no OS-level or ejabberd-level restriction narrowing what the sudo rule allows beyond "run ejabberdctl as root at all" — this app's own tiered confirmation dialogs are the only safety net between a logged-in admin and any ejabberdctl subcommand this app happens to call. Acceptable for the same reason as the read path's IP-based trust: a dedicated, single-purpose observatory control host with one admin identity, not a shared or multi-tenant one.


Package management

The Packages page (/packages/) lists every installed pyobs-* package (plus anything extra listed in PYOBS_MANAGED_PACKAGES) alongside its latest release on PyPI, and lets you update any of them with one click. It always reflects pip's own view of the environment PYOBS_EXEC runs in (via the sibling pip next to it) — nothing here is invented or cached. The fleet-wide Overview page (/overview/) additionally shows a package-version matrix across every configured host, so a package that's drifted out of sync on one host is easy to spot.

PYOBS_MANAGED_PACKAGES

pip never remembers how a package was originally installed — only what's currently there — so a bare pip install --upgrade pyobs-core would silently drop a [full] extra forever, and a package that isn't on PyPI at all has nothing for the Packages page to even find. PYOBS_MANAGED_PACKAGES fills in what pip's own installed-environment metadata can't recover:

PYOBS_MANAGED_PACKAGES= [
"pyobs-core[full]", # keep using this extra on every future upgrade"my-custom-driver", # a non-"pyobs"-prefixed package, shown/managed alongside pyobs-*"pyobs-iagvt[gui] @ git+ssh://git@gitlab.example.org/iagvt/pyobs-iagvt.git", # git-installed
]
  • Extras — list the full name[extra] spec and future updates keep using it, instead of reverting to a bare install.
  • Non-pyobs-prefixed packages — a bare name makes it show up on the Packages page and be upgradable through it too.
  • Git/URL-installed packages — a PEP 508 direct reference (name[extras] @ <url>) for a package that isn't published on PyPI. The Packages page skips the (futile) PyPI version check for these and shows a Reinstall action instead, which just re-runs pip install --upgrade <spec> to pick up whatever's newest at that URL/ref.

Malformed entries are skipped rather than raising, so a typo here can't break the whole page.

Installing a private git-hosted package

For a private repository (e.g. an institute-internal driver like pyobs-iagvt above), pip running non-interactively needs its own credentials — an SSH deploy key is the recommended way:

  1. Generate a passwordless key for the OS user that actually runs pyobs-web-admin's own process (gunicorn/the systemd service user — not necessarily the pyobs service account, since the Packages page's pip install inherits this process's environment, not pyobs's):
    ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N ""
  2. Add the public key as a GitLab Deploy Key on the private project (Settings → Repository → Deploy keys), read-only access is enough. Scoped to just that repo and revocable independently of any person's account — prefer this over a personal SSH key.
  3. Pre-seed known_hosts for that user, so the first non-interactive install doesn't hang waiting on a host-key prompt:
    ssh-keyscan gitlab.example.org >>~/.ssh/known_hosts
  4. Use git+ssh://, not git+https://, in PYOBS_MANAGED_PACKAGES:
    PYOBS_MANAGED_PACKAGES= [
    "pyobs-iagvt[gui] @ git+ssh://git@gitlab.example.org/iagvt/pyobs-iagvt.git",
    ]
  5. In hub mode, repeat this on every host that's expected to manage the package — package updates run on whichever host is currently active, not just the hub, so each of them needs its own copy of the key (or the same key deployed to all of them).

An SSH key is preferred over embedding a GitLab Deploy Token in an HTTPS URL: the token would end up as a literal pip install command-line argument, visible to any other local user via ps aux for the few seconds the install runs. An SSH key file avoids that exposure.


How modules are managed

  • Discovery — all *.yaml files in PYOBS_CONFIG_DIR (excluding *.shared.yaml) are treated as modules. *.shared.yaml files are listed separately as shared configs.
  • Creating a module — the "New module" button writes a fresh <name>.yaml with a minimal starter (class: key only); PYOBS_CONFIG_DIR is created automatically if it doesn't exist yet.
  • Activate / Deactivate — deactivating a module renames its config from name.yaml to _name.yaml (stopping it first if running); activating renames it back. Deactivated modules are excluded from Start All and Restart All.
  • Start — runs pyobs --pid-file <run>/<name>.pid --log-file <log>/<name>.log --log-level <level> <config>. pyobs daemonises itself via python-daemon. If the effective log backend is "journald" (see below), --syslog is passed instead of --log-file — pyobs then logs directly to the systemd journal, tagged SYSLOG_IDENTIFIER=pyobs and PYOBS_MODULE=<name>.
  • Stop — sends SIGTERM to the PID in the PID file; falls back to SIGKILL after 5 s.
  • Restart — stop followed by start.
  • Status — checks whether the process with the stored PID is alive (os.kill(pid, 0)).
  • Resource usage — uptime, CPU %, and RSS memory read via psutil on every status poll.
  • Logs — read from PYOBS_LOG_DIR's flat files by default, or from the systemd journal via journalctl if the effective log backend is "journald"; the log viewer and per-level counts work identically either way. The effective backend is PYOBS_LOG_BACKEND if set explicitly, otherwise auto-detected from pyobsd's own config file (~/.config/pyobs.yaml, /etc/pyobs.yaml, or /opt/pyobs/storage/pyobs.yaml, first found wins) — the same file pyobsd (pyobs-core's daemon manager) reads to decide whether it starts modules with --syslog, so this can't silently drift out of sync with it. Scrolling a log window to the top auto-loads older entries via journalctl's --until, for journald-backed modules only — the file backend's plain tail -n has no seek/offset to page further back with, so it reports nothing older available instead.
  • Log counts — per-level message counts (DEBUG / INFO / WARNING / ERROR / CRITICAL) for the last 24 h, using binary search on the log file to avoid reading the whole file.

Project layout

pyobs_web_admin/
settings.py Django project settings
local_settings.py.example Template for local overrides (not committed)
urls.py URL config
modules/
services.py All pyobs process and filesystem logic
views.py HTML pages + JSON API endpoints
proxy.py HTTP client for hub → remote host calls
ejabberd.py mod_http_api (status) + ejabberdctl (user management) client
middleware.py Login-required redirect + hub token auth
context_processors.py
deploy/
pyobs-web-admin.service systemd unit file
ejabberdctl-sudo.sh sudo wrapper for EJABBERDCTL -- see ejabberd user management
templates/
base.html Bootstrap 5 layout with responsive sidebar
modules/
dashboard.html
detail.html
shared_detail.html Config editor for *.shared.yaml files
new_module.html "Create a new module" form
packages.html Package list + Update/Reinstall actions
fleet_overview.html Fleet-wide host summary + package-version matrix
all_logs.html Fleet-wide live log tail
acl_matrix.html Fleet-wide ACL matrix
xmpp_users.html Fleet-wide XMPP account list + write actions
registration/
login.html

About

Web GUI for managing pyobs modules

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

pyobs-web-admin

A web-based administration interface for pyobs, the robotic telescope framework. It lets you start, stop, and restart modules, tail and filter their logs, and view and edit their configuration files — all from a browser.

Dashboard showing modules grouped under Stopped and Deactivated headings, with summary tiles and per-row quick-action buttons

Features

  • Dashboard — sortable list view of all modules with:
    • Running / stopped / total summary counts plus total CPU and RAM
    • Per-module status badge, RAM, CPU, and uptime columns — click any header to sort, reset icon restores default grouping
    • Modules grouped under Running / Stopped / Deactivated headers when sorted by status
    • Warning/error log counts for the last 24 h (highlighted in colour if non-zero)
    • Quick start, restart, stop, and activate/deactivate buttons per module
    • Start All, Restart All, and Stop All bulk actions
    • Inactive modules (prefixed with _) are excluded from bulk start/restart
    • Outdated badge on any running module whose loaded pyobs-* versions lag the installed ones (parsed from pyobs-core's startup "Loaded pyobs packages:" log line — comm-independent, works for comm-less modules too), plus a Restart outdated bulk action that restarts only those
    • Responsive: on small screens the table collapses to status dot + name + log counts + actions
  • Module detail — per-module view with four tabs:
    • Overview — current status, PID, uptime, CPU and memory usage, running pyobs-* package versions (flagged when they lag the installed set), per-level log message counts (last 24 h), XMPP connection state (if enabled), start/restart/stop/activate/deactivate control
    • Logs — live log tail with text filter, time-range filter (set a start date to load all logs since that instant, or click a line to set it), colour-coded by severity, auto-refresh; scrolling to the top auto-loads older entries (journald-backed modules, or file-backed modules once a start date is set)
    • Config — YAML editor with syntax highlighting and colour-coded {include} lines; included shared configs are shown as clickable links
    • ACL — point-and-click editor for the module's acl: block: click to allow/deny known modules, add other callers, toggle enforce/log mode
  • New module — a "+" next to the sidebar's Modules section creates a brand-new <name>.yaml config (a minimal starter with just a class: key) and takes you straight to its Config tab to fill in the rest
  • Shared configs*.shared.yaml config fragments listed in a separate sidebar section with a YAML-highlighted config editor (no start/stop controls)
  • Packages (/packages/) — every installed pyobs-* package (plus anything else listed in PYOBS_MANAGED_PACKAGES) with its installed and latest-PyPI version, and a one-click Update button; git/URL-installed packages get a Reinstall action instead (see Package management)
  • Overview (/overview/) — fleet-wide summary, one row per configured host: reachable or not, running/stopped/total counts, aggregate CPU/RAM, linking into that host's own Dashboard, plus a package-version matrix (one row per pyobs-* package, one column per host) so version drift across the fleet is visible at a glance. Deliberately no bulk or per-module actions — those stay on the per-host Dashboard, since a fleet-wide "Stop All" from one button is a real footgun
  • All Logs (/logs/) — fleet-wide live log tail across every module on every configured host, same filtering and scroll-to-load-older behaviour as a module's own Logs tab
  • ACL Matrix (/acl/) — fleet-wide read-only matrix of which module can call which, merged across every configured host
  • Hub mode — control multiple remote pyobs hosts from a single browser tab; remote hosts are listed in the sidebar and all actions are proxied transparently
  • ejabberd / XMPP status (optional) — dashboard summary tile and per-module connected/not-connected indicator, plus a session/last-seen/registered-account block on each module's own page, for modules with a comm.user in their config — closes the gap between "the process is running" and "the module is actually reachable over XMPP" (see ejabberd integration)
  • ejabberd / XMPP user management (optional, builds on the above) — register, reset password, ban/unban, unregister, and kick XMPP accounts, either from a module's own Overview tab or from a fleet-wide Users page (/xmpp-users/) listing every registered account across every host, cross-referenced against which module(s) use it and which one is actually running. Safe by design for an identity shared across more than one module's comm.user — a password reset writes back to every module sharing it, and destructive actions name which other modules are affected before you confirm (see ejabberd user management)
  • Responsive — works on mobile with a slide-in sidebar
  • No pyobs-core dependency — communicates with pyobs directly via subprocess; no Python imports from pyobs-core
  • Keycloak login (optional) — SSO on top of the default shared admin/password login, with per-person access granted/revoked via Keycloak group membership (see Keycloak login)

Technology

LayerChoice
BackendPython 3.13, Django 6, psutil
WSGI serverGunicorn
FrontendBootstrap 5 (CDN), CodeMirror 5 (CDN), vanilla JS
Package manageruv
AuthShared admin/password login plus optional Keycloak SSO; both share one SQLite db (sessions table for both, User table for Keycloak-linked accounts only)
Hub authPre-shared token in X-Hub-Token header; CSRF bypassed for hub requests

Development setup

git clone https://github.com/pyobs/pyobs-web-admin.git
cd pyobs-web-admin
uv sync
uv run python manage.py migrate
uv run python manage.py runserver

Create pyobs_web_admin/local_settings.py (see Configuration below).


Production setup

1. Install the app

cd /opt/pyobs
git clone https://github.com/pyobs/pyobs-web-admin.git
cd pyobs-web-admin
uv sync

2. Configure

Copy and edit the local settings file:

cp pyobs_web_admin/local_settings.py.example pyobs_web_admin/local_settings.py # or create from scratch$EDITOR pyobs_web_admin/local_settings.py

Minimum required settings for production (see Configuration):

DEBUG=FalseSECRET_KEY="..."# generate belowALLOWED_HOSTS= ["your-hostname-or-ip"]
ADMIN_USERNAME="admin"ADMIN_PASSWORD_HASH="..."# generate belowPYOBS_EXEC="/opt/pyobs/venv/bin/pyobs"

Generate a secret key:

DJANGO_SETTINGS_MODULE=pyobs_web_admin.settings uv run python -c \
"from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"

Generate a password hash:

DJANGO_SETTINGS_MODULE=pyobs_web_admin.settings uv run python -c \
"from django.contrib.auth.hashers import make_password; print(make_password('yourpassword'))"

3. Install the systemd service

cp deploy/pyobs-web-admin.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now pyobs-web-admin

Check that it started:

systemctl status pyobs-web-admin
journalctl -u pyobs-web-admin -f

4. Configure nginx

Add a site configuration that proxies to gunicorn on port 8765:

server{listen80;server_name your-hostname-or-ip;location / {proxy_passhttp://127.0.0.1:8765;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;proxy_set_header X-Forwarded-Proto $scheme;}}

If you add TLS (strongly recommended for any non-private network), also set in local_settings.py:

SESSION_COOKIE_SECURE=TrueCSRF_COOKIE_SECURE=True

Configuration

All runtime configuration lives in pyobs_web_admin/local_settings.py, which is not committed to version control. A full reference:

# DjangoSECRET_KEY="..."# required in productionDEBUG=False# set True only in developmentALLOWED_HOSTS= ["*"] # restrict to hostname/IP in production# HTTPS (enable once TLS is in place)# SESSION_COOKIE_SECURE = True# CSRF_COOKIE_SECURE = True# AuthenticationADMIN_USERNAME="admin"ADMIN_PASSWORD_HASH="pbkdf2_sha256$..."# see generation command above# Keycloak login (optional — see Keycloak login section)# PYOBS_AUTH = {# "SERVER_URL": "https://keycloak.example.org",# "REALM": "pyobs",# "CLIENT_ID": "web-admin",# "CLIENT_SECRET": "",# "REDIRECT_URI": "https://your.domain.com/accounts/keycloak/callback/",# "POST_LOGOUT_REDIRECT_URI": "https://your.domain.com/",# # Optional one-click IdP login: IDP_HINT is passed to Keycloak as kc_idp_hint (skips its# # login/IdP-selection page, going straight to that identity provider, e.g. GWDG SSO);# # IDP_LABEL is the button label on the login page. Leave both unset for the plain# # single "Log in with Keycloak" button.# "IDP_HINT": "gwdg",# "IDP_LABEL": "GWDG",# "USER_RESOLVER": "pyobs_web_admin.authentication.keycloak.resolve_user",# # Only members of this Keycloak group are authorized to use web-admin - create it (and add# # people to it) in the Keycloak admin console before anyone logs in.# "REQUIRED_GROUPS": ["/pyobs-web-admin"],# # Keycloak-independent kill switch, layered on top of REQUIRED_GROUPS above: an admin can# # deactivate a specific local User (Django admin) regardless of their Keycloak group# # membership.# "ENFORCE_LOCAL_ACTIVE": True,# }# pyobs pathsPYOBS_EXEC="/opt/pyobs/venv/bin/pyobs"# path to the pyobs executablePYOBS_CONFIG_DIR="/opt/pyobs/config"# directory containing *.yaml module configsPYOBS_LOG_DIR="/opt/pyobs/log"# directory containing *.log filesPYOBS_RUN_DIR="/opt/pyobs/run"# directory for PID filesPYOBS_LOG_LEVEL="info"# log level passed to pyobs on startPYOBS_LOG_BACKEND=None# None (default): auto-detect from pyobsd's own# config; "file" or "journald" to override. If# "journald": the account running pyobs-web-admin# needs journal read access — `usermod -aG# systemd-journal <account>` (preferred over `adm`,# which grants broader log access than needed) —# otherwise logs come back silently empty, no error.# Packages page (optional — see Package management section)PYOBS_MANAGED_PACKAGES= [] # e.g. ["pyobs-core[full]", "my-custom-driver",# "pyobs-iagvt[gui] @ git+ssh://git@gitlab.example.org/iagvt/pyobs-iagvt.git"]# Hub (optional — see Hub mode section)HUB_TOKEN=""# deprecated, single-token form -- see HUB_CLIENTSHUB_CLIENTS= [] # named tokens for external callers (hub, scripts, ...)HUB_HOSTS= [] # remote hosts this instance controls# ejabberd integration (optional — see ejabberd integration section)EJABBERD_ENABLED=False# show XMPP status on the dashboard/module pagesEJABBERD_HOST="localhost"# which host runs ejabberd -- "localhost" or a HUB_HOSTS nameEJABBERD_DOMAIN=""# the XMPP vhost ejabberd servesEJABBERD_API_URL="http://127.0.0.1:5281/api"# mod_http_api base URLEJABBERDCTL="ejabberdctl"# required for user management writes (register/# reset/ban/unregister/kick); also a read fallback# if EJABBERD_API_URL can't be reached -- see# ejabberd user management section below

Keycloak login

The shared admin/password login above is the default and always available — Keycloak is an additive option on top of it, not a replacement, so it stays working as a break-glass fallback if Keycloak itself is unreachable. Unlike the shared account, a Keycloak-linked person's access is granted or revoked individually via Keycloak group membership, without touching anyone else's access or handing out the shared password.

Enabling it

  1. Register a client for this instance in your Keycloak realm (redirect URI https://your.domain.com/accounts/keycloak/callback/, post-logout redirect URI https://your.domain.com/).
  2. Add the PYOBS_AUTH block shown in Configuration to local_settings.py, filling in SERVER_URL, CLIENT_ID, and CLIENT_SECRET. REQUIRED_GROUPS defaults to ["/pyobs-web-admin"] — create that group in the realm and add whoever should have access to it (Keycloak admin console) before anyone tries to log in, or every login is refused as "not authorized".
  3. Run manage.py migrate (creates db.sqlite3, used for the Keycloak-linked User table and, as of this version, sessions too — SESSION_ENGINE is now database-backed rather than signed cookies, since a Keycloak session carries a refresh token that shouldn't be serialized into the browser. This applies to every session app-wide, including the shared admin/password login's — it's no longer fully DB-free, but it's the one Django setting shared by both login paths, so it can't be split per-path). Expired sessions aren't purged automatically — run manage.py clearsessions periodically (e.g. a daily cron/systemd timer) or django_session grows unbounded in a long-lived deployment.

The login page then shows a "Log in with Keycloak" button. A first-time Keycloak login mints a local User (linked to an existing one by email, falling back to username, if either matches), active immediately — whether they're actually let in is decided by REQUIRED_GROUPS above, not by anything in this app's own database.

Setting IDP_HINT (plus IDP_LABEL for the button text) switches the login page to two buttons: "Log in with <IDP_LABEL>" goes straight to that identity provider (skipping Keycloak's own login/IdP-selection page), and "Log in with local Keycloak account" keeps the local-account path reachable for anyone without that IdP's identity. Leave IDP_HINT unset for the single-button behavior above.

Granting or revoking a person's access

This is now done in the Keycloak admin console, not this app's Django admin: add or remove the person from the /pyobs-web-admin group (or whatever REQUIRED_GROUPS is set to). The change takes effect at their next login. Django's /admin/ site is still mounted (the shared admin/password account and any other Django superuser can still get in there for other reasons), with the ENFORCE_LOCAL_ACTIVE setting above (recommended, and the example config's default), toggling a Keycloak-linked account's Active flag there still works too, as an additional, Keycloak-independent kill switch on top of the group gate. Leave ENFORCE_LOCAL_ACTIVE unset if you'd rather Keycloak group membership be the only thing that matters.

The shared admin/password account works at /admin/ directly (no prior visit to /login/ needed first) because manage.py migrate syncs a matching superuser User automatically — see pyobs_web_admin.authentication.admin_sync, and Configuration for the ADMIN_USERNAME/ADMIN_PASSWORD_HASH settings that drive it.


Hub mode

pyobs-web-admin can act as a hub to control multiple remote pyobs hosts from a single browser session. When remote hosts are configured, a Hosts section appears at the top of the sidebar. Clicking a host switches the active context — all subsequent actions (start/stop/logs/config) are transparently proxied to that host's API.

Setting up the hub

On the hub (the machine you browse to), add to local_settings.py:

HUB_HOSTS= [
{"name": "obs1", "url": "http://obs1:8765", "token": "shared-secret"},
{"name": "obs2", "url": "http://obs2:8765", "token": "another-secret"},
]

On each remote host, give it a named client entry matching the token the hub sends:

HUB_CLIENTS= [
{"name": "hub", "token": "shared-secret"}, # must match HUB_HOSTS' token above
]

The hub — or any other external caller, such as a script calling the API directly — authenticates to remote instances via an X-Hub-Token header. Remote instances check that header against every entry in HUB_CLIENTS; a match bypasses the normal browser session/CSRF check, so the caller can invoke the API without a login session. Give each caller its own named entry so it can be revoked or rotated independently. Tokens are plain pre-shared strings — use long random values and keep them secret.

The older HUB_TOKEN setting (a single unnamed token) still works for backwards compatibility, equivalent to a HUB_CLIENTS entry named "default".

There's no separate "external" API — every endpoint the hub calls (start/stop, logs, config, ACL, packages, ejabberd user management, ...) is the same plain JSON API any X-Hub-Token-authenticated caller can use directly, e.g. from a script. See docs/source/api_endpoints.rst for the full endpoint reference.


ejabberd integration

If the ejabberd server pyobs's XMPP comm layer connects through runs on the same host, pyobs-web-admin can show live connection state alongside the process status it already tracks — closing the gap between "the module's process is running" and "the module is actually reachable over XMPP." When enabled: the dashboard gets a summary tile (how many of this installation's own modules, identified by their config's comm.user, are currently XMPP-connected) plus a small icon per module row; each module's own page gets a session/last-seen/registered-account block in its Overview tab. A module with no comm: block in its config (e.g. a pure HTTP module) is skipped entirely — there's nothing for it to connect to.

Enabling it

In local_settings.py:

EJABBERD_ENABLED=TrueEJABBERD_HOST="localhost"# or a HUB_HOSTS name, if ejabberd runs on a different hostEJABBERD_DOMAIN="your-xmpp-domain"# the vhost ejabberd serves, e.g. "pyobs.example.org"EJABBERD_API_URL="http://127.0.0.1:5281/api"

If EJABBERD_HOST names a HUB_HOSTS entry instead of "localhost", every instance in the fleet transparently proxies its ejabberd queries to that one host — only that host needs EJABBERD_API_URL actually pointed at a real ejabberd; every other instance just needs EJABBERD_HOST set to its name.

ejabberd-side configuration

This talks to ejabberd's HTTP admin API (mod_http_api), not ejabberdctl — about 50–60x faster per call, since it hits the already-running node directly instead of spawning a new Erlang VM per invocation (ejabberdctl is used as a fallback only if EJABBERD_API_URL can't be reached). Add this to ejabberd's own config:

listen:
-
port: 5281ip: "127.0.0.1"# loopback only -- see security note belowmodule: ejabberd_httprequest_handlers:
/api: mod_http_api # add this to an *existing* listener's request_handlers if one's# already on this port (e.g. for BOSH/WebSocket) -- ejabberd only# allows one listener per portmodules:
mod_http_api: {}api_permissions:
"console commands":
from: [ejabberd_ctl]who: allwhat: "*""pyobs-web-admin readonly":
from: [mod_http_api]who:
access:
allow:
- acl: loopbackwhat:
- "status"
- "stats"
- "connected_users_info"
- "registered_users"
- "user_sessions_info"
- "get_last"
- "check_account"

Reload ejabberd's config after adding this (ejabberdctl reload_config, or a restart if that doesn't pick up the new listener). The what: list is a deliberate whitelist — leave it as-is; mod_http_api can also expose account-management commands (register/unregister/change_password) that should never be reachable here.

Security note. Access is IP-based, not credential-based — any request from loopback is trusted, no password or token is involved. This blocks the network (a request from outside the host is rejected), but not other processes on the same machine, which get the same access pyobs-web-admin does. That's an accepted tradeoff for a dedicated, single-purpose observatory control host, not a shared one — reassess if that's not your deployment.


ejabberd user management

Builds on ejabberd integration above (requires EJABBERD_ENABLED = True) to add write actions on top of the read-only status it already shows: register, reset password, ban / unban, unregister, and kick (force-disconnect one session without touching the account) for any module's comm.user. Reversible actions get a single confirmation dialog; unregister — the one action with no undo — requires retyping the account's username first. An identity shared by more than one module's comm.user (a real, supported scenario — e.g. a test copy of a module reusing a real module's identity) is handled safely: a password reset writes the new password back into every module sharing it, not just the one the action was triggered from, and destructive actions (ban/unregister) name every other module affected before you can confirm.

This surfaces in two places:

  • The module detail page's existing ejabberd block (Overview tab) — register when the account isn't registered yet, reset/ban/unregister when it is.
  • A dedicated Users page (/xmpp-users/), linked from the sidebar whenever EJABBERD_ENABLED = True — every registered XMPP account across every configured host, in one fleet-wide, mobile-friendly list. Unlike the module page, this also covers accounts with no owning module at all (e.g. admin) via a manual "register account" form, and accounts shared by more than one module show a status dot marking which one is actually the connected session.

Transport: ejabberdctl, not mod_http_api

Unlike the read path above, writes always go through the ejabberdctl CLI, never mod_http_api — a write's cost is dominated by a human clicking a confirmation dialog, not command latency, so the ~50–60x speed advantage HTTP has for reads doesn't matter here. This also means no api_permissions change is needed for user management specifically — but see the security note below, since ejabberdctl itself is far more powerful than the read-only HTTP whitelist above.

ejabberdctl normally refuses to run as anything other than root or the ejabberd system user ("can only be run by root or the user ejabberd"), which only matters here since writes always need it (the read path mostly avoids it via mod_http_api). If pyobs-web-admin runs as its own service user (e.g. pyobs, per deploy/pyobs-web-admin.service), give that user a narrowly-scoped passwordless sudo rule for just this one binary:

# /etc/sudoers.d/pyobs-web-admin-ejabberdctl
pyobs ALL=(root) NOPASSWD: /usr/sbin/ejabberdctl

(adjust the username and binary path for your setup — check with which ejabberdctl), then point EJABBERDCTL in local_settings.py at the wrapper script committed at the repo root:

EJABBERDCTL="/opt/pyobs/pyobs-web-admin/ejabberdctl-sudo.sh"

ejabberdctl-sudo.sh is a two-line wrapper (exec sudo -n ejabberdctl "$@") — the -n flag makes sudo fail fast instead of hanging on a password prompt if the sudoers rule above isn't in place. Not needed at all if pyobs-web-admin already runs as root or ejabberd.

Security note. This is a materially bigger trust step than the read-only integration above: ejabberdctl can do anything an ejabberd administrator can do, not just the small read-only whitelist mod_http_api's api_permissions enforces. There is no OS-level or ejabberd-level restriction narrowing what the sudo rule allows beyond "run ejabberdctl as root at all" — this app's own tiered confirmation dialogs are the only safety net between a logged-in admin and any ejabberdctl subcommand this app happens to call. Acceptable for the same reason as the read path's IP-based trust: a dedicated, single-purpose observatory control host with one admin identity, not a shared or multi-tenant one.


Package management

The Packages page (/packages/) lists every installed pyobs-* package (plus anything extra listed in PYOBS_MANAGED_PACKAGES) alongside its latest release on PyPI, and lets you update any of them with one click. It always reflects pip's own view of the environment PYOBS_EXEC runs in (via the sibling pip next to it) — nothing here is invented or cached. The fleet-wide Overview page (/overview/) additionally shows a package-version matrix across every configured host, so a package that's drifted out of sync on one host is easy to spot.

PYOBS_MANAGED_PACKAGES

pip never remembers how a package was originally installed — only what's currently there — so a bare pip install --upgrade pyobs-core would silently drop a [full] extra forever, and a package that isn't on PyPI at all has nothing for the Packages page to even find. PYOBS_MANAGED_PACKAGES fills in what pip's own installed-environment metadata can't recover:

PYOBS_MANAGED_PACKAGES= [
"pyobs-core[full]", # keep using this extra on every future upgrade"my-custom-driver", # a non-"pyobs"-prefixed package, shown/managed alongside pyobs-*"pyobs-iagvt[gui] @ git+ssh://git@gitlab.example.org/iagvt/pyobs-iagvt.git", # git-installed
]
  • Extras — list the full name[extra] spec and future updates keep using it, instead of reverting to a bare install.
  • Non-pyobs-prefixed packages — a bare name makes it show up on the Packages page and be upgradable through it too.
  • Git/URL-installed packages — a PEP 508 direct reference (name[extras] @ <url>) for a package that isn't published on PyPI. The Packages page skips the (futile) PyPI version check for these and shows a Reinstall action instead, which just re-runs pip install --upgrade <spec> to pick up whatever's newest at that URL/ref.

Malformed entries are skipped rather than raising, so a typo here can't break the whole page.

Installing a private git-hosted package

For a private repository (e.g. an institute-internal driver like pyobs-iagvt above), pip running non-interactively needs its own credentials — an SSH deploy key is the recommended way:

  1. Generate a passwordless key for the OS user that actually runs pyobs-web-admin's own process (gunicorn/the systemd service user — not necessarily the pyobs service account, since the Packages page's pip install inherits this process's environment, not pyobs's):
    ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N ""
  2. Add the public key as a GitLab Deploy Key on the private project (Settings → Repository → Deploy keys), read-only access is enough. Scoped to just that repo and revocable independently of any person's account — prefer this over a personal SSH key.
  3. Pre-seed known_hosts for that user, so the first non-interactive install doesn't hang waiting on a host-key prompt:
    ssh-keyscan gitlab.example.org >>~/.ssh/known_hosts
  4. Use git+ssh://, not git+https://, in PYOBS_MANAGED_PACKAGES:
    PYOBS_MANAGED_PACKAGES= [
    "pyobs-iagvt[gui] @ git+ssh://git@gitlab.example.org/iagvt/pyobs-iagvt.git",
    ]
  5. In hub mode, repeat this on every host that's expected to manage the package — package updates run on whichever host is currently active, not just the hub, so each of them needs its own copy of the key (or the same key deployed to all of them).

An SSH key is preferred over embedding a GitLab Deploy Token in an HTTPS URL: the token would end up as a literal pip install command-line argument, visible to any other local user via ps aux for the few seconds the install runs. An SSH key file avoids that exposure.


How modules are managed

  • Discovery — all *.yaml files in PYOBS_CONFIG_DIR (excluding *.shared.yaml) are treated as modules. *.shared.yaml files are listed separately as shared configs.
  • Creating a module — the "New module" button writes a fresh <name>.yaml with a minimal starter (class: key only); PYOBS_CONFIG_DIR is created automatically if it doesn't exist yet.
  • Activate / Deactivate — deactivating a module renames its config from name.yaml to _name.yaml (stopping it first if running); activating renames it back. Deactivated modules are excluded from Start All and Restart All.
  • Start — runs pyobs --pid-file <run>/<name>.pid --log-file <log>/<name>.log --log-level <level> <config>. pyobs daemonises itself via python-daemon. If the effective log backend is "journald" (see below), --syslog is passed instead of --log-file — pyobs then logs directly to the systemd journal, tagged SYSLOG_IDENTIFIER=pyobs and PYOBS_MODULE=<name>.
  • Stop — sends SIGTERM to the PID in the PID file; falls back to SIGKILL after 5 s.
  • Restart — stop followed by start.
  • Status — checks whether the process with the stored PID is alive (os.kill(pid, 0)).
  • Resource usage — uptime, CPU %, and RSS memory read via psutil on every status poll.
  • Logs — read from PYOBS_LOG_DIR's flat files by default, or from the systemd journal via journalctl if the effective log backend is "journald"; the log viewer and per-level counts work identically either way. The effective backend is PYOBS_LOG_BACKEND if set explicitly, otherwise auto-detected from pyobsd's own config file (~/.config/pyobs.yaml, /etc/pyobs.yaml, or /opt/pyobs/storage/pyobs.yaml, first found wins) — the same file pyobsd (pyobs-core's daemon manager) reads to decide whether it starts modules with --syslog, so this can't silently drift out of sync with it. Scrolling a log window to the top auto-loads older entries via journalctl's --until, for journald-backed modules only — the file backend's plain tail -n has no seek/offset to page further back with, so it reports nothing older available instead.
  • Log counts — per-level message counts (DEBUG / INFO / WARNING / ERROR / CRITICAL) for the last 24 h, using binary search on the log file to avoid reading the whole file.

Project layout

pyobs_web_admin/
settings.py Django project settings
local_settings.py.example Template for local overrides (not committed)
urls.py URL config
modules/
services.py All pyobs process and filesystem logic
views.py HTML pages + JSON API endpoints
proxy.py HTTP client for hub → remote host calls
ejabberd.py mod_http_api (status) + ejabberdctl (user management) client
middleware.py Login-required redirect + hub token auth
context_processors.py
deploy/
pyobs-web-admin.service systemd unit file
ejabberdctl-sudo.sh sudo wrapper for EJABBERDCTL -- see ejabberd user management
templates/
base.html Bootstrap 5 layout with responsive sidebar
modules/
dashboard.html
detail.html
shared_detail.html Config editor for *.shared.yaml files
new_module.html "Create a new module" form
packages.html Package list + Update/Reinstall actions
fleet_overview.html Fleet-wide host summary + package-version matrix
all_logs.html Fleet-wide live log tail
acl_matrix.html Fleet-wide ACL matrix
xmpp_users.html Fleet-wide XMPP account list + write actions
registration/
login.html

About

Web GUI for managing pyobs modules

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

pyobs-web-admin

A web-based administration interface for pyobs, the robotic telescope framework. It lets you start, stop, and restart modules, tail and filter their logs, and view and edit their configuration files — all from a browser.

Dashboard showing modules grouped under Stopped and Deactivated headings, with summary tiles and per-row quick-action buttons

Features

  • Dashboard — sortable list view of all modules with:
    • Running / stopped / total summary counts plus total CPU and RAM
    • Per-module status badge, RAM, CPU, and uptime columns — click any header to sort, reset icon restores default grouping
    • Modules grouped under Running / Stopped / Deactivated headers when sorted by status
    • Warning/error log counts for the last 24 h (highlighted in colour if non-zero)
    • Quick start, restart, stop, and activate/deactivate buttons per module
    • Start All, Restart All, and Stop All bulk actions
    • Inactive modules (prefixed with _) are excluded from bulk start/restart
    • Outdated badge on any running module whose loaded pyobs-* versions lag the installed ones (parsed from pyobs-core's startup "Loaded pyobs packages:" log line — comm-independent, works for comm-less modules too), plus a Restart outdated bulk action that restarts only those
    • Responsive: on small screens the table collapses to status dot + name + log counts + actions
  • Module detail — per-module view with four tabs:
    • Overview — current status, PID, uptime, CPU and memory usage, running pyobs-* package versions (flagged when they lag the installed set), per-level log message counts (last 24 h), XMPP connection state (if enabled), start/restart/stop/activate/deactivate control
    • Logs — live log tail with text filter, time-range filter (set a start date to load all logs since that instant, or click a line to set it), colour-coded by severity, auto-refresh; scrolling to the top auto-loads older entries (journald-backed modules, or file-backed modules once a start date is set)
    • Config — YAML editor with syntax highlighting and colour-coded {include} lines; included shared configs are shown as clickable links
    • ACL — point-and-click editor for the module's acl: block: click to allow/deny known modules, add other callers, toggle enforce/log mode
  • New module — a "+" next to the sidebar's Modules section creates a brand-new <name>.yaml config (a minimal starter with just a class: key) and takes you straight to its Config tab to fill in the rest
  • Shared configs*.shared.yaml config fragments listed in a separate sidebar section with a YAML-highlighted config editor (no start/stop controls)
  • Packages (/packages/) — every installed pyobs-* package (plus anything else listed in PYOBS_MANAGED_PACKAGES) with its installed and latest-PyPI version, and a one-click Update button; git/URL-installed packages get a Reinstall action instead (see Package management)
  • Overview (/overview/) — fleet-wide summary, one row per configured host: reachable or not, running/stopped/total counts, aggregate CPU/RAM, linking into that host's own Dashboard, plus a package-version matrix (one row per pyobs-* package, one column per host) so version drift across the fleet is visible at a glance. Deliberately no bulk or per-module actions — those stay on the per-host Dashboard, since a fleet-wide "Stop All" from one button is a real footgun
  • All Logs (/logs/) — fleet-wide live log tail across every module on every configured host, same filtering and scroll-to-load-older behaviour as a module's own Logs tab
  • ACL Matrix (/acl/) — fleet-wide read-only matrix of which module can call which, merged across every configured host
  • Hub mode — control multiple remote pyobs hosts from a single browser tab; remote hosts are listed in the sidebar and all actions are proxied transparently
  • ejabberd / XMPP status (optional) — dashboard summary tile and per-module connected/not-connected indicator, plus a session/last-seen/registered-account block on each module's own page, for modules with a comm.user in their config — closes the gap between "the process is running" and "the module is actually reachable over XMPP" (see ejabberd integration)
  • ejabberd / XMPP user management (optional, builds on the above) — register, reset password, ban/unban, unregister, and kick XMPP accounts, either from a module's own Overview tab or from a fleet-wide Users page (/xmpp-users/) listing every registered account across every host, cross-referenced against which module(s) use it and which one is actually running. Safe by design for an identity shared across more than one module's comm.user — a password reset writes back to every module sharing it, and destructive actions name which other modules are affected before you confirm (see ejabberd user management)
  • Responsive — works on mobile with a slide-in sidebar
  • No pyobs-core dependency — communicates with pyobs directly via subprocess; no Python imports from pyobs-core
  • Keycloak login (optional) — SSO on top of the default shared admin/password login, with per-person access granted/revoked via Keycloak group membership (see Keycloak login)

Technology

LayerChoice
BackendPython 3.13, Django 6, psutil
WSGI serverGunicorn
FrontendBootstrap 5 (CDN), CodeMirror 5 (CDN), vanilla JS
Package manageruv
AuthShared admin/password login plus optional Keycloak SSO; both share one SQLite db (sessions table for both, User table for Keycloak-linked accounts only)
Hub authPre-shared token in X-Hub-Token header; CSRF bypassed for hub requests

Development setup

git clone https://github.com/pyobs/pyobs-web-admin.git
cd pyobs-web-admin
uv sync
uv run python manage.py migrate
uv run python manage.py runserver

Create pyobs_web_admin/local_settings.py (see Configuration below).


Production setup

1. Install the app

cd /opt/pyobs
git clone https://github.com/pyobs/pyobs-web-admin.git
cd pyobs-web-admin
uv sync

2. Configure

Copy and edit the local settings file:

cp pyobs_web_admin/local_settings.py.example pyobs_web_admin/local_settings.py # or create from scratch$EDITOR pyobs_web_admin/local_settings.py

Minimum required settings for production (see Configuration):

DEBUG=FalseSECRET_KEY="..."# generate belowALLOWED_HOSTS= ["your-hostname-or-ip"]
ADMIN_USERNAME="admin"ADMIN_PASSWORD_HASH="..."# generate belowPYOBS_EXEC="/opt/pyobs/venv/bin/pyobs"

Generate a secret key:

DJANGO_SETTINGS_MODULE=pyobs_web_admin.settings uv run python -c \
"from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"

Generate a password hash:

DJANGO_SETTINGS_MODULE=pyobs_web_admin.settings uv run python -c \
"from django.contrib.auth.hashers import make_password; print(make_password('yourpassword'))"

3. Install the systemd service

cp deploy/pyobs-web-admin.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now pyobs-web-admin

Check that it started:

systemctl status pyobs-web-admin
journalctl -u pyobs-web-admin -f

4. Configure nginx

Add a site configuration that proxies to gunicorn on port 8765:

server{listen80;server_name your-hostname-or-ip;location / {proxy_passhttp://127.0.0.1:8765;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;proxy_set_header X-Forwarded-Proto $scheme;}}

If you add TLS (strongly recommended for any non-private network), also set in local_settings.py:

SESSION_COOKIE_SECURE=TrueCSRF_COOKIE_SECURE=True

Configuration

All runtime configuration lives in pyobs_web_admin/local_settings.py, which is not committed to version control. A full reference:

# DjangoSECRET_KEY="..."# required in productionDEBUG=False# set True only in developmentALLOWED_HOSTS= ["*"] # restrict to hostname/IP in production# HTTPS (enable once TLS is in place)# SESSION_COOKIE_SECURE = True# CSRF_COOKIE_SECURE = True# AuthenticationADMIN_USERNAME="admin"ADMIN_PASSWORD_HASH="pbkdf2_sha256$..."# see generation command above# Keycloak login (optional — see Keycloak login section)# PYOBS_AUTH = {# "SERVER_URL": "https://keycloak.example.org",# "REALM": "pyobs",# "CLIENT_ID": "web-admin",# "CLIENT_SECRET": "",# "REDIRECT_URI": "https://your.domain.com/accounts/keycloak/callback/",# "POST_LOGOUT_REDIRECT_URI": "https://your.domain.com/",# # Optional one-click IdP login: IDP_HINT is passed to Keycloak as kc_idp_hint (skips its# # login/IdP-selection page, going straight to that identity provider, e.g. GWDG SSO);# # IDP_LABEL is the button label on the login page. Leave both unset for the plain# # single "Log in with Keycloak" button.# "IDP_HINT": "gwdg",# "IDP_LABEL": "GWDG",# "USER_RESOLVER": "pyobs_web_admin.authentication.keycloak.resolve_user",# # Only members of this Keycloak group are authorized to use web-admin - create it (and add# # people to it) in the Keycloak admin console before anyone logs in.# "REQUIRED_GROUPS": ["/pyobs-web-admin"],# # Keycloak-independent kill switch, layered on top of REQUIRED_GROUPS above: an admin can# # deactivate a specific local User (Django admin) regardless of their Keycloak group# # membership.# "ENFORCE_LOCAL_ACTIVE": True,# }# pyobs pathsPYOBS_EXEC="/opt/pyobs/venv/bin/pyobs"# path to the pyobs executablePYOBS_CONFIG_DIR="/opt/pyobs/config"# directory containing *.yaml module configsPYOBS_LOG_DIR="/opt/pyobs/log"# directory containing *.log filesPYOBS_RUN_DIR="/opt/pyobs/run"# directory for PID filesPYOBS_LOG_LEVEL="info"# log level passed to pyobs on startPYOBS_LOG_BACKEND=None# None (default): auto-detect from pyobsd's own# config; "file" or "journald" to override. If# "journald": the account running pyobs-web-admin# needs journal read access — `usermod -aG# systemd-journal <account>` (preferred over `adm`,# which grants broader log access than needed) —# otherwise logs come back silently empty, no error.# Packages page (optional — see Package management section)PYOBS_MANAGED_PACKAGES= [] # e.g. ["pyobs-core[full]", "my-custom-driver",# "pyobs-iagvt[gui] @ git+ssh://git@gitlab.example.org/iagvt/pyobs-iagvt.git"]# Hub (optional — see Hub mode section)HUB_TOKEN=""# deprecated, single-token form -- see HUB_CLIENTSHUB_CLIENTS= [] # named tokens for external callers (hub, scripts, ...)HUB_HOSTS= [] # remote hosts this instance controls# ejabberd integration (optional — see ejabberd integration section)EJABBERD_ENABLED=False# show XMPP status on the dashboard/module pagesEJABBERD_HOST="localhost"# which host runs ejabberd -- "localhost" or a HUB_HOSTS nameEJABBERD_DOMAIN=""# the XMPP vhost ejabberd servesEJABBERD_API_URL="http://127.0.0.1:5281/api"# mod_http_api base URLEJABBERDCTL="ejabberdctl"# required for user management writes (register/# reset/ban/unregister/kick); also a read fallback# if EJABBERD_API_URL can't be reached -- see# ejabberd user management section below

Keycloak login

The shared admin/password login above is the default and always available — Keycloak is an additive option on top of it, not a replacement, so it stays working as a break-glass fallback if Keycloak itself is unreachable. Unlike the shared account, a Keycloak-linked person's access is granted or revoked individually via Keycloak group membership, without touching anyone else's access or handing out the shared password.

Enabling it

  1. Register a client for this instance in your Keycloak realm (redirect URI https://your.domain.com/accounts/keycloak/callback/, post-logout redirect URI https://your.domain.com/).
  2. Add the PYOBS_AUTH block shown in Configuration to local_settings.py, filling in SERVER_URL, CLIENT_ID, and CLIENT_SECRET. REQUIRED_GROUPS defaults to ["/pyobs-web-admin"] — create that group in the realm and add whoever should have access to it (Keycloak admin console) before anyone tries to log in, or every login is refused as "not authorized".
  3. Run manage.py migrate (creates db.sqlite3, used for the Keycloak-linked User table and, as of this version, sessions too — SESSION_ENGINE is now database-backed rather than signed cookies, since a Keycloak session carries a refresh token that shouldn't be serialized into the browser. This applies to every session app-wide, including the shared admin/password login's — it's no longer fully DB-free, but it's the one Django setting shared by both login paths, so it can't be split per-path). Expired sessions aren't purged automatically — run manage.py clearsessions periodically (e.g. a daily cron/systemd timer) or django_session grows unbounded in a long-lived deployment.

The login page then shows a "Log in with Keycloak" button. A first-time Keycloak login mints a local User (linked to an existing one by email, falling back to username, if either matches), active immediately — whether they're actually let in is decided by REQUIRED_GROUPS above, not by anything in this app's own database.

Setting IDP_HINT (plus IDP_LABEL for the button text) switches the login page to two buttons: "Log in with <IDP_LABEL>" goes straight to that identity provider (skipping Keycloak's own login/IdP-selection page), and "Log in with local Keycloak account" keeps the local-account path reachable for anyone without that IdP's identity. Leave IDP_HINT unset for the single-button behavior above.

Granting or revoking a person's access

This is now done in the Keycloak admin console, not this app's Django admin: add or remove the person from the /pyobs-web-admin group (or whatever REQUIRED_GROUPS is set to). The change takes effect at their next login. Django's /admin/ site is still mounted (the shared admin/password account and any other Django superuser can still get in there for other reasons), with the ENFORCE_LOCAL_ACTIVE setting above (recommended, and the example config's default), toggling a Keycloak-linked account's Active flag there still works too, as an additional, Keycloak-independent kill switch on top of the group gate. Leave ENFORCE_LOCAL_ACTIVE unset if you'd rather Keycloak group membership be the only thing that matters.

The shared admin/password account works at /admin/ directly (no prior visit to /login/ needed first) because manage.py migrate syncs a matching superuser User automatically — see pyobs_web_admin.authentication.admin_sync, and Configuration for the ADMIN_USERNAME/ADMIN_PASSWORD_HASH settings that drive it.


Hub mode

pyobs-web-admin can act as a hub to control multiple remote pyobs hosts from a single browser session. When remote hosts are configured, a Hosts section appears at the top of the sidebar. Clicking a host switches the active context — all subsequent actions (start/stop/logs/config) are transparently proxied to that host's API.

Setting up the hub

On the hub (the machine you browse to), add to local_settings.py:

HUB_HOSTS= [
{"name": "obs1", "url": "http://obs1:8765", "token": "shared-secret"},
{"name": "obs2", "url": "http://obs2:8765", "token": "another-secret"},
]

On each remote host, give it a named client entry matching the token the hub sends:

HUB_CLIENTS= [
{"name": "hub", "token": "shared-secret"}, # must match HUB_HOSTS' token above
]

The hub — or any other external caller, such as a script calling the API directly — authenticates to remote instances via an X-Hub-Token header. Remote instances check that header against every entry in HUB_CLIENTS; a match bypasses the normal browser session/CSRF check, so the caller can invoke the API without a login session. Give each caller its own named entry so it can be revoked or rotated independently. Tokens are plain pre-shared strings — use long random values and keep them secret.

The older HUB_TOKEN setting (a single unnamed token) still works for backwards compatibility, equivalent to a HUB_CLIENTS entry named "default".

There's no separate "external" API — every endpoint the hub calls (start/stop, logs, config, ACL, packages, ejabberd user management, ...) is the same plain JSON API any X-Hub-Token-authenticated caller can use directly, e.g. from a script. See docs/source/api_endpoints.rst for the full endpoint reference.


ejabberd integration

If the ejabberd server pyobs's XMPP comm layer connects through runs on the same host, pyobs-web-admin can show live connection state alongside the process status it already tracks — closing the gap between "the module's process is running" and "the module is actually reachable over XMPP." When enabled: the dashboard gets a summary tile (how many of this installation's own modules, identified by their config's comm.user, are currently XMPP-connected) plus a small icon per module row; each module's own page gets a session/last-seen/registered-account block in its Overview tab. A module with no comm: block in its config (e.g. a pure HTTP module) is skipped entirely — there's nothing for it to connect to.

Enabling it

In local_settings.py:

EJABBERD_ENABLED=TrueEJABBERD_HOST="localhost"# or a HUB_HOSTS name, if ejabberd runs on a different hostEJABBERD_DOMAIN="your-xmpp-domain"# the vhost ejabberd serves, e.g. "pyobs.example.org"EJABBERD_API_URL="http://127.0.0.1:5281/api"

If EJABBERD_HOST names a HUB_HOSTS entry instead of "localhost", every instance in the fleet transparently proxies its ejabberd queries to that one host — only that host needs EJABBERD_API_URL actually pointed at a real ejabberd; every other instance just needs EJABBERD_HOST set to its name.

ejabberd-side configuration

This talks to ejabberd's HTTP admin API (mod_http_api), not ejabberdctl — about 50–60x faster per call, since it hits the already-running node directly instead of spawning a new Erlang VM per invocation (ejabberdctl is used as a fallback only if EJABBERD_API_URL can't be reached). Add this to ejabberd's own config:

listen:
-
port: 5281ip: "127.0.0.1"# loopback only -- see security note belowmodule: ejabberd_httprequest_handlers:
/api: mod_http_api # add this to an *existing* listener's request_handlers if one's# already on this port (e.g. for BOSH/WebSocket) -- ejabberd only# allows one listener per portmodules:
mod_http_api: {}api_permissions:
"console commands":
from: [ejabberd_ctl]who: allwhat: "*""pyobs-web-admin readonly":
from: [mod_http_api]who:
access:
allow:
- acl: loopbackwhat:
- "status"
- "stats"
- "connected_users_info"
- "registered_users"
- "user_sessions_info"
- "get_last"
- "check_account"

Reload ejabberd's config after adding this (ejabberdctl reload_config, or a restart if that doesn't pick up the new listener). The what: list is a deliberate whitelist — leave it as-is; mod_http_api can also expose account-management commands (register/unregister/change_password) that should never be reachable here.

Security note. Access is IP-based, not credential-based — any request from loopback is trusted, no password or token is involved. This blocks the network (a request from outside the host is rejected), but not other processes on the same machine, which get the same access pyobs-web-admin does. That's an accepted tradeoff for a dedicated, single-purpose observatory control host, not a shared one — reassess if that's not your deployment.


ejabberd user management

Builds on ejabberd integration above (requires EJABBERD_ENABLED = True) to add write actions on top of the read-only status it already shows: register, reset password, ban / unban, unregister, and kick (force-disconnect one session without touching the account) for any module's comm.user. Reversible actions get a single confirmation dialog; unregister — the one action with no undo — requires retyping the account's username first. An identity shared by more than one module's comm.user (a real, supported scenario — e.g. a test copy of a module reusing a real module's identity) is handled safely: a password reset writes the new password back into every module sharing it, not just the one the action was triggered from, and destructive actions (ban/unregister) name every other module affected before you can confirm.

This surfaces in two places:

  • The module detail page's existing ejabberd block (Overview tab) — register when the account isn't registered yet, reset/ban/unregister when it is.
  • A dedicated Users page (/xmpp-users/), linked from the sidebar whenever EJABBERD_ENABLED = True — every registered XMPP account across every configured host, in one fleet-wide, mobile-friendly list. Unlike the module page, this also covers accounts with no owning module at all (e.g. admin) via a manual "register account" form, and accounts shared by more than one module show a status dot marking which one is actually the connected session.

Transport: ejabberdctl, not mod_http_api

Unlike the read path above, writes always go through the ejabberdctl CLI, never mod_http_api — a write's cost is dominated by a human clicking a confirmation dialog, not command latency, so the ~50–60x speed advantage HTTP has for reads doesn't matter here. This also means no api_permissions change is needed for user management specifically — but see the security note below, since ejabberdctl itself is far more powerful than the read-only HTTP whitelist above.

ejabberdctl normally refuses to run as anything other than root or the ejabberd system user ("can only be run by root or the user ejabberd"), which only matters here since writes always need it (the read path mostly avoids it via mod_http_api). If pyobs-web-admin runs as its own service user (e.g. pyobs, per deploy/pyobs-web-admin.service), give that user a narrowly-scoped passwordless sudo rule for just this one binary:

# /etc/sudoers.d/pyobs-web-admin-ejabberdctl
pyobs ALL=(root) NOPASSWD: /usr/sbin/ejabberdctl

(adjust the username and binary path for your setup — check with which ejabberdctl), then point EJABBERDCTL in local_settings.py at the wrapper script committed at the repo root:

EJABBERDCTL="/opt/pyobs/pyobs-web-admin/ejabberdctl-sudo.sh"

ejabberdctl-sudo.sh is a two-line wrapper (exec sudo -n ejabberdctl "$@") — the -n flag makes sudo fail fast instead of hanging on a password prompt if the sudoers rule above isn't in place. Not needed at all if pyobs-web-admin already runs as root or ejabberd.

Security note. This is a materially bigger trust step than the read-only integration above: ejabberdctl can do anything an ejabberd administrator can do, not just the small read-only whitelist mod_http_api's api_permissions enforces. There is no OS-level or ejabberd-level restriction narrowing what the sudo rule allows beyond "run ejabberdctl as root at all" — this app's own tiered confirmation dialogs are the only safety net between a logged-in admin and any ejabberdctl subcommand this app happens to call. Acceptable for the same reason as the read path's IP-based trust: a dedicated, single-purpose observatory control host with one admin identity, not a shared or multi-tenant one.


Package management

The Packages page (/packages/) lists every installed pyobs-* package (plus anything extra listed in PYOBS_MANAGED_PACKAGES) alongside its latest release on PyPI, and lets you update any of them with one click. It always reflects pip's own view of the environment PYOBS_EXEC runs in (via the sibling pip next to it) — nothing here is invented or cached. The fleet-wide Overview page (/overview/) additionally shows a package-version matrix across every configured host, so a package that's drifted out of sync on one host is easy to spot.

PYOBS_MANAGED_PACKAGES

pip never remembers how a package was originally installed — only what's currently there — so a bare pip install --upgrade pyobs-core would silently drop a [full] extra forever, and a package that isn't on PyPI at all has nothing for the Packages page to even find. PYOBS_MANAGED_PACKAGES fills in what pip's own installed-environment metadata can't recover:

PYOBS_MANAGED_PACKAGES= [
"pyobs-core[full]", # keep using this extra on every future upgrade"my-custom-driver", # a non-"pyobs"-prefixed package, shown/managed alongside pyobs-*"pyobs-iagvt[gui] @ git+ssh://git@gitlab.example.org/iagvt/pyobs-iagvt.git", # git-installed
]
  • Extras — list the full name[extra] spec and future updates keep using it, instead of reverting to a bare install.
  • Non-pyobs-prefixed packages — a bare name makes it show up on the Packages page and be upgradable through it too.
  • Git/URL-installed packages — a PEP 508 direct reference (name[extras] @ <url>) for a package that isn't published on PyPI. The Packages page skips the (futile) PyPI version check for these and shows a Reinstall action instead, which just re-runs pip install --upgrade <spec> to pick up whatever's newest at that URL/ref.

Malformed entries are skipped rather than raising, so a typo here can't break the whole page.

Installing a private git-hosted package

For a private repository (e.g. an institute-internal driver like pyobs-iagvt above), pip running non-interactively needs its own credentials — an SSH deploy key is the recommended way:

  1. Generate a passwordless key for the OS user that actually runs pyobs-web-admin's own process (gunicorn/the systemd service user — not necessarily the pyobs service account, since the Packages page's pip install inherits this process's environment, not pyobs's):
    ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N ""
  2. Add the public key as a GitLab Deploy Key on the private project (Settings → Repository → Deploy keys), read-only access is enough. Scoped to just that repo and revocable independently of any person's account — prefer this over a personal SSH key.
  3. Pre-seed known_hosts for that user, so the first non-interactive install doesn't hang waiting on a host-key prompt:
    ssh-keyscan gitlab.example.org >>~/.ssh/known_hosts
  4. Use git+ssh://, not git+https://, in PYOBS_MANAGED_PACKAGES:
    PYOBS_MANAGED_PACKAGES= [
    "pyobs-iagvt[gui] @ git+ssh://git@gitlab.example.org/iagvt/pyobs-iagvt.git",
    ]
  5. In hub mode, repeat this on every host that's expected to manage the package — package updates run on whichever host is currently active, not just the hub, so each of them needs its own copy of the key (or the same key deployed to all of them).

An SSH key is preferred over embedding a GitLab Deploy Token in an HTTPS URL: the token would end up as a literal pip install command-line argument, visible to any other local user via ps aux for the few seconds the install runs. An SSH key file avoids that exposure.


How modules are managed

  • Discovery — all *.yaml files in PYOBS_CONFIG_DIR (excluding *.shared.yaml) are treated as modules. *.shared.yaml files are listed separately as shared configs.
  • Creating a module — the "New module" button writes a fresh <name>.yaml with a minimal starter (class: key only); PYOBS_CONFIG_DIR is created automatically if it doesn't exist yet.
  • Activate / Deactivate — deactivating a module renames its config from name.yaml to _name.yaml (stopping it first if running); activating renames it back. Deactivated modules are excluded from Start All and Restart All.
  • Start — runs pyobs --pid-file <run>/<name>.pid --log-file <log>/<name>.log --log-level <level> <config>. pyobs daemonises itself via python-daemon. If the effective log backend is "journald" (see below), --syslog is passed instead of --log-file — pyobs then logs directly to the systemd journal, tagged SYSLOG_IDENTIFIER=pyobs and PYOBS_MODULE=<name>.
  • Stop — sends SIGTERM to the PID in the PID file; falls back to SIGKILL after 5 s.
  • Restart — stop followed by start.
  • Status — checks whether the process with the stored PID is alive (os.kill(pid, 0)).
  • Resource usage — uptime, CPU %, and RSS memory read via psutil on every status poll.
  • Logs — read from PYOBS_LOG_DIR's flat files by default, or from the systemd journal via journalctl if the effective log backend is "journald"; the log viewer and per-level counts work identically either way. The effective backend is PYOBS_LOG_BACKEND if set explicitly, otherwise auto-detected from pyobsd's own config file (~/.config/pyobs.yaml, /etc/pyobs.yaml, or /opt/pyobs/storage/pyobs.yaml, first found wins) — the same file pyobsd (pyobs-core's daemon manager) reads to decide whether it starts modules with --syslog, so this can't silently drift out of sync with it. Scrolling a log window to the top auto-loads older entries via journalctl's --until, for journald-backed modules only — the file backend's plain tail -n has no seek/offset to page further back with, so it reports nothing older available instead.
  • Log counts — per-level message counts (DEBUG / INFO / WARNING / ERROR / CRITICAL) for the last 24 h, using binary search on the log file to avoid reading the whole file.

Project layout

pyobs_web_admin/
settings.py Django project settings
local_settings.py.example Template for local overrides (not committed)
urls.py URL config
modules/
services.py All pyobs process and filesystem logic
views.py HTML pages + JSON API endpoints
proxy.py HTTP client for hub → remote host calls
ejabberd.py mod_http_api (status) + ejabberdctl (user management) client
middleware.py Login-required redirect + hub token auth
context_processors.py
deploy/
pyobs-web-admin.service systemd unit file
ejabberdctl-sudo.sh sudo wrapper for EJABBERDCTL -- see ejabberd user management
templates/
base.html Bootstrap 5 layout with responsive sidebar
modules/
dashboard.html
detail.html
shared_detail.html Config editor for *.shared.yaml files
new_module.html "Create a new module" form
packages.html Package list + Update/Reinstall actions
fleet_overview.html Fleet-wide host summary + package-version matrix
all_logs.html Fleet-wide live log tail
acl_matrix.html Fleet-wide ACL matrix
xmpp_users.html Fleet-wide XMPP account list + write actions
registration/
login.html

About

Web GUI for managing pyobs modules

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

pyobs-web-admin

A web-based administration interface for pyobs, the robotic telescope framework. It lets you start, stop, and restart modules, tail and filter their logs, and view and edit their configuration files — all from a browser.

Dashboard showing modules grouped under Stopped and Deactivated headings, with summary tiles and per-row quick-action buttons

Features

  • Dashboard — sortable list view of all modules with:
    • Running / stopped / total summary counts plus total CPU and RAM
    • Per-module status badge, RAM, CPU, and uptime columns — click any header to sort, reset icon restores default grouping
    • Modules grouped under Running / Stopped / Deactivated headers when sorted by status
    • Warning/error log counts for the last 24 h (highlighted in colour if non-zero)
    • Quick start, restart, stop, and activate/deactivate buttons per module
    • Start All, Restart All, and Stop All bulk actions
    • Inactive modules (prefixed with _) are excluded from bulk start/restart
    • Outdated badge on any running module whose loaded pyobs-* versions lag the installed ones (parsed from pyobs-core's startup "Loaded pyobs packages:" log line — comm-independent, works for comm-less modules too), plus a Restart outdated bulk action that restarts only those
    • Responsive: on small screens the table collapses to status dot + name + log counts + actions
  • Module detail — per-module view with four tabs:
    • Overview — current status, PID, uptime, CPU and memory usage, running pyobs-* package versions (flagged when they lag the installed set), per-level log message counts (last 24 h), XMPP connection state (if enabled), start/restart/stop/activate/deactivate control
    • Logs — live log tail with text filter, time-range filter (set a start date to load all logs since that instant, or click a line to set it), colour-coded by severity, auto-refresh; scrolling to the top auto-loads older entries (journald-backed modules, or file-backed modules once a start date is set)
    • Config — YAML editor with syntax highlighting and colour-coded {include} lines; included shared configs are shown as clickable links
    • ACL — point-and-click editor for the module's acl: block: click to allow/deny known modules, add other callers, toggle enforce/log mode
  • New module — a "+" next to the sidebar's Modules section creates a brand-new <name>.yaml config (a minimal starter with just a class: key) and takes you straight to its Config tab to fill in the rest
  • Shared configs*.shared.yaml config fragments listed in a separate sidebar section with a YAML-highlighted config editor (no start/stop controls)
  • Packages (/packages/) — every installed pyobs-* package (plus anything else listed in PYOBS_MANAGED_PACKAGES) with its installed and latest-PyPI version, and a one-click Update button; git/URL-installed packages get a Reinstall action instead (see Package management)
  • Overview (/overview/) — fleet-wide summary, one row per configured host: reachable or not, running/stopped/total counts, aggregate CPU/RAM, linking into that host's own Dashboard, plus a package-version matrix (one row per pyobs-* package, one column per host) so version drift across the fleet is visible at a glance. Deliberately no bulk or per-module actions — those stay on the per-host Dashboard, since a fleet-wide "Stop All" from one button is a real footgun
  • All Logs (/logs/) — fleet-wide live log tail across every module on every configured host, same filtering and scroll-to-load-older behaviour as a module's own Logs tab
  • ACL Matrix (/acl/) — fleet-wide read-only matrix of which module can call which, merged across every configured host
  • Hub mode — control multiple remote pyobs hosts from a single browser tab; remote hosts are listed in the sidebar and all actions are proxied transparently
  • ejabberd / XMPP status (optional) — dashboard summary tile and per-module connected/not-connected indicator, plus a session/last-seen/registered-account block on each module's own page, for modules with a comm.user in their config — closes the gap between "the process is running" and "the module is actually reachable over XMPP" (see ejabberd integration)
  • ejabberd / XMPP user management (optional, builds on the above) — register, reset password, ban/unban, unregister, and kick XMPP accounts, either from a module's own Overview tab or from a fleet-wide Users page (/xmpp-users/) listing every registered account across every host, cross-referenced against which module(s) use it and which one is actually running. Safe by design for an identity shared across more than one module's comm.user — a password reset writes back to every module sharing it, and destructive actions name which other modules are affected before you confirm (see ejabberd user management)
  • Responsive — works on mobile with a slide-in sidebar
  • No pyobs-core dependency — communicates with pyobs directly via subprocess; no Python imports from pyobs-core
  • Keycloak login (optional) — SSO on top of the default shared admin/password login, with per-person access granted/revoked via Keycloak group membership (see Keycloak login)

Technology

LayerChoice
BackendPython 3.13, Django 6, psutil
WSGI serverGunicorn
FrontendBootstrap 5 (CDN), CodeMirror 5 (CDN), vanilla JS
Package manageruv
AuthShared admin/password login plus optional Keycloak SSO; both share one SQLite db (sessions table for both, User table for Keycloak-linked accounts only)
Hub authPre-shared token in X-Hub-Token header; CSRF bypassed for hub requests

Development setup

git clone https://github.com/pyobs/pyobs-web-admin.git
cd pyobs-web-admin
uv sync
uv run python manage.py migrate
uv run python manage.py runserver

Create pyobs_web_admin/local_settings.py (see Configuration below).


Production setup

1. Install the app

cd /opt/pyobs
git clone https://github.com/pyobs/pyobs-web-admin.git
cd pyobs-web-admin
uv sync

2. Configure

Copy and edit the local settings file:

cp pyobs_web_admin/local_settings.py.example pyobs_web_admin/local_settings.py # or create from scratch$EDITOR pyobs_web_admin/local_settings.py

Minimum required settings for production (see Configuration):

DEBUG=FalseSECRET_KEY="..."# generate belowALLOWED_HOSTS= ["your-hostname-or-ip"]
ADMIN_USERNAME="admin"ADMIN_PASSWORD_HASH="..."# generate belowPYOBS_EXEC="/opt/pyobs/venv/bin/pyobs"

Generate a secret key:

DJANGO_SETTINGS_MODULE=pyobs_web_admin.settings uv run python -c \
"from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"

Generate a password hash:

DJANGO_SETTINGS_MODULE=pyobs_web_admin.settings uv run python -c \
"from django.contrib.auth.hashers import make_password; print(make_password('yourpassword'))"

3. Install the systemd service

cp deploy/pyobs-web-admin.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now pyobs-web-admin

Check that it started:

systemctl status pyobs-web-admin
journalctl -u pyobs-web-admin -f

4. Configure nginx

Add a site configuration that proxies to gunicorn on port 8765:

server{listen80;server_name your-hostname-or-ip;location / {proxy_passhttp://127.0.0.1:8765;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;proxy_set_header X-Forwarded-Proto $scheme;}}

If you add TLS (strongly recommended for any non-private network), also set in local_settings.py:

SESSION_COOKIE_SECURE=TrueCSRF_COOKIE_SECURE=True

Configuration

All runtime configuration lives in pyobs_web_admin/local_settings.py, which is not committed to version control. A full reference:

# DjangoSECRET_KEY="..."# required in productionDEBUG=False# set True only in developmentALLOWED_HOSTS= ["*"] # restrict to hostname/IP in production# HTTPS (enable once TLS is in place)# SESSION_COOKIE_SECURE = True# CSRF_COOKIE_SECURE = True# AuthenticationADMIN_USERNAME="admin"ADMIN_PASSWORD_HASH="pbkdf2_sha256$..."# see generation command above# Keycloak login (optional — see Keycloak login section)# PYOBS_AUTH = {# "SERVER_URL": "https://keycloak.example.org",# "REALM": "pyobs",# "CLIENT_ID": "web-admin",# "CLIENT_SECRET": "",# "REDIRECT_URI": "https://your.domain.com/accounts/keycloak/callback/",# "POST_LOGOUT_REDIRECT_URI": "https://your.domain.com/",# # Optional one-click IdP login: IDP_HINT is passed to Keycloak as kc_idp_hint (skips its# # login/IdP-selection page, going straight to that identity provider, e.g. GWDG SSO);# # IDP_LABEL is the button label on the login page. Leave both unset for the plain# # single "Log in with Keycloak" button.# "IDP_HINT": "gwdg",# "IDP_LABEL": "GWDG",# "USER_RESOLVER": "pyobs_web_admin.authentication.keycloak.resolve_user",# # Only members of this Keycloak group are authorized to use web-admin - create it (and add# # people to it) in the Keycloak admin console before anyone logs in.# "REQUIRED_GROUPS": ["/pyobs-web-admin"],# # Keycloak-independent kill switch, layered on top of REQUIRED_GROUPS above: an admin can# # deactivate a specific local User (Django admin) regardless of their Keycloak group# # membership.# "ENFORCE_LOCAL_ACTIVE": True,# }# pyobs pathsPYOBS_EXEC="/opt/pyobs/venv/bin/pyobs"# path to the pyobs executablePYOBS_CONFIG_DIR="/opt/pyobs/config"# directory containing *.yaml module configsPYOBS_LOG_DIR="/opt/pyobs/log"# directory containing *.log filesPYOBS_RUN_DIR="/opt/pyobs/run"# directory for PID filesPYOBS_LOG_LEVEL="info"# log level passed to pyobs on startPYOBS_LOG_BACKEND=None# None (default): auto-detect from pyobsd's own# config; "file" or "journald" to override. If# "journald": the account running pyobs-web-admin# needs journal read access — `usermod -aG# systemd-journal <account>` (preferred over `adm`,# which grants broader log access than needed) —# otherwise logs come back silently empty, no error.# Packages page (optional — see Package management section)PYOBS_MANAGED_PACKAGES= [] # e.g. ["pyobs-core[full]", "my-custom-driver",# "pyobs-iagvt[gui] @ git+ssh://git@gitlab.example.org/iagvt/pyobs-iagvt.git"]# Hub (optional — see Hub mode section)HUB_TOKEN=""# deprecated, single-token form -- see HUB_CLIENTSHUB_CLIENTS= [] # named tokens for external callers (hub, scripts, ...)HUB_HOSTS= [] # remote hosts this instance controls# ejabberd integration (optional — see ejabberd integration section)EJABBERD_ENABLED=False# show XMPP status on the dashboard/module pagesEJABBERD_HOST="localhost"# which host runs ejabberd -- "localhost" or a HUB_HOSTS nameEJABBERD_DOMAIN=""# the XMPP vhost ejabberd servesEJABBERD_API_URL="http://127.0.0.1:5281/api"# mod_http_api base URLEJABBERDCTL="ejabberdctl"# required for user management writes (register/# reset/ban/unregister/kick); also a read fallback# if EJABBERD_API_URL can't be reached -- see# ejabberd user management section below

Keycloak login

The shared admin/password login above is the default and always available — Keycloak is an additive option on top of it, not a replacement, so it stays working as a break-glass fallback if Keycloak itself is unreachable. Unlike the shared account, a Keycloak-linked person's access is granted or revoked individually via Keycloak group membership, without touching anyone else's access or handing out the shared password.

Enabling it

  1. Register a client for this instance in your Keycloak realm (redirect URI https://your.domain.com/accounts/keycloak/callback/, post-logout redirect URI https://your.domain.com/).
  2. Add the PYOBS_AUTH block shown in Configuration to local_settings.py, filling in SERVER_URL, CLIENT_ID, and CLIENT_SECRET. REQUIRED_GROUPS defaults to ["/pyobs-web-admin"] — create that group in the realm and add whoever should have access to it (Keycloak admin console) before anyone tries to log in, or every login is refused as "not authorized".
  3. Run manage.py migrate (creates db.sqlite3, used for the Keycloak-linked User table and, as of this version, sessions too — SESSION_ENGINE is now database-backed rather than signed cookies, since a Keycloak session carries a refresh token that shouldn't be serialized into the browser. This applies to every session app-wide, including the shared admin/password login's — it's no longer fully DB-free, but it's the one Django setting shared by both login paths, so it can't be split per-path). Expired sessions aren't purged automatically — run manage.py clearsessions periodically (e.g. a daily cron/systemd timer) or django_session grows unbounded in a long-lived deployment.

The login page then shows a "Log in with Keycloak" button. A first-time Keycloak login mints a local User (linked to an existing one by email, falling back to username, if either matches), active immediately — whether they're actually let in is decided by REQUIRED_GROUPS above, not by anything in this app's own database.

Setting IDP_HINT (plus IDP_LABEL for the button text) switches the login page to two buttons: "Log in with <IDP_LABEL>" goes straight to that identity provider (skipping Keycloak's own login/IdP-selection page), and "Log in with local Keycloak account" keeps the local-account path reachable for anyone without that IdP's identity. Leave IDP_HINT unset for the single-button behavior above.

Granting or revoking a person's access

This is now done in the Keycloak admin console, not this app's Django admin: add or remove the person from the /pyobs-web-admin group (or whatever REQUIRED_GROUPS is set to). The change takes effect at their next login. Django's /admin/ site is still mounted (the shared admin/password account and any other Django superuser can still get in there for other reasons), with the ENFORCE_LOCAL_ACTIVE setting above (recommended, and the example config's default), toggling a Keycloak-linked account's Active flag there still works too, as an additional, Keycloak-independent kill switch on top of the group gate. Leave ENFORCE_LOCAL_ACTIVE unset if you'd rather Keycloak group membership be the only thing that matters.

The shared admin/password account works at /admin/ directly (no prior visit to /login/ needed first) because manage.py migrate syncs a matching superuser User automatically — see pyobs_web_admin.authentication.admin_sync, and Configuration for the ADMIN_USERNAME/ADMIN_PASSWORD_HASH settings that drive it.


Hub mode

pyobs-web-admin can act as a hub to control multiple remote pyobs hosts from a single browser session. When remote hosts are configured, a Hosts section appears at the top of the sidebar. Clicking a host switches the active context — all subsequent actions (start/stop/logs/config) are transparently proxied to that host's API.

Setting up the hub

On the hub (the machine you browse to), add to local_settings.py:

HUB_HOSTS= [
{"name": "obs1", "url": "http://obs1:8765", "token": "shared-secret"},
{"name": "obs2", "url": "http://obs2:8765", "token": "another-secret"},
]

On each remote host, give it a named client entry matching the token the hub sends:

HUB_CLIENTS= [
{"name": "hub", "token": "shared-secret"}, # must match HUB_HOSTS' token above
]

The hub — or any other external caller, such as a script calling the API directly — authenticates to remote instances via an X-Hub-Token header. Remote instances check that header against every entry in HUB_CLIENTS; a match bypasses the normal browser session/CSRF check, so the caller can invoke the API without a login session. Give each caller its own named entry so it can be revoked or rotated independently. Tokens are plain pre-shared strings — use long random values and keep them secret.

The older HUB_TOKEN setting (a single unnamed token) still works for backwards compatibility, equivalent to a HUB_CLIENTS entry named "default".

There's no separate "external" API — every endpoint the hub calls (start/stop, logs, config, ACL, packages, ejabberd user management, ...) is the same plain JSON API any X-Hub-Token-authenticated caller can use directly, e.g. from a script. See docs/source/api_endpoints.rst for the full endpoint reference.


ejabberd integration

If the ejabberd server pyobs's XMPP comm layer connects through runs on the same host, pyobs-web-admin can show live connection state alongside the process status it already tracks — closing the gap between "the module's process is running" and "the module is actually reachable over XMPP." When enabled: the dashboard gets a summary tile (how many of this installation's own modules, identified by their config's comm.user, are currently XMPP-connected) plus a small icon per module row; each module's own page gets a session/last-seen/registered-account block in its Overview tab. A module with no comm: block in its config (e.g. a pure HTTP module) is skipped entirely — there's nothing for it to connect to.

Enabling it

In local_settings.py:

EJABBERD_ENABLED=TrueEJABBERD_HOST="localhost"# or a HUB_HOSTS name, if ejabberd runs on a different hostEJABBERD_DOMAIN="your-xmpp-domain"# the vhost ejabberd serves, e.g. "pyobs.example.org"EJABBERD_API_URL="http://127.0.0.1:5281/api"

If EJABBERD_HOST names a HUB_HOSTS entry instead of "localhost", every instance in the fleet transparently proxies its ejabberd queries to that one host — only that host needs EJABBERD_API_URL actually pointed at a real ejabberd; every other instance just needs EJABBERD_HOST set to its name.

ejabberd-side configuration

This talks to ejabberd's HTTP admin API (mod_http_api), not ejabberdctl — about 50–60x faster per call, since it hits the already-running node directly instead of spawning a new Erlang VM per invocation (ejabberdctl is used as a fallback only if EJABBERD_API_URL can't be reached). Add this to ejabberd's own config:

listen:
-
port: 5281ip: "127.0.0.1"# loopback only -- see security note belowmodule: ejabberd_httprequest_handlers:
/api: mod_http_api # add this to an *existing* listener's request_handlers if one's# already on this port (e.g. for BOSH/WebSocket) -- ejabberd only# allows one listener per portmodules:
mod_http_api: {}api_permissions:
"console commands":
from: [ejabberd_ctl]who: allwhat: "*""pyobs-web-admin readonly":
from: [mod_http_api]who:
access:
allow:
- acl: loopbackwhat:
- "status"
- "stats"
- "connected_users_info"
- "registered_users"
- "user_sessions_info"
- "get_last"
- "check_account"

Reload ejabberd's config after adding this (ejabberdctl reload_config, or a restart if that doesn't pick up the new listener). The what: list is a deliberate whitelist — leave it as-is; mod_http_api can also expose account-management commands (register/unregister/change_password) that should never be reachable here.

Security note. Access is IP-based, not credential-based — any request from loopback is trusted, no password or token is involved. This blocks the network (a request from outside the host is rejected), but not other processes on the same machine, which get the same access pyobs-web-admin does. That's an accepted tradeoff for a dedicated, single-purpose observatory control host, not a shared one — reassess if that's not your deployment.


ejabberd user management

Builds on ejabberd integration above (requires EJABBERD_ENABLED = True) to add write actions on top of the read-only status it already shows: register, reset password, ban / unban, unregister, and kick (force-disconnect one session without touching the account) for any module's comm.user. Reversible actions get a single confirmation dialog; unregister — the one action with no undo — requires retyping the account's username first. An identity shared by more than one module's comm.user (a real, supported scenario — e.g. a test copy of a module reusing a real module's identity) is handled safely: a password reset writes the new password back into every module sharing it, not just the one the action was triggered from, and destructive actions (ban/unregister) name every other module affected before you can confirm.

This surfaces in two places:

  • The module detail page's existing ejabberd block (Overview tab) — register when the account isn't registered yet, reset/ban/unregister when it is.
  • A dedicated Users page (/xmpp-users/), linked from the sidebar whenever EJABBERD_ENABLED = True — every registered XMPP account across every configured host, in one fleet-wide, mobile-friendly list. Unlike the module page, this also covers accounts with no owning module at all (e.g. admin) via a manual "register account" form, and accounts shared by more than one module show a status dot marking which one is actually the connected session.

Transport: ejabberdctl, not mod_http_api

Unlike the read path above, writes always go through the ejabberdctl CLI, never mod_http_api — a write's cost is dominated by a human clicking a confirmation dialog, not command latency, so the ~50–60x speed advantage HTTP has for reads doesn't matter here. This also means no api_permissions change is needed for user management specifically — but see the security note below, since ejabberdctl itself is far more powerful than the read-only HTTP whitelist above.

ejabberdctl normally refuses to run as anything other than root or the ejabberd system user ("can only be run by root or the user ejabberd"), which only matters here since writes always need it (the read path mostly avoids it via mod_http_api). If pyobs-web-admin runs as its own service user (e.g. pyobs, per deploy/pyobs-web-admin.service), give that user a narrowly-scoped passwordless sudo rule for just this one binary:

# /etc/sudoers.d/pyobs-web-admin-ejabberdctl
pyobs ALL=(root) NOPASSWD: /usr/sbin/ejabberdctl

(adjust the username and binary path for your setup — check with which ejabberdctl), then point EJABBERDCTL in local_settings.py at the wrapper script committed at the repo root:

EJABBERDCTL="/opt/pyobs/pyobs-web-admin/ejabberdctl-sudo.sh"

ejabberdctl-sudo.sh is a two-line wrapper (exec sudo -n ejabberdctl "$@") — the -n flag makes sudo fail fast instead of hanging on a password prompt if the sudoers rule above isn't in place. Not needed at all if pyobs-web-admin already runs as root or ejabberd.

Security note. This is a materially bigger trust step than the read-only integration above: ejabberdctl can do anything an ejabberd administrator can do, not just the small read-only whitelist mod_http_api's api_permissions enforces. There is no OS-level or ejabberd-level restriction narrowing what the sudo rule allows beyond "run ejabberdctl as root at all" — this app's own tiered confirmation dialogs are the only safety net between a logged-in admin and any ejabberdctl subcommand this app happens to call. Acceptable for the same reason as the read path's IP-based trust: a dedicated, single-purpose observatory control host with one admin identity, not a shared or multi-tenant one.


Package management

The Packages page (/packages/) lists every installed pyobs-* package (plus anything extra listed in PYOBS_MANAGED_PACKAGES) alongside its latest release on PyPI, and lets you update any of them with one click. It always reflects pip's own view of the environment PYOBS_EXEC runs in (via the sibling pip next to it) — nothing here is invented or cached. The fleet-wide Overview page (/overview/) additionally shows a package-version matrix across every configured host, so a package that's drifted out of sync on one host is easy to spot.

PYOBS_MANAGED_PACKAGES

pip never remembers how a package was originally installed — only what's currently there — so a bare pip install --upgrade pyobs-core would silently drop a [full] extra forever, and a package that isn't on PyPI at all has nothing for the Packages page to even find. PYOBS_MANAGED_PACKAGES fills in what pip's own installed-environment metadata can't recover:

PYOBS_MANAGED_PACKAGES= [
"pyobs-core[full]", # keep using this extra on every future upgrade"my-custom-driver", # a non-"pyobs"-prefixed package, shown/managed alongside pyobs-*"pyobs-iagvt[gui] @ git+ssh://git@gitlab.example.org/iagvt/pyobs-iagvt.git", # git-installed
]
  • Extras — list the full name[extra] spec and future updates keep using it, instead of reverting to a bare install.
  • Non-pyobs-prefixed packages — a bare name makes it show up on the Packages page and be upgradable through it too.
  • Git/URL-installed packages — a PEP 508 direct reference (name[extras] @ <url>) for a package that isn't published on PyPI. The Packages page skips the (futile) PyPI version check for these and shows a Reinstall action instead, which just re-runs pip install --upgrade <spec> to pick up whatever's newest at that URL/ref.

Malformed entries are skipped rather than raising, so a typo here can't break the whole page.

Installing a private git-hosted package

For a private repository (e.g. an institute-internal driver like pyobs-iagvt above), pip running non-interactively needs its own credentials — an SSH deploy key is the recommended way:

  1. Generate a passwordless key for the OS user that actually runs pyobs-web-admin's own process (gunicorn/the systemd service user — not necessarily the pyobs service account, since the Packages page's pip install inherits this process's environment, not pyobs's):
    ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N ""
  2. Add the public key as a GitLab Deploy Key on the private project (Settings → Repository → Deploy keys), read-only access is enough. Scoped to just that repo and revocable independently of any person's account — prefer this over a personal SSH key.
  3. Pre-seed known_hosts for that user, so the first non-interactive install doesn't hang waiting on a host-key prompt:
    ssh-keyscan gitlab.example.org >>~/.ssh/known_hosts
  4. Use git+ssh://, not git+https://, in PYOBS_MANAGED_PACKAGES:
    PYOBS_MANAGED_PACKAGES= [
    "pyobs-iagvt[gui] @ git+ssh://git@gitlab.example.org/iagvt/pyobs-iagvt.git",
    ]
  5. In hub mode, repeat this on every host that's expected to manage the package — package updates run on whichever host is currently active, not just the hub, so each of them needs its own copy of the key (or the same key deployed to all of them).

An SSH key is preferred over embedding a GitLab Deploy Token in an HTTPS URL: the token would end up as a literal pip install command-line argument, visible to any other local user via ps aux for the few seconds the install runs. An SSH key file avoids that exposure.


How modules are managed

  • Discovery — all *.yaml files in PYOBS_CONFIG_DIR (excluding *.shared.yaml) are treated as modules. *.shared.yaml files are listed separately as shared configs.
  • Creating a module — the "New module" button writes a fresh <name>.yaml with a minimal starter (class: key only); PYOBS_CONFIG_DIR is created automatically if it doesn't exist yet.
  • Activate / Deactivate — deactivating a module renames its config from name.yaml to _name.yaml (stopping it first if running); activating renames it back. Deactivated modules are excluded from Start All and Restart All.
  • Start — runs pyobs --pid-file <run>/<name>.pid --log-file <log>/<name>.log --log-level <level> <config>. pyobs daemonises itself via python-daemon. If the effective log backend is "journald" (see below), --syslog is passed instead of --log-file — pyobs then logs directly to the systemd journal, tagged SYSLOG_IDENTIFIER=pyobs and PYOBS_MODULE=<name>.
  • Stop — sends SIGTERM to the PID in the PID file; falls back to SIGKILL after 5 s.
  • Restart — stop followed by start.
  • Status — checks whether the process with the stored PID is alive (os.kill(pid, 0)).
  • Resource usage — uptime, CPU %, and RSS memory read via psutil on every status poll.
  • Logs — read from PYOBS_LOG_DIR's flat files by default, or from the systemd journal via journalctl if the effective log backend is "journald"; the log viewer and per-level counts work identically either way. The effective backend is PYOBS_LOG_BACKEND if set explicitly, otherwise auto-detected from pyobsd's own config file (~/.config/pyobs.yaml, /etc/pyobs.yaml, or /opt/pyobs/storage/pyobs.yaml, first found wins) — the same file pyobsd (pyobs-core's daemon manager) reads to decide whether it starts modules with --syslog, so this can't silently drift out of sync with it. Scrolling a log window to the top auto-loads older entries via journalctl's --until, for journald-backed modules only — the file backend's plain tail -n has no seek/offset to page further back with, so it reports nothing older available instead.
  • Log counts — per-level message counts (DEBUG / INFO / WARNING / ERROR / CRITICAL) for the last 24 h, using binary search on the log file to avoid reading the whole file.

Project layout

pyobs_web_admin/
settings.py Django project settings
local_settings.py.example Template for local overrides (not committed)
urls.py URL config
modules/
services.py All pyobs process and filesystem logic
views.py HTML pages + JSON API endpoints
proxy.py HTTP client for hub → remote host calls
ejabberd.py mod_http_api (status) + ejabberdctl (user management) client
middleware.py Login-required redirect + hub token auth
context_processors.py
deploy/
pyobs-web-admin.service systemd unit file
ejabberdctl-sudo.sh sudo wrapper for EJABBERDCTL -- see ejabberd user management
templates/
base.html Bootstrap 5 layout with responsive sidebar
modules/
dashboard.html
detail.html
shared_detail.html Config editor for *.shared.yaml files
new_module.html "Create a new module" form
packages.html Package list + Update/Reinstall actions
fleet_overview.html Fleet-wide host summary + package-version matrix
all_logs.html Fleet-wide live log tail
acl_matrix.html Fleet-wide ACL matrix
xmpp_users.html Fleet-wide XMPP account list + write actions
registration/
login.html

About

Web GUI for managing pyobs modules

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

pyobs-web-admin

A web-based administration interface for pyobs, the robotic telescope framework. It lets you start, stop, and restart modules, tail and filter their logs, and view and edit their configuration files — all from a browser.

Dashboard showing modules grouped under Stopped and Deactivated headings, with summary tiles and per-row quick-action buttons

Features

  • Dashboard — sortable list view of all modules with:
    • Running / stopped / total summary counts plus total CPU and RAM
    • Per-module status badge, RAM, CPU, and uptime columns — click any header to sort, reset icon restores default grouping
    • Modules grouped under Running / Stopped / Deactivated headers when sorted by status
    • Warning/error log counts for the last 24 h (highlighted in colour if non-zero)
    • Quick start, restart, stop, and activate/deactivate buttons per module
    • Start All, Restart All, and Stop All bulk actions
    • Inactive modules (prefixed with _) are excluded from bulk start/restart
    • Outdated badge on any running module whose loaded pyobs-* versions lag the installed ones (parsed from pyobs-core's startup "Loaded pyobs packages:" log line — comm-independent, works for comm-less modules too), plus a Restart outdated bulk action that restarts only those
    • Responsive: on small screens the table collapses to status dot + name + log counts + actions
  • Module detail — per-module view with four tabs:
    • Overview — current status, PID, uptime, CPU and memory usage, running pyobs-* package versions (flagged when they lag the installed set), per-level log message counts (last 24 h), XMPP connection state (if enabled), start/restart/stop/activate/deactivate control
    • Logs — live log tail with text filter, time-range filter (set a start date to load all logs since that instant, or click a line to set it), colour-coded by severity, auto-refresh; scrolling to the top auto-loads older entries (journald-backed modules, or file-backed modules once a start date is set)
    • Config — YAML editor with syntax highlighting and colour-coded {include} lines; included shared configs are shown as clickable links
    • ACL — point-and-click editor for the module's acl: block: click to allow/deny known modules, add other callers, toggle enforce/log mode
  • New module — a "+" next to the sidebar's Modules section creates a brand-new <name>.yaml config (a minimal starter with just a class: key) and takes you straight to its Config tab to fill in the rest
  • Shared configs*.shared.yaml config fragments listed in a separate sidebar section with a YAML-highlighted config editor (no start/stop controls)
  • Packages (/packages/) — every installed pyobs-* package (plus anything else listed in PYOBS_MANAGED_PACKAGES) with its installed and latest-PyPI version, and a one-click Update button; git/URL-installed packages get a Reinstall action instead (see Package management)
  • Overview (/overview/) — fleet-wide summary, one row per configured host: reachable or not, running/stopped/total counts, aggregate CPU/RAM, linking into that host's own Dashboard, plus a package-version matrix (one row per pyobs-* package, one column per host) so version drift across the fleet is visible at a glance. Deliberately no bulk or per-module actions — those stay on the per-host Dashboard, since a fleet-wide "Stop All" from one button is a real footgun
  • All Logs (/logs/) — fleet-wide live log tail across every module on every configured host, same filtering and scroll-to-load-older behaviour as a module's own Logs tab
  • ACL Matrix (/acl/) — fleet-wide read-only matrix of which module can call which, merged across every configured host
  • Hub mode — control multiple remote pyobs hosts from a single browser tab; remote hosts are listed in the sidebar and all actions are proxied transparently
  • ejabberd / XMPP status (optional) — dashboard summary tile and per-module connected/not-connected indicator, plus a session/last-seen/registered-account block on each module's own page, for modules with a comm.user in their config — closes the gap between "the process is running" and "the module is actually reachable over XMPP" (see ejabberd integration)
  • ejabberd / XMPP user management (optional, builds on the above) — register, reset password, ban/unban, unregister, and kick XMPP accounts, either from a module's own Overview tab or from a fleet-wide Users page (/xmpp-users/) listing every registered account across every host, cross-referenced against which module(s) use it and which one is actually running. Safe by design for an identity shared across more than one module's comm.user — a password reset writes back to every module sharing it, and destructive actions name which other modules are affected before you confirm (see ejabberd user management)
  • Responsive — works on mobile with a slide-in sidebar
  • No pyobs-core dependency — communicates with pyobs directly via subprocess; no Python imports from pyobs-core
  • Keycloak login (optional) — SSO on top of the default shared admin/password login, with per-person access granted/revoked via Keycloak group membership (see Keycloak login)

Technology

LayerChoice
BackendPython 3.13, Django 6, psutil
WSGI serverGunicorn
FrontendBootstrap 5 (CDN), CodeMirror 5 (CDN), vanilla JS
Package manageruv
AuthShared admin/password login plus optional Keycloak SSO; both share one SQLite db (sessions table for both, User table for Keycloak-linked accounts only)
Hub authPre-shared token in X-Hub-Token header; CSRF bypassed for hub requests

Development setup

git clone https://github.com/pyobs/pyobs-web-admin.git
cd pyobs-web-admin
uv sync
uv run python manage.py migrate
uv run python manage.py runserver

Create pyobs_web_admin/local_settings.py (see Configuration below).


Production setup

1. Install the app

cd /opt/pyobs
git clone https://github.com/pyobs/pyobs-web-admin.git
cd pyobs-web-admin
uv sync

2. Configure

Copy and edit the local settings file:

cp pyobs_web_admin/local_settings.py.example pyobs_web_admin/local_settings.py # or create from scratch$EDITOR pyobs_web_admin/local_settings.py

Minimum required settings for production (see Configuration):

DEBUG=FalseSECRET_KEY="..."# generate belowALLOWED_HOSTS= ["your-hostname-or-ip"]
ADMIN_USERNAME="admin"ADMIN_PASSWORD_HASH="..."# generate belowPYOBS_EXEC="/opt/pyobs/venv/bin/pyobs"

Generate a secret key:

DJANGO_SETTINGS_MODULE=pyobs_web_admin.settings uv run python -c \
"from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"

Generate a password hash:

DJANGO_SETTINGS_MODULE=pyobs_web_admin.settings uv run python -c \
"from django.contrib.auth.hashers import make_password; print(make_password('yourpassword'))"

3. Install the systemd service

cp deploy/pyobs-web-admin.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now pyobs-web-admin

Check that it started:

systemctl status pyobs-web-admin
journalctl -u pyobs-web-admin -f

4. Configure nginx

Add a site configuration that proxies to gunicorn on port 8765:

server{listen80;server_name your-hostname-or-ip;location / {proxy_passhttp://127.0.0.1:8765;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;proxy_set_header X-Forwarded-Proto $scheme;}}

If you add TLS (strongly recommended for any non-private network), also set in local_settings.py:

SESSION_COOKIE_SECURE=TrueCSRF_COOKIE_SECURE=True

Configuration

All runtime configuration lives in pyobs_web_admin/local_settings.py, which is not committed to version control. A full reference:

# DjangoSECRET_KEY="..."# required in productionDEBUG=False# set True only in developmentALLOWED_HOSTS= ["*"] # restrict to hostname/IP in production# HTTPS (enable once TLS is in place)# SESSION_COOKIE_SECURE = True# CSRF_COOKIE_SECURE = True# AuthenticationADMIN_USERNAME="admin"ADMIN_PASSWORD_HASH="pbkdf2_sha256$..."# see generation command above# Keycloak login (optional — see Keycloak login section)# PYOBS_AUTH = {# "SERVER_URL": "https://keycloak.example.org",# "REALM": "pyobs",# "CLIENT_ID": "web-admin",# "CLIENT_SECRET": "",# "REDIRECT_URI": "https://your.domain.com/accounts/keycloak/callback/",# "POST_LOGOUT_REDIRECT_URI": "https://your.domain.com/",# # Optional one-click IdP login: IDP_HINT is passed to Keycloak as kc_idp_hint (skips its# # login/IdP-selection page, going straight to that identity provider, e.g. GWDG SSO);# # IDP_LABEL is the button label on the login page. Leave both unset for the plain# # single "Log in with Keycloak" button.# "IDP_HINT": "gwdg",# "IDP_LABEL": "GWDG",# "USER_RESOLVER": "pyobs_web_admin.authentication.keycloak.resolve_user",# # Only members of this Keycloak group are authorized to use web-admin - create it (and add# # people to it) in the Keycloak admin console before anyone logs in.# "REQUIRED_GROUPS": ["/pyobs-web-admin"],# # Keycloak-independent kill switch, layered on top of REQUIRED_GROUPS above: an admin can# # deactivate a specific local User (Django admin) regardless of their Keycloak group# # membership.# "ENFORCE_LOCAL_ACTIVE": True,# }# pyobs pathsPYOBS_EXEC="/opt/pyobs/venv/bin/pyobs"# path to the pyobs executablePYOBS_CONFIG_DIR="/opt/pyobs/config"# directory containing *.yaml module configsPYOBS_LOG_DIR="/opt/pyobs/log"# directory containing *.log filesPYOBS_RUN_DIR="/opt/pyobs/run"# directory for PID filesPYOBS_LOG_LEVEL="info"# log level passed to pyobs on startPYOBS_LOG_BACKEND=None# None (default): auto-detect from pyobsd's own# config; "file" or "journald" to override. If# "journald": the account running pyobs-web-admin# needs journal read access — `usermod -aG# systemd-journal <account>` (preferred over `adm`,# which grants broader log access than needed) —# otherwise logs come back silently empty, no error.# Packages page (optional — see Package management section)PYOBS_MANAGED_PACKAGES= [] # e.g. ["pyobs-core[full]", "my-custom-driver",# "pyobs-iagvt[gui] @ git+ssh://git@gitlab.example.org/iagvt/pyobs-iagvt.git"]# Hub (optional — see Hub mode section)HUB_TOKEN=""# deprecated, single-token form -- see HUB_CLIENTSHUB_CLIENTS= [] # named tokens for external callers (hub, scripts, ...)HUB_HOSTS= [] # remote hosts this instance controls# ejabberd integration (optional — see ejabberd integration section)EJABBERD_ENABLED=False# show XMPP status on the dashboard/module pagesEJABBERD_HOST="localhost"# which host runs ejabberd -- "localhost" or a HUB_HOSTS nameEJABBERD_DOMAIN=""# the XMPP vhost ejabberd servesEJABBERD_API_URL="http://127.0.0.1:5281/api"# mod_http_api base URLEJABBERDCTL="ejabberdctl"# required for user management writes (register/# reset/ban/unregister/kick); also a read fallback# if EJABBERD_API_URL can't be reached -- see# ejabberd user management section below

Keycloak login

The shared admin/password login above is the default and always available — Keycloak is an additive option on top of it, not a replacement, so it stays working as a break-glass fallback if Keycloak itself is unreachable. Unlike the shared account, a Keycloak-linked person's access is granted or revoked individually via Keycloak group membership, without touching anyone else's access or handing out the shared password.

Enabling it

  1. Register a client for this instance in your Keycloak realm (redirect URI https://your.domain.com/accounts/keycloak/callback/, post-logout redirect URI https://your.domain.com/).
  2. Add the PYOBS_AUTH block shown in Configuration to local_settings.py, filling in SERVER_URL, CLIENT_ID, and CLIENT_SECRET. REQUIRED_GROUPS defaults to ["/pyobs-web-admin"] — create that group in the realm and add whoever should have access to it (Keycloak admin console) before anyone tries to log in, or every login is refused as "not authorized".
  3. Run manage.py migrate (creates db.sqlite3, used for the Keycloak-linked User table and, as of this version, sessions too — SESSION_ENGINE is now database-backed rather than signed cookies, since a Keycloak session carries a refresh token that shouldn't be serialized into the browser. This applies to every session app-wide, including the shared admin/password login's — it's no longer fully DB-free, but it's the one Django setting shared by both login paths, so it can't be split per-path). Expired sessions aren't purged automatically — run manage.py clearsessions periodically (e.g. a daily cron/systemd timer) or django_session grows unbounded in a long-lived deployment.

The login page then shows a "Log in with Keycloak" button. A first-time Keycloak login mints a local User (linked to an existing one by email, falling back to username, if either matches), active immediately — whether they're actually let in is decided by REQUIRED_GROUPS above, not by anything in this app's own database.

Setting IDP_HINT (plus IDP_LABEL for the button text) switches the login page to two buttons: "Log in with <IDP_LABEL>" goes straight to that identity provider (skipping Keycloak's own login/IdP-selection page), and "Log in with local Keycloak account" keeps the local-account path reachable for anyone without that IdP's identity. Leave IDP_HINT unset for the single-button behavior above.

Granting or revoking a person's access

This is now done in the Keycloak admin console, not this app's Django admin: add or remove the person from the /pyobs-web-admin group (or whatever REQUIRED_GROUPS is set to). The change takes effect at their next login. Django's /admin/ site is still mounted (the shared admin/password account and any other Django superuser can still get in there for other reasons), with the ENFORCE_LOCAL_ACTIVE setting above (recommended, and the example config's default), toggling a Keycloak-linked account's Active flag there still works too, as an additional, Keycloak-independent kill switch on top of the group gate. Leave ENFORCE_LOCAL_ACTIVE unset if you'd rather Keycloak group membership be the only thing that matters.

The shared admin/password account works at /admin/ directly (no prior visit to /login/ needed first) because manage.py migrate syncs a matching superuser User automatically — see pyobs_web_admin.authentication.admin_sync, and Configuration for the ADMIN_USERNAME/ADMIN_PASSWORD_HASH settings that drive it.


Hub mode

pyobs-web-admin can act as a hub to control multiple remote pyobs hosts from a single browser session. When remote hosts are configured, a Hosts section appears at the top of the sidebar. Clicking a host switches the active context — all subsequent actions (start/stop/logs/config) are transparently proxied to that host's API.

Setting up the hub

On the hub (the machine you browse to), add to local_settings.py:

HUB_HOSTS= [
{"name": "obs1", "url": "http://obs1:8765", "token": "shared-secret"},
{"name": "obs2", "url": "http://obs2:8765", "token": "another-secret"},
]

On each remote host, give it a named client entry matching the token the hub sends:

HUB_CLIENTS= [
{"name": "hub", "token": "shared-secret"}, # must match HUB_HOSTS' token above
]

The hub — or any other external caller, such as a script calling the API directly — authenticates to remote instances via an X-Hub-Token header. Remote instances check that header against every entry in HUB_CLIENTS; a match bypasses the normal browser session/CSRF check, so the caller can invoke the API without a login session. Give each caller its own named entry so it can be revoked or rotated independently. Tokens are plain pre-shared strings — use long random values and keep them secret.

The older HUB_TOKEN setting (a single unnamed token) still works for backwards compatibility, equivalent to a HUB_CLIENTS entry named "default".

There's no separate "external" API — every endpoint the hub calls (start/stop, logs, config, ACL, packages, ejabberd user management, ...) is the same plain JSON API any X-Hub-Token-authenticated caller can use directly, e.g. from a script. See docs/source/api_endpoints.rst for the full endpoint reference.


ejabberd integration

If the ejabberd server pyobs's XMPP comm layer connects through runs on the same host, pyobs-web-admin can show live connection state alongside the process status it already tracks — closing the gap between "the module's process is running" and "the module is actually reachable over XMPP." When enabled: the dashboard gets a summary tile (how many of this installation's own modules, identified by their config's comm.user, are currently XMPP-connected) plus a small icon per module row; each module's own page gets a session/last-seen/registered-account block in its Overview tab. A module with no comm: block in its config (e.g. a pure HTTP module) is skipped entirely — there's nothing for it to connect to.

Enabling it

In local_settings.py:

EJABBERD_ENABLED=TrueEJABBERD_HOST="localhost"# or a HUB_HOSTS name, if ejabberd runs on a different hostEJABBERD_DOMAIN="your-xmpp-domain"# the vhost ejabberd serves, e.g. "pyobs.example.org"EJABBERD_API_URL="http://127.0.0.1:5281/api"

If EJABBERD_HOST names a HUB_HOSTS entry instead of "localhost", every instance in the fleet transparently proxies its ejabberd queries to that one host — only that host needs EJABBERD_API_URL actually pointed at a real ejabberd; every other instance just needs EJABBERD_HOST set to its name.

ejabberd-side configuration

This talks to ejabberd's HTTP admin API (mod_http_api), not ejabberdctl — about 50–60x faster per call, since it hits the already-running node directly instead of spawning a new Erlang VM per invocation (ejabberdctl is used as a fallback only if EJABBERD_API_URL can't be reached). Add this to ejabberd's own config:

listen:
-
port: 5281ip: "127.0.0.1"# loopback only -- see security note belowmodule: ejabberd_httprequest_handlers:
/api: mod_http_api # add this to an *existing* listener's request_handlers if one's# already on this port (e.g. for BOSH/WebSocket) -- ejabberd only# allows one listener per portmodules:
mod_http_api: {}api_permissions:
"console commands":
from: [ejabberd_ctl]who: allwhat: "*""pyobs-web-admin readonly":
from: [mod_http_api]who:
access:
allow:
- acl: loopbackwhat:
- "status"
- "stats"
- "connected_users_info"
- "registered_users"
- "user_sessions_info"
- "get_last"
- "check_account"

Reload ejabberd's config after adding this (ejabberdctl reload_config, or a restart if that doesn't pick up the new listener). The what: list is a deliberate whitelist — leave it as-is; mod_http_api can also expose account-management commands (register/unregister/change_password) that should never be reachable here.

Security note. Access is IP-based, not credential-based — any request from loopback is trusted, no password or token is involved. This blocks the network (a request from outside the host is rejected), but not other processes on the same machine, which get the same access pyobs-web-admin does. That's an accepted tradeoff for a dedicated, single-purpose observatory control host, not a shared one — reassess if that's not your deployment.


ejabberd user management

Builds on ejabberd integration above (requires EJABBERD_ENABLED = True) to add write actions on top of the read-only status it already shows: register, reset password, ban / unban, unregister, and kick (force-disconnect one session without touching the account) for any module's comm.user. Reversible actions get a single confirmation dialog; unregister — the one action with no undo — requires retyping the account's username first. An identity shared by more than one module's comm.user (a real, supported scenario — e.g. a test copy of a module reusing a real module's identity) is handled safely: a password reset writes the new password back into every module sharing it, not just the one the action was triggered from, and destructive actions (ban/unregister) name every other module affected before you can confirm.

This surfaces in two places:

  • The module detail page's existing ejabberd block (Overview tab) — register when the account isn't registered yet, reset/ban/unregister when it is.
  • A dedicated Users page (/xmpp-users/), linked from the sidebar whenever EJABBERD_ENABLED = True — every registered XMPP account across every configured host, in one fleet-wide, mobile-friendly list. Unlike the module page, this also covers accounts with no owning module at all (e.g. admin) via a manual "register account" form, and accounts shared by more than one module show a status dot marking which one is actually the connected session.

Transport: ejabberdctl, not mod_http_api

Unlike the read path above, writes always go through the ejabberdctl CLI, never mod_http_api — a write's cost is dominated by a human clicking a confirmation dialog, not command latency, so the ~50–60x speed advantage HTTP has for reads doesn't matter here. This also means no api_permissions change is needed for user management specifically — but see the security note below, since ejabberdctl itself is far more powerful than the read-only HTTP whitelist above.

ejabberdctl normally refuses to run as anything other than root or the ejabberd system user ("can only be run by root or the user ejabberd"), which only matters here since writes always need it (the read path mostly avoids it via mod_http_api). If pyobs-web-admin runs as its own service user (e.g. pyobs, per deploy/pyobs-web-admin.service), give that user a narrowly-scoped passwordless sudo rule for just this one binary:

# /etc/sudoers.d/pyobs-web-admin-ejabberdctl
pyobs ALL=(root) NOPASSWD: /usr/sbin/ejabberdctl

(adjust the username and binary path for your setup — check with which ejabberdctl), then point EJABBERDCTL in local_settings.py at the wrapper script committed at the repo root:

EJABBERDCTL="/opt/pyobs/pyobs-web-admin/ejabberdctl-sudo.sh"

ejabberdctl-sudo.sh is a two-line wrapper (exec sudo -n ejabberdctl "$@") — the -n flag makes sudo fail fast instead of hanging on a password prompt if the sudoers rule above isn't in place. Not needed at all if pyobs-web-admin already runs as root or ejabberd.

Security note. This is a materially bigger trust step than the read-only integration above: ejabberdctl can do anything an ejabberd administrator can do, not just the small read-only whitelist mod_http_api's api_permissions enforces. There is no OS-level or ejabberd-level restriction narrowing what the sudo rule allows beyond "run ejabberdctl as root at all" — this app's own tiered confirmation dialogs are the only safety net between a logged-in admin and any ejabberdctl subcommand this app happens to call. Acceptable for the same reason as the read path's IP-based trust: a dedicated, single-purpose observatory control host with one admin identity, not a shared or multi-tenant one.


Package management

The Packages page (/packages/) lists every installed pyobs-* package (plus anything extra listed in PYOBS_MANAGED_PACKAGES) alongside its latest release on PyPI, and lets you update any of them with one click. It always reflects pip's own view of the environment PYOBS_EXEC runs in (via the sibling pip next to it) — nothing here is invented or cached. The fleet-wide Overview page (/overview/) additionally shows a package-version matrix across every configured host, so a package that's drifted out of sync on one host is easy to spot.

PYOBS_MANAGED_PACKAGES

pip never remembers how a package was originally installed — only what's currently there — so a bare pip install --upgrade pyobs-core would silently drop a [full] extra forever, and a package that isn't on PyPI at all has nothing for the Packages page to even find. PYOBS_MANAGED_PACKAGES fills in what pip's own installed-environment metadata can't recover:

PYOBS_MANAGED_PACKAGES= [
"pyobs-core[full]", # keep using this extra on every future upgrade"my-custom-driver", # a non-"pyobs"-prefixed package, shown/managed alongside pyobs-*"pyobs-iagvt[gui] @ git+ssh://git@gitlab.example.org/iagvt/pyobs-iagvt.git", # git-installed
]
  • Extras — list the full name[extra] spec and future updates keep using it, instead of reverting to a bare install.
  • Non-pyobs-prefixed packages — a bare name makes it show up on the Packages page and be upgradable through it too.
  • Git/URL-installed packages — a PEP 508 direct reference (name[extras] @ <url>) for a package that isn't published on PyPI. The Packages page skips the (futile) PyPI version check for these and shows a Reinstall action instead, which just re-runs pip install --upgrade <spec> to pick up whatever's newest at that URL/ref.

Malformed entries are skipped rather than raising, so a typo here can't break the whole page.

Installing a private git-hosted package

For a private repository (e.g. an institute-internal driver like pyobs-iagvt above), pip running non-interactively needs its own credentials — an SSH deploy key is the recommended way:

  1. Generate a passwordless key for the OS user that actually runs pyobs-web-admin's own process (gunicorn/the systemd service user — not necessarily the pyobs service account, since the Packages page's pip install inherits this process's environment, not pyobs's):
    ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N ""
  2. Add the public key as a GitLab Deploy Key on the private project (Settings → Repository → Deploy keys), read-only access is enough. Scoped to just that repo and revocable independently of any person's account — prefer this over a personal SSH key.
  3. Pre-seed known_hosts for that user, so the first non-interactive install doesn't hang waiting on a host-key prompt:
    ssh-keyscan gitlab.example.org >>~/.ssh/known_hosts
  4. Use git+ssh://, not git+https://, in PYOBS_MANAGED_PACKAGES:
    PYOBS_MANAGED_PACKAGES= [
    "pyobs-iagvt[gui] @ git+ssh://git@gitlab.example.org/iagvt/pyobs-iagvt.git",
    ]
  5. In hub mode, repeat this on every host that's expected to manage the package — package updates run on whichever host is currently active, not just the hub, so each of them needs its own copy of the key (or the same key deployed to all of them).

An SSH key is preferred over embedding a GitLab Deploy Token in an HTTPS URL: the token would end up as a literal pip install command-line argument, visible to any other local user via ps aux for the few seconds the install runs. An SSH key file avoids that exposure.


How modules are managed

  • Discovery — all *.yaml files in PYOBS_CONFIG_DIR (excluding *.shared.yaml) are treated as modules. *.shared.yaml files are listed separately as shared configs.
  • Creating a module — the "New module" button writes a fresh <name>.yaml with a minimal starter (class: key only); PYOBS_CONFIG_DIR is created automatically if it doesn't exist yet.
  • Activate / Deactivate — deactivating a module renames its config from name.yaml to _name.yaml (stopping it first if running); activating renames it back. Deactivated modules are excluded from Start All and Restart All.
  • Start — runs pyobs --pid-file <run>/<name>.pid --log-file <log>/<name>.log --log-level <level> <config>. pyobs daemonises itself via python-daemon. If the effective log backend is "journald" (see below), --syslog is passed instead of --log-file — pyobs then logs directly to the systemd journal, tagged SYSLOG_IDENTIFIER=pyobs and PYOBS_MODULE=<name>.
  • Stop — sends SIGTERM to the PID in the PID file; falls back to SIGKILL after 5 s.
  • Restart — stop followed by start.
  • Status — checks whether the process with the stored PID is alive (os.kill(pid, 0)).
  • Resource usage — uptime, CPU %, and RSS memory read via psutil on every status poll.
  • Logs — read from PYOBS_LOG_DIR's flat files by default, or from the systemd journal via journalctl if the effective log backend is "journald"; the log viewer and per-level counts work identically either way. The effective backend is PYOBS_LOG_BACKEND if set explicitly, otherwise auto-detected from pyobsd's own config file (~/.config/pyobs.yaml, /etc/pyobs.yaml, or /opt/pyobs/storage/pyobs.yaml, first found wins) — the same file pyobsd (pyobs-core's daemon manager) reads to decide whether it starts modules with --syslog, so this can't silently drift out of sync with it. Scrolling a log window to the top auto-loads older entries via journalctl's --until, for journald-backed modules only — the file backend's plain tail -n has no seek/offset to page further back with, so it reports nothing older available instead.
  • Log counts — per-level message counts (DEBUG / INFO / WARNING / ERROR / CRITICAL) for the last 24 h, using binary search on the log file to avoid reading the whole file.

Project layout

pyobs_web_admin/
settings.py Django project settings
local_settings.py.example Template for local overrides (not committed)
urls.py URL config
modules/
services.py All pyobs process and filesystem logic
views.py HTML pages + JSON API endpoints
proxy.py HTTP client for hub → remote host calls
ejabberd.py mod_http_api (status) + ejabberdctl (user management) client
middleware.py Login-required redirect + hub token auth
context_processors.py
deploy/
pyobs-web-admin.service systemd unit file
ejabberdctl-sudo.sh sudo wrapper for EJABBERDCTL -- see ejabberd user management
templates/
base.html Bootstrap 5 layout with responsive sidebar
modules/
dashboard.html
detail.html
shared_detail.html Config editor for *.shared.yaml files
new_module.html "Create a new module" form
packages.html Package list + Update/Reinstall actions
fleet_overview.html Fleet-wide host summary + package-version matrix
all_logs.html Fleet-wide live log tail
acl_matrix.html Fleet-wide ACL matrix
xmpp_users.html Fleet-wide XMPP account list + write actions
registration/
login.html

About

Web GUI for managing pyobs modules

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages