docs: update FreeCAD MCP README to document dual-service architecture

This commit is contained in:
2026-05-31 10:13:43 -04:00
parent 3893b91a55
commit 77a82b4784
2 changed files with 208 additions and 137 deletions

View File

@@ -1,62 +1,104 @@
# FreeCAD Robust MCP Server — Ansible Deployment # FreeCAD Robust MCP Server — Ansible Deployment
Deploys the [FreeCAD Robust MCP Server](https://pypi.org/project/freecad-robust-mcp/) Deploys the [FreeCAD Robust MCP Server](https://pypi.org/project/freecad-robust-mcp/)
to Caliban as a systemd service with HTTP transport. to Caliban as **two** systemd services:
- **`freecad-mcp.service`** — the MCP server (HTTP/streamable-http transport on
`:22061`), pip-installed into a venv under `/srv/freecad-mcp`, run as the
hardened `harper` service user.
- **`freecad-mcp-bridge.service`** — FreeCAD itself running in **GUI** mode on
the XRDP desktop (display `:10`), exposing the XML-RPC bridge on
`localhost:9875`. Run as `robert` (the `principal_user`, who owns the X
session), from source staged as a tarball.
The MCP server connects to the bridge over `localhost:9875`; the bridge in turn
drives FreeCAD. The two halves rendezvous only on that local port.
## Architecture ## Architecture
``` ```
┌─────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────
│ caliban.incus │ │ caliban.incus │
│ │ │ │
│ ┌──────────────────────┐ │ │ ┌──────────────────────┐ │
│ │ freecad-mcp.service │ │ │ │ freecad-mcp.service │ │
│ │ (streamable-http) │◄─── :22061 ──────────┤◄── MCP Client │ │ (streamable-http) │◄─── :22061 ────────────────────┤◄── MCP Client
│ │ venv + PyPI package │ │ │ venv + PyPI package │ (user: harper, hardened)
│ └─────────────────────┘ │ │ └─────────────────────┘
│ xmlrpc localhost:9875
│ │ xmlrpc :9875 │
│ ▼ │ │ ▼ │
│ ┌────────────────────── │ ┌──────────────────────────────┐
│ │ FreeCAD (future) │ │ freecad-mcp-bridge.service
│ │ XML-RPC server │ │ │ /usr/bin/freecad (GUI) DISPLAY=:10 (XRDP)
└──────────────────────┘ │ startup_bridge.py user: robert
└─────────────────────────────────────────────────┘ │ │ XML-RPC :9875 / socket :9876│ │
│ └──────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
``` ```
## FreeCAD bridge required for tool calls ## Two services, two users (by design)
The service starts and answers the MCP `initialize` handshake **without** FreeCAD | Service | User | Transport / port | Hardened | Needs X |
running — the XML-RPC connection to FreeCAD is only attempted on the first CAD | ---------------------------- | -------- | ----------------------- | -------- | ------- |
tool call (lazy connect). So a green Ansible healthcheck means "transport up", | `freecad-mcp.service` | `harper` | HTTP `:22061` | yes | no |
**not** "FreeCAD reachable". | `freecad-mcp-bridge.service` | `robert` | XML-RPC `:9875` (+ 9876) | no | yes (`:10`) |
Actual CAD tool calls require FreeCAD running with the Robust MCP Bridge The bridge runs as `robert` because it attaches to the standard XRDP display
workbench started, listening on XML-RPC `localhost:9875`. Standing up that bridge `:10`, owned by `robert` with Xauthority `/home/robert/.Xauthority`. It cannot
(GUI or headless) on Caliban is a separate step from getting this service to be hardened like the server unit — it needs the user's X session and home.
boot.
## How the bridge starts (no `just`/`mise`/`uv` needed)
The bridge runs **inside FreeCAD's own Python interpreter** via
`/usr/bin/freecad <startup_bridge.py>`. The README "Option B"
(`just freecad::run-gui`) in the upstream repo is only a launcher wrapper that
locates FreeCAD and runs that same script — `just`, `mise`, and `uv` are not
required for the bridge.
The bridge scripts are **not** shipped in the pip wheel (it packages only
`src/freecad_mcp`). They live in the git repo under
`freecad/RobustMCPBridge/freecad_mcp_bridge/`, so the bridge is delivered
separately as a staged tarball (see Deployment below).
> **GUI vs headless:** We run GUI mode to keep the GUI-only tools (screenshots,
> object color, visibility, camera). `freecadcmd <blocking_bridge.py>` would run
> headless without those tools — not used here.
> **Python version:** FreeCAD 1.0.0 on Caliban uses the system Python (3.13),
> not a bundled 3.11. The upstream ABI-match warning applies only to *embedded*
> mode (importing `FreeCAD` into an external interpreter). We run scripts inside
> FreeCAD and the bridge is pure stdlib, so the version mismatch is a non-issue.
## Lazy connect: a green server healthcheck is not "FreeCAD reachable"
`freecad-mcp.service` starts and answers the MCP `initialize` handshake **without**
the bridge running — the XML-RPC connection to FreeCAD is only attempted on the
first CAD tool call. So the server playbook's `initialize` check proves
"transport up", **not** "FreeCAD reachable". The bridge playbook's validation
(below) is what proves the full chain.
## Prerequisites ## Prerequisites
- Caliban host in Ansible inventory (already exists in Ouranos) - Caliban host in the `freecad_mcp` inventory group (already configured).
- Python 3.11+ on Caliban (already present) - `python3` + `python3-venv` on Caliban (installed by the playbook).
- `freecad` package on Caliban (installed by the playbook).
- The XRDP display `:10` running, owned by `robert` (the standard Ouranos RDP
desktop — not configured here, it is always present).
## Deployment ## Files in this role
### 1. Copy playbook files to Ouranos
Copy the contents of this directory into your Ouranos repo:
``` ```
ansible/freecad_mcp/ ansible/freecad_mcp/
├── deploy.yml ├── deploy.yml # Two plays: MCP server + GUI bridge
├── .env.j2 ├── stage.yml # Clones the fork + builds the bridge tarball
── freecad-mcp.service.j2 ── .env.j2 # MCP server env (FREECAD_* vars)
├── freecad-mcp.service.j2 # MCP server unit (harper, hardened)
└── freecad-mcp-bridge.service.j2 # FreeCAD GUI bridge unit (robert, :10)
``` ```
### 2. Add inventory group ## Inventory
Add to `ansible/inventory/hosts`: `ansible/inventory/hosts` (already present):
```yaml ```yaml
freecad_mcp: freecad_mcp:
@@ -64,9 +106,7 @@ freecad_mcp:
caliban.incus: caliban.incus:
``` ```
### 3. Add host variables Host vars in `ansible/inventory/host_vars/caliban.incus.yml`:
Add to `ansible/inventory/host_vars/caliban.incus.yml`:
```yaml ```yaml
# FreeCAD Robust MCP Server # FreeCAD Robust MCP Server
@@ -74,60 +114,93 @@ freecad_mcp_user: harper
freecad_mcp_group: harper freecad_mcp_group: harper
freecad_mcp_directory: /srv/freecad-mcp freecad_mcp_directory: /srv/freecad-mcp
freecad_mcp_port: 22061 freecad_mcp_port: 22061
freecad_mcp_version: "0.5.0" freecad_mcp_xmlrpc_port: 9875
freecad_mcp_socket_port: 9876
# FreeCAD MCP Bridge (GUI, runs as principal_user on the XRDP display)
freecad_mcp_bridge_directory: "/home/{{ principal_user }}/freecad-mcp-bridge"
freecad_mcp_bridge_display: ":10"
``` ```
Update `services` list: Group vars in `ansible/inventory/group_vars/all/vars.yml`:
```yaml ```yaml
services: freecad_mcp_version: 0.6.1 # PyPI version pin (server install)
- alloy freecad_mcp_git_ref: "main" # fork ref for BOTH the pip install and the staged bridge tarball
- caliban
- docker
- freecad_mcp
- kernos
``` ```
### 4. Run the playbook ## Deployment
The bridge source is delivered via the staging pattern: cloned on the Ansible
controller, packed with `git archive`, and unpacked on the host (no deploy keys
on Caliban). Stage first, then deploy:
```bash ```bash
cd ~/git/ouranos/ansible
source ~/env/ouranos/bin/activate
# 1. Build the bridge tarball on the controller (~/rel/freecad_mcp_bridge_<ref>.tar)
ansible-playbook freecad_mcp/stage.yml
# 2. Deploy the MCP server (idempotent) + the GUI bridge
ansible-playbook freecad_mcp/deploy.yml ansible-playbook freecad_mcp/deploy.yml
``` ```
`stage.yml` clones/pulls the fork into `~/gh/freecad-addon-robust-mcp-server` at
`freecad_mcp_git_ref` and `git archive`s it to
`~/rel/freecad_mcp_bridge_<ref>.tar`. `deploy.yml` unpacks that into
`~robert/freecad-mcp-bridge` and points the bridge unit at
`freecad/RobustMCPBridge/freecad_mcp_bridge/startup_bridge.py`.
## Upgrading ## Upgrading
To upgrade to a new PyPI version, update `freecad_mcp_version` in host_vars - **MCP server:** bump `freecad_mcp_version` (PyPI) and/or `freecad_mcp_git_ref`
and re-run the playbook. The pip install task will detect the version change in group vars, re-run `deploy.yml`. The pip task detects the change and the
and the handler will restart the service. handler restarts `freecad-mcp`.
- **Bridge:** re-run `stage.yml` (rebuilds the tarball from the latest fork
ref), then `deploy.yml`. The `unarchive` change notifies the
`restart freecad-mcp-bridge` handler.
## Validation ## Validation
The playbook automatically validates the deployment by: The playbooks validate automatically:
1. Waiting for the HTTP port to become available - **Server play:** waits for `:22061`, sends an MCP `initialize` request to
2. Sending an MCP `initialize` JSON-RPC request to `/mcp` `/mcp`, expects HTTP 200 (transport-level only — see lazy-connect note above).
3. Verifying a 200 response - **Bridge play:** waits for `:9875`, then calls the bridge's XML-RPC `execute`
with `_result_ = bool(FreeCAD.GuiUp)` and asserts the result is `True`
proving FreeCAD is up **in GUI mode**, end to end.
You can also manually test: Manual checks:
```bash ```bash
# Transport up (no FreeCAD needed):
curl -X POST http://caliban.incus:22061/mcp \ curl -X POST http://caliban.incus:22061/mcp \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"1.0.0"}}}' -d '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"1.0.0"}}}'
# Bridge listening + in GUI mode:
ss -ltnp | grep 9875
python3 -c 'import xmlrpc.client as x; print(x.ServerProxy("http://localhost:9875", allow_none=True).execute("_result_ = bool(FreeCAD.GuiUp)"))'
``` ```
## Service Management ## Service Management
```bash ```bash
# On Caliban # MCP server
sudo systemctl status freecad-mcp sudo systemctl status freecad-mcp
sudo systemctl restart freecad-mcp sudo systemctl restart freecad-mcp
sudo journalctl -u freecad-mcp -f sudo journalctl -u freecad-mcp -f
# FreeCAD GUI bridge
sudo systemctl status freecad-mcp-bridge
sudo systemctl restart freecad-mcp-bridge
sudo journalctl -u freecad-mcp-bridge -f
``` ```
## Security ## Security
The systemd service runs with hardened settings: The **MCP server** unit (`freecad-mcp.service`, user `harper`) is hardened:
| Setting | Value | Rationale | | Setting | Value | Rationale |
|---------|-------|-----------| |---------|-------|-----------|
@@ -137,4 +210,15 @@ The systemd service runs with hardened settings:
| `PrivateTmp` | `true` | Isolated /tmp namespace | | `PrivateTmp` | `true` | Isolated /tmp namespace |
| `ReadWritePaths` | `/srv/freecad-mcp` | Only app directory is writable | | `ReadWritePaths` | `/srv/freecad-mcp` | Only app directory is writable |
The **bridge** unit (`freecad-mcp-bridge.service`, user `robert`) is **not**
hardened: FreeCAD GUI needs the user's X session, `.Xauthority`, and FreeCAD
config in the home directory. It binds XML-RPC/socket on `localhost` only.
## Known limitation
The bridge depends on the XRDP `:10` session (owned by `robert`). `Restart=on-failure`
recovers crashes, but **not** loss of the X display — if that session restarts,
restart `freecad-mcp-bridge` afterward. Auto-tying the two is a possible
follow-up.

View File

@@ -13,7 +13,50 @@ Infrastructure-as-Code project managing the **Ouranos Lab** — a development sa
> **DNS Domain**: Incus resolves containers via the `.incus` domain suffix (e.g., `oberon.incus`, `portia.incus`). IPv4 addresses are dynamically assigned — always use DNS names, never hardcode IPs. > **DNS Domain**: Incus resolves containers via the `.incus` domain suffix (e.g., `oberon.incus`, `portia.incus`). IPv4 addresses are dynamically assigned — always use DNS names, never hardcode IPs.
--- ## Project Numbers
- External Apps
- Well known: Postgresl, ssh, web, prometheus
- 220: External Apps (legacy)
- 290: External App 1
- 299: External App 9
- Django Projects:
- 221: Zelus
- 222: Angelia
- 224: Athena
- 225: Kairos
- 226: Icarlos
- 227: MCP Switchboard (227), Spelunker (228), Peitho (229), Mnemosyne (230)
- FastAgent Projects:
- 240: Pallas Iolaus
- 241: Pallas Kottos
- 242: Pallas Mentor
- FastAPI Projects:
- 200: Daedalus
- 201: Arke
- 202: Kernos
- 203: Rommie
- 204: Orpheus
- 205: Periplus
- 206: Nike
- 207: Stentor
- 208: Argos
- 209: Hecate
- 210: Rhema
- 211: Synesis
## Port Numbering
Well-known ports running as a service may be used: Postgresql 5432, Prometheus Metrics 9100.
However inside a docker project, the number plan needs to be followed to avoid port conflicts and confusion:
XXXYZ
XXX Project Number or 290-299 for external project (host specific)
Y Service: 0 reserved, 1-4 flexible, 5 database, 6 MCP, 7 API, 8 Web App, 9 Prometheus metrics
Z Instance: The running instance of this app on the same host, starting at 1. May also be used to handle exceptions.
255 Incus port forwarding: Ports in ths range are forwarded from the Incus host to Incus containers (defined in Terraform), but HAProxy through Titania
514ZZ is the syslog port. Docker containers send their syslog to an Alloy syslog collector port. ZZ is the application instance, they just need to be different on the same host and increment from 01.
## Uranian Host Architecture ## Uranian Host Architecture
@@ -40,12 +83,6 @@ This is the host that runs Python projects in the Ouranos sandbox.
It has an RDP server and is generally where application development happens. It has an RDP server and is generally where application development happens.
Each project has a number that is used to determine port numbers. Each project has a number that is used to determine port numbers.
- Docker engine
- JupyterLab (port 22071 via OAuth2-Proxy)
- Gitea Runner (CI/CD agent)
- Django Projects: Zelus (221), Angelia (222), Athena (224), Kairos (225), Icarlos (226), MCP Switchboard (227), Spelunker (228), Peitho (229), Mnemosyne (230)
- FastAgent Projects: Pallas (240)
- FastAPI Projects: Daedalus (200), Arke (201) Kernos (202), Rommie (203), Orpheus (204), Periplus (205), Nike (206), Stentor (207), Argos (208),
### caliban — Agent Automation ### caliban — Agent Automation
@@ -53,20 +90,19 @@ Autonomous computer agent learning through environmental interaction.
- Docker engine - Docker engine
- Agent S MCP Server (MATE desktop, AT-SPI automation) - Agent S MCP Server (MATE desktop, AT-SPI automation)
- Kernos MCP Shell Server (port 22062) - Kernos MCP Shell Server
- Rommie MCP Server (port 20361) — agent-to-agent GUI automation via Agent S - Rommie MCP Server — agent-to-agent GUI automation via Agent S
- FreeCAD Robust MCP Server (port 22061) — CAD automation via FreeCAD XML-RPC - FreeCAD Robust MCP Server — CAD automation via FreeCAD XML-RPC
- GPU passthrough - GPU passthrough
- RDP access (port 25521) - RDP access
### oberon — Container Orchestration & Dockerized Shared Services ### oberon — Container Orchestration & Dockerized Shared Services
King of the Fairies orchestrating containers and managing MCP infrastructure. King of the Fairies orchestrating containers and managing MCP infrastructure.
- Docker engine - Docker engine
- MCP Switchboard (port 22781) — Django app routing MCP tool calls
- RabbitMQ message queue - RabbitMQ message queue
- smtp4dev SMTP test server (port 22025) - smtp4dev SMTP test server
### portia — Relational Database ### portia — Relational Database
@@ -78,10 +114,7 @@ Intelligent and resourceful — the reliability of relational databases.
### ariel — Graph Database ### ariel — Graph Database
Air spirit — ethereal, interconnected nature mirroring graph relationships. Air spirit — ethereal, interconnected nature mirroring graph relationships.
- Neo4j (Docker)
- Neo4j 5.26.0 (Docker)
- HTTP API: port 25554
- Bolt: port 7687 (reached as `ariel.incus:7687` on the internal network)
### umbriel — Graph Database (Mnemosyne) ### umbriel — Graph Database (Mnemosyne)
@@ -91,20 +124,18 @@ instance so Mnemosyne's `Library`/`Collection`/`Item`/`Chunk`/`Concept` labels,
vector indexes, and schema migrations can't collide with another tenant's vector indexes, and schema migrations can't collide with another tenant's
graph on Ariel. graph on Ariel.
- Neo4j 5.26.0 (Docker) - Neo4j (Docker)
- HTTP Browser: port 25555
- Bolt: port 7687 (reached as `umbriel.incus:7687` on the internal network)
### miranda — MCP Docker Host ### miranda — MCP Docker Host
Curious bridge between worlds — hosting MCP server containers. Curious bridge between worlds — hosting MCP server containers.
- Docker engine (API exposed on port 2375 for MCP Switchboard) - Docker engine
- MCPO OpenAI-compatible MCP proxy 22071 - MCPO OpenAI-compatible MCP
- Argos MCP Server — web search via SearXNG (port 20861) - Argos MCP Server — web search via SearXNG
- Grafana MCP Server (port 22063) - Grafana MCP Server
- Neo4j MCP Server (port 22064) - Neo4j MCP Server
- Gitea MCP Server (port 22062) - Gitea MCP Server
### prospero — Observability Stack ### prospero — Observability Stack
@@ -121,11 +152,9 @@ Master magician observing all events.
Witty and resourceful moon for PHP, Go, and Node.js runtimes. Witty and resourceful moon for PHP, Go, and Node.js runtimes.
- SearXNG privacy search (port 22083, behind OAuth2-Proxy) - SearXNG privacy search
- Gitea self-hosted Git (port 22082, SSH on 22022) - Gitea self-hosted Git
- LobeChat AI chat interface (port 22081) - Nextcloud file sharing and collaboration
- Nextcloud file sharing and collaboration (port 22083)
- AnythingLLM document AI workspace (port 22084)
- Jellyfin media server (port 22086, NVIDIA transcoding, Casdoor SSO) - Jellyfin media server (port 22086, NVIDIA transcoding, Casdoor SSO)
- Nextcloud data on dedicated Incus storage volume - Nextcloud data on dedicated Incus storage volume
- Open WebUI LLM interface (port 22088, PostgreSQL backend on Portia - Open WebUI LLM interface (port 22088, PostgreSQL backend on Portia
@@ -135,7 +164,7 @@ Witty and resourceful moon for PHP, Go, and Node.js runtimes.
Original magical power wielding language magic. Original magical power wielding language magic.
- Arke LLM API Proxy (port 25540) - Arke LLM API Proxy
- Multi-provider support (OpenAI, Anthropic, etc.) - Multi-provider support (OpenAI, Anthropic, etc.)
- Session management with Memcached - Session management with Memcached
- Database backend on Portia - Database backend on Portia
@@ -144,7 +173,7 @@ Original magical power wielding language magic.
Queen of the Fairies managing access control and authentication. Queen of the Fairies managing access control and authentication.
- HAProxy 3.x with TLS termination (port 443) - HAProxy 3.x with TLS termination
- Let's Encrypt wildcard certificate via certbot DNS-01 (Namecheap) - Let's Encrypt wildcard certificate via certbot DNS-01 (Namecheap)
- HTTP to HTTPS redirect (port 80) - HTTP to HTTPS redirect (port 80)
- Gitea SSH proxy (port 22022) - Gitea SSH proxy (port 22022)
@@ -153,21 +182,6 @@ Queen of the Fairies managing access control and authentication.
--- ---
## Port Numbering
Well-known ports running as a service may be used: Postgresql 5432, Prometheus Metrics 9100.
However inside a docker project, the number plan needs to be followed to avoid port conflicts and confusion:
XXXYZ
XXX Project Number or 220 for external project
Y Service: 0 reserved, 1-4 flexible, 5 database, 6 MCP, 7 API, 8 Web App, 9 Prometheus metrics
Z Instance: The running instance of this app on the same host, starting at 1. May also be used to handle exceptions.
255 Incus port forwarding: Ports in ths range are forwarded from the Incus host to Incus containers (defined in Terraform)
514ZZ is the syslog port. Docker containers send their syslog to an Alloy syslog collector port. ZZ is the application instance, they just need to be different on the same host and increment from 01.
---
## Application Conventions ## Application Conventions
@@ -256,36 +270,9 @@ Titania provides TLS termination and reverse proxy for all services.
- **HTTP**: port 80 (redirects to HTTPS) - **HTTP**: port 80 (redirects to HTTPS)
- **Certificate**: Let's Encrypt wildcard via certbot DNS-01 - **Certificate**: Let's Encrypt wildcard via certbot DNS-01
### Route Table ### Subdomains
| Subdomain | Backend | Service | Refer to the Ansible Titania host inventory (`inventory/host_vars/titania.incus.yml`) for current backend routing configuration.
|-----------|---------|---------|
| `ouranos.helu.ca` (root) | puck.incus:22281 | Angelia (Django) |
| `alertmanager.ouranos.helu.ca` | prospero.incus:443 (SSL) | AlertManager |
| `angelia.ouranos.helu.ca` | puck.incus:22281 | Angelia (Django) |
| `anythingllm.ouranos.helu.ca` | rosalind.incus:22084 | AnythingLLM |
| `arke.ouranos.helu.ca` | sycorax.incus:25540 | Arke LLM Proxy |
| `athena.ouranos.helu.ca` | puck.incus:22481 | Athena (Django) |
| `gitea.ouranos.helu.ca` | rosalind.incus:22082 | Gitea |
| `grafana.ouranos.helu.ca` | prospero.incus:443 (SSL) | Grafana |
| `hass.ouranos.helu.ca` | oberon.incus:8123 | Home Assistant |
| `id.ouranos.helu.ca` | titania.incus:22081 | Casdoor SSO |
| `jellyfin.ouranos.helu.ca` | rosalind.incus:22086 | Jellyfin |
| `icarlos.ouranos.helu.ca` | puck.incus:22681 | Icarlos (Django) |
| `jupyterlab.ouranos.helu.ca` | puck.incus:22071 | JupyterLab (OAuth2-Proxy) |
| `kairos.ouranos.helu.ca` | puck.incus:22581 | Kairos (Django) |
| `lobechat.ouranos.helu.ca` | rosalind.incus:22081 | LobeChat |
| `loki.ouranos.helu.ca` | prospero.incus:443 (SSL) | Loki |
| `mcp-switchboard.ouranos.helu.ca` | oberon.incus:22781 | MCP Switchboard |
| `nextcloud.ouranos.helu.ca` | rosalind.incus:22083 | Nextcloud |
| `openwebui.ouranos.helu.ca` | oberon.incus:22088 | Open WebUI |
| `peitho.ouranos.helu.ca` | puck.incus:22981 | Peitho (Django) |
| `periplus.ouranos.helu.ca` | puck.incus:20681 | Periplus (FastAPI + MCP via nginx) |
| `pgadmin.ouranos.helu.ca` | prospero.incus:443 (SSL) | PgAdmin 4 |
| `prometheus.ouranos.helu.ca` | prospero.incus:443 (SSL) | Prometheus |
| `searxng.ouranos.helu.ca` | oberon.incus:22073 | SearXNG (OAuth2-Proxy) |
| `smtp4dev.ouranos.helu.ca` | oberon.incus:22085 | smtp4dev |
| `spelunker.ouranos.helu.ca` | puck.incus:22881 | Spelunker (Django) |
--- ---