- JavaScript 72.2%
- CSS 16.7%
- HTML 9.9%
- Dockerfile 1.2%
| public | ||
| src | ||
| .containerignore | ||
| .env.example | ||
| .gitignore | ||
| akg-backamo.container | ||
| Containerfile | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
AKG Backamo ESGA
Airfield dashboard for Aeroklubben i Göteborg at Backamo (ESGA):
- Live view – latest webcam JPEG (uploaded by the camera via FTP), with a "UTC Time" label added after the timestamp
- METAR / TAF – nearby airports, from aviationweather.gov
- Bookings – today's bookings as a timeline, from the myWebLog API v5
- NOTAM – filtered from the LFV "ESAA FIR VFR 24hr" bulletin for a local flight from ESGA
- Traffic – live ADS-B on a MapLibre GL JS map, with club aircraft highlighted
- Clubhouse TV screens – two full-screen views for 16:9 TVs: live traffic, and bookings + weather + NOTAM + significant weather chart
Access requires a myWebLog login (TV screens use a separate token).
How sign-in works
myWebLog's OAuth 2.0 is client credentials only (server to server); there is no member-facing "log in with myWebLog" page. The app therefore:
- exchanges the club's API token for a one-hour access token (
POST /oauth/token); - checks the member's own username and password with
POST /login-checks; - keeps the member signed in with a signed, HTTP-only cookie (7 days by default).
Members can type their username with or without the <organization id>- prefix. Sign-in attempts are rate-limited (10 per 15 min per IP), and myWebLog applies its own lock-out after too many failed attempts.
Data sources
| Panel | Source | Refresh |
|---|---|---|
| Webcam | Files in the webcam volume | 30 s |
| METAR | aviationweather.gov/api/data/metar + /taf |
5 min |
| Bookings | api.myweblog.se/main/v5/bookings + /objects |
2 min |
| NOTAM | aro.lfv.se – PIB "ESAA FIR VFR 24hr" (PDF) |
10 min |
| Traffic | api.adsb.lol (or a local readsb/tar1090 aircraft.json) |
5 s |
| SWC | Northavimet NORCICS chart (PDF, © FMI/SMHI/DMI/MET Norway), converted to PNG | 10 min |
| Map | OpenFreeMap tiles (no key needed) | – |
Upstream responses are cached server-side and shared between users. If a source fails, the last good data is served and marked stale.
NOTAM disclaimer: the filter keeps NOTAMs for the configured aerodromes, en-route items with a position within
NOTAM_RADIUS_NMof ESGA, and items mentioning nearby places. It is a convenience, not an official briefing – the panel links to the complete bulletin.
Deployment (Podman Quadlet)
1. myWebLog API token
On the myWebLog website (Integrations → API), create a token with read access to:
- Login check
- Bookings
- Objects
Note the token (mwla1_…) and its Client ID. If the token is restricted to IP addresses, add the server's public IP.
2. Build the image
podman build -t akg/backamo:latest /path/to/akg-backamo
3. Create the secrets
printf '%s' 'mwla1_your_token' | podman secret create akg-backamo-mwl-secret -
openssl rand -base64 48 | tr -d '\n' | podman secret create akg-backamo-session-secret -
openssl rand -hex 24 | tr -d '\n' | podman secret create akg-backamo-tv-token -
Changing the session secret signs everyone out. Read the TV token back with podman secret inspect --showsecret akg-backamo-tv-token when setting up the screens.
4. Webcam volume
The container reads the camera images from a Podman volume shared with the FTP server, mounted read-only at /webcam. The default volume name is backamo-webcam; change it in the unit file if yours differs.
- By default the newest
*.jpg/*.jpegis shown (files modified in the last 2 s are skipped while the upload completes). SetWEBCAM_FILEif the camera always overwrites the same file name. - The files must be readable by the container user (uid 1000,
node). - The unit mounts with
z(shared SELinux label), notZ, so the FTP container keeps access.
5. Install the Quadlet unit
Edit akg-backamo.container – at least MWL_CLIENT_ID and the volume name – then:
# System-wide
sudo cp akg-backamo.container /etc/containers/systemd/
sudo systemctl daemon-reload
sudo systemctl start akg-backamo
# or rootless
cp akg-backamo.container ~/.config/containers/systemd/
systemctl --user daemon-reload
systemctl --user start akg-backamo
Quadlet-generated services are started at boot through the [Install] section; systemctl enable is not needed.
Logs and status:
journalctl -u akg-backamo -f # add --user for rootless
podman healthcheck run akg-backamo
6. Reverse proxy
The app is published on the host at port 9197 and expects a TLS-terminating reverse proxy in front (session cookies are Secure).
Caddy in a container (pasta)
In a pasta-networked container, host.containers.internal is translated to the host's own address, not to 127.0.0.1. The port must therefore be published on all interfaces (PublishPort=9197:3000, as in the unit file), and blocked from outside by the host firewall:
backamo.example.se {
encode zstd gzip
reverse_proxy http://host.containers.internal:9197
}
No extra headers are needed. Caddy forwards the original Host, and sets X-Forwarded-Proto: https and X-Forwarded-For to the real client address, overwriting any value the client sent. The app needs the original Host and the https scheme for its same-origin check on sign-in, and X-Forwarded-For for the sign-in rate limit.
Reload Caddy after editing, e.g. podman exec caddy caddy reload --config /etc/caddy/Caddyfile.
To check the connection from inside the Caddy container:
podman exec caddy wget -qO- http://host.containers.internal:9197/healthz
Caddy on the host
The port can then be published on loopback only (PublishPort=127.0.0.1:9197:3000):
backamo.example.se {
reverse_proxy 127.0.0.1:9197
}
nginx
nginx needs proxy_set_header Host $host;, proxy_set_header X-Forwarded-Proto $scheme; and proxy_set_header X-Forwarded-For $remote_addr;.
Trusted proxy
TRUST_PROXY=1 trusts exactly one proxy hop, i.e. the X-Forwarded-* headers set by Caddy. Make sure port 9197 is not reachable from outside the host (firewalld and most default firewall setups block unlisted ports). If it is reachable directly, clients bypass TLS and can send their own X-Forwarded-For to get around the sign-in rate limit.
With firewalld, check that the port is not open:
sudo firewall-cmd --list-ports # 9197/tcp must not be listed
Updating
podman build -t akg/backamo:latest /path/to/akg-backamo
sudo systemctl restart akg-backamo
Clubhouse TV screens
Two full-screen pages designed for 1920×1080 (they scale to any 16:9 screen, and never need scrolling or a mouse):
| URL | Content |
|---|---|
/tv/traffic |
Live traffic map around ESGA (larger symbols, 5/10/20/30 NM rings) and a list of aircraft sorted by distance, club aircraft first |
/tv/board |
Today's bookings, METAR/TAF, NOTAM, and the significant weather chart |
Long lists (METAR/TAF, NOTAM, aircraft) scroll slowly by themselves. The pages reload every 6 hours to pick up new versions.
Signing in a screen
The TVs don't use a member login. Open each page once with the TV token:
https://backamo.example.se/tv/traffic?tv_token=<TV_TOKEN>
https://backamo.example.se/tv/board?tv_token=<TV_TOKEN>
The app sets a cookie valid for 400 days and redirects to the address without the token. The cookie is renewed daily while the screen is on, so it doesn't expire. Bookmark or set the start page to the address without the token.
To sign out all screens, change TV_TOKEN and the session secret. Wrong tokens get 403 and are logged.
Significant weather chart
The board shows the NORCICS SWC charts (SFC–FL450) issued for 00, 06, 12 and 18 UTC. It shows the chart for the latest slot before now and the next one, e.g. at 15Z the 12Z and the 18Z charts, alternating every SWC_SLIDE_SECONDS. A chart not yet published is skipped. The PDFs are converted to PNG in the container with pdftoppm.
Browser on the TV
Any kiosk browser works, e.g. a Raspberry Pi with Chromium:
chromium --kiosk --noerrdialogs --disable-session-crashed-bubble https://backamo.example.se/tv/board
The map needs WebGL, which most smart-TV browsers lack; a small computer (Raspberry Pi 4/5, mini PC) on the HDMI input is the reliable option.
Configuration
All settings are environment variables (Environment= in the unit, or .env for development).
| Variable | Default | Description |
|---|---|---|
SESSION_SECRET |
required | Cookie signing key |
MWL_CLIENT_ID |
required | myWebLog token Client ID |
MWL_CLIENT_SECRET |
required | myWebLog token (mwla1_…) |
PORT |
3000 |
Listen port inside the container |
TRUST_PROXY |
loopback |
Express trust proxy setting |
COOKIE_SECURE |
true |
Set false only for local http development |
SESSION_MAX_AGE_HOURS |
168 |
Sign-in lifetime |
TZ_LOCAL |
Europe/Stockholm |
Time zone for bookings |
BOOKING_AIRCRAFT |
(all) | Registrations shown in the booking calendar, comma-separated, in display order (e.g. SE-MOA,SE-MOY,SE-KWH) |
CLUB_AIRCRAFT |
BOOKING_AIRCRAFT |
Registrations highlighted on the traffic map. With adsb.lol they are also looked up by registration (every 15 s), so they show even outside the radius, and are listed under the map when heard without a position (Mode S only) |
AIRFIELD_LAT / _LON |
58.1772 / 11.9735 |
Map centre, NOTAM and ADS-B reference |
WEBCAM_DIR |
/webcam |
Folder with camera images |
WEBCAM_FILE |
(newest jpg) | Fixed file name |
WEBCAM_LABEL |
UTC Time |
Text added after the timestamp; empty disables |
WEBCAM_LABEL_X |
800 |
x where the timestamp ends (px) |
WEBCAM_LABEL_BASELINE |
1702 |
Bottom of the timestamp text (px) |
WEBCAM_LABEL_HEIGHT |
50 |
Timestamp text height (px) |
WEBCAM_REFRESH_SECONDS |
30 |
Browser polling interval |
METAR_STATIONS |
ESGT,ESGP,ESGG,ESIB |
METAR/TAF stations |
NOTAM_AERODROMES |
ESGA,ESGT,ESGP,ESGG,ESIB |
Aerodromes whose NOTAMs are always shown |
NOTAM_RADIUS_NM |
40 |
Radius for positioned en-route NOTAMs |
NOTAM_KEYWORDS |
nearby place names | Matches NOTAMs without a position |
NOTAM_BULLETIN |
ESAA FIR VFR 24hr |
LFV bulletin name |
ADSB_URL |
https://api.adsb.lol/v2/point/{lat}/{lon}/{radius} |
ADS-B source; {lat}, {lon}, {radius} substituted |
ADSB_RADIUS_NM |
40 |
ADS-B search radius |
ADSB_REFRESH_SECONDS |
5 |
Traffic polling interval |
MAP_STYLE_URL |
https://tiles.openfreemap.org/styles/liberty |
MapLibre style |
MAP_EXTRA_ORIGINS |
– | Extra tile/glyph origins allowed by the CSP (comma-separated) |
TV_TOKEN |
(TV screens disabled) | Token for signing in the clubhouse screens (at least 24 characters) |
SWC_URL |
Northavimet NORCICS URL with {time} |
Significant weather chart PDF; {time} is YYYYMMDDHHmm |
SWC_SLIDE_SECONDS |
15 |
Time each chart is shown on the board |
Local ADS-B receiver
If the club runs its own receiver (readsb, dump1090-fa, tar1090), point ADSB_URL at its aircraft.json, e.g. http://192.168.1.50:8080/data/aircraft.json. A local feed has no registration/type data, only what the aircraft transmits.
Other map styles
A style using another tile host must have its origin in MAP_EXTRA_ORIGINS (the CSP blocks it otherwise). Labels use the Noto Sans Regular font, which the style's glyph server must provide.
Local development
Requires Node.js 22+.
npm install
cp .env.example .env # fill in the myWebLog credentials
mkdir -p dev-webcam # put a test .jpg here
npm run dev # http://localhost:3000
Project layout
Containerfile Image definition (node:22-bookworm-slim + DejaVu fonts + poppler-utils)
akg-backamo.container Podman Quadlet unit
src/
server.js Express app, routes, CSP, sessions
config.js Environment variables
auth.js Sign-in / sign-out, TV token, auth middleware
myweblog.js myWebLog API v5 client (OAuth, login check, bookings)
webcam.js Latest image + "UTC Time" label (sharp)
weather.js METAR/TAF
notam.js LFV PIB download, parsing and filtering
adsb.js ADS-B traffic
swc.js Significant weather charts (PDF → PNG)
cache.js Shared TTL cache
public/
index.html, app.js Dashboard
js/ Shared frontend modules (panels, traffic map, helpers)
tv/ Clubhouse TV screens
login.html Sign-in page
assets/ Logo, styles, login script (public)
Colours
Defined as CSS variables in public/assets/styles.css, converted from the club's CMYK values (approximate):
| Name | CMYK | Screen |
|---|---|---|
| AKG blue | C100 M81 Y20 K4 | #12488c |
| AKG gold | C0 M23 Y99 K0 | #fdc300 |
| AKG pale gold | C7 M13 Y69 K0 | #efd56b |
| AKG red | C0 M96 Y66 K0 | #e31b44 |