No description
  • Go 95.1%
  • Shell 2.7%
  • HTML 1.7%
  • Dockerfile 0.3%
  • Makefile 0.2%
Find a file
Dennis Heinz 92e5993686 fix: no LLM redirects, https-only API endpoints, failed logins logged for fail2ban
- llm: the client never follows redirects, so a custom auth header and the
  prompt cannot be resent to another host; a 3xx fails through the error path
- config: llm.base_url and grav.base_url must be https, http only on loopback
- web: wrong Basic-Auth credentials log 'basic auth failed' with the client IP
  (remote address, or the last entry of server.client_ip_header behind a proxy);
  rate limiting is left to fail2ban on the host
- README documents the https rule, client_ip_header and a fail2ban example
2026-10-09 04:02:41 +02:00
.claude docs: add ROADMAP.md with phase labels for git-bug issues 2026-10-09 02:54:17 +02:00
cmd/bumo-blogger fix: no LLM redirects, https-only API endpoints, failed logins logged for fail2ban 2026-10-09 04:02:41 +02:00
configs fix: no LLM redirects, https-only API endpoints, failed logins logged for fail2ban 2026-10-09 04:02:41 +02:00
docs fix: no LLM redirects, https-only API endpoints, failed logins logged for fail2ban 2026-10-09 04:02:41 +02:00
internal fix: no LLM redirects, https-only API endpoints, failed logins logged for fail2ban 2026-10-09 04:02:41 +02:00
scripts chore: replace GSD decision IDs in comments with ADR references 2026-10-09 02:20:58 +02:00
.dockerignore chore: replace GSD decision IDs in comments with ADR references 2026-10-09 02:20:58 +02:00
.gitignore feat: add module, Makefile, depguard lint and domain/port contracts 2026-10-08 20:39:54 +02:00
.golangci.yml build(lint): enable the standard golangci-lint linters next to depguard 2026-10-09 03:17:40 +02:00
CONTEXT.md docs: add CONTEXT.md, ADRs and PITFALLS.md 2026-10-09 02:27:34 +02:00
Dockerfile build(docker): add Dockerfile, .dockerignore and German operations README 2026-10-08 21:54:57 +02:00
go.mod feat(review): add Review use case and sanitized Vorschau page with one-click Ablehnen 2026-10-08 21:22:48 +02:00
go.sum feat(review): add Review use case and sanitized Vorschau page with one-click Ablehnen 2026-10-08 21:22:48 +02:00
Makefile build(docker): add Dockerfile, .dockerignore and German operations README 2026-10-08 21:54:57 +02:00
README.md fix: no LLM redirects, https-only API endpoints, failed logins logged for fail2ban 2026-10-09 04:02:41 +02:00

bumo-blogger

A self-hosted Go service for buchforst-mobil.de. Every day it reads local RSS feeds, picks the most relevant topics for Köln-Buchforst and Mülheim-Süd, and has an exchangeable LLM write up to three German blog post suggestions. In a small web UI (htmx, Alpine.js, Bulma) the editor reviews them, rejects them, or uploads them to Grav with one click as unpublished pages. Going live happens in Grav.

Project docs

  • CONTEXT.md: domain glossary. The German UI terms (Vorschlag, Freigeben, Quellen, …) are the canonical names.
  • docs/adr/: architecture decision records.
  • docs/PITFALLS.md: known traps and open gaps.
  • docs/ROADMAP.md: phases in order with their issues.
  • docs/agents/: conventions for coding agents (issue tracker, triage labels, domain docs).

Development

Requires Go 1.27 (GOTOOLCHAIN=auto fetches it). No C toolchain is needed: the build always runs with CGO_ENABLED=0.

make build   # binary in bin/
make test    # go test ./...
make lint    # golangci-lint (pinned), including depguard architecture rules
make run     # serve with configs/config.example.yaml
make docker  # image bumo-blogger:dev

The code follows a flat clean architecture: cmd → internal/adapters → internal/app → internal/domain. See ADR-0001.

Issue tracking with git-bug

Issues live in this repository as git-bug entities under refs/bugs/, not on a hosting platform. They are local only; there is no bridge.

git bug bug status:open sort:creation             # list open issues
git bug bug label:ready-for-agent                 # filter by label
git bug bug show <id>                             # details and comments
git bug bug new --non-interactive -t "<title>" -m "<body>"
git bug bug label new <id> needs-triage
git bug bug comment new <id> --non-interactive -m "<text>"
git bug bug status close <id>
git bug webui                                     # browser UI
  • Labels: triage state (needs-triage, needs-info, ready-for-agent, ready-for-human, wontfix) plus the phase (phase-1 … phase-5, backlog).
  • Dependencies: an issue states them on its first body line as Blocked by: <id>, <id>.
  • Body text: pass it with -m, not -F. With -F, git-bug uses the file's first line as the title and drops every line starting with #, which strips the Markdown headings.
  • The UIs hold the repository lock: while git bug webui or git bug termui runs, every other git-bug command, reads included, hangs without output until the UI exits. It does not fail or time out.
  • Pushing: a plain git push does not send issues. Once a remote exists, use git bug push and git bug pull.

More detail: docs/agents/issue-tracker.md.

Operations

Environment variables

Secrets live only in the environment, never in the YAML file. The service refuses to start if one is missing:

Variable Purpose
OPENROUTER_API_KEY API key of the LLM provider (the name can be changed with llm.api_key_env)
GRAV_API_KEY Key of the Grav API user
BASIC_AUTH_USER, BASIC_AUTH_PASS Login for the web UI

Non-secret overrides: BUMO_CONFIG, BUMO_SERVER_ADDR, BUMO_DATABASE_PATH, BUMO_LOG_FORMAT, BUMO_LOG_LEVEL, BUMO_LLM_BASE_URL, BUMO_GRAV_BASE_URL, BUMO_SCHEDULE_DAILY_AT.

Configuration

Template: configs/config.example.yaml. Unknown keys make the start fail. Changes take effect only after a restart.

  • Add a feed: add another entry under sources (id, name, url). No code change is needed. Optional flags per feed: repair_links (broken Stadt Köln links), teaser_only (paywalled: title, teaser and link only), fix_berlin_offset (feed writes +0100 all year).
  • Models: llm.tasks.rank.model and llm.tasks.write.model.
  • Switch provider: change only llm.base_url, llm.auth_header and llm.auth_scheme, plus llm.extra_body if needed.
# OpenAI
llm:
  base_url: "https://api.openai.com/v1"
  api_key_env: OPENAI_API_KEY
  auth_header: Authorization
  auth_scheme: Bearer
  extra_body: {}
  tasks:
    rank: { model: "gpt-5-mini", max_tokens: 2000 }
    write: { model: "gpt-5", max_tokens: 2500 }

# Azure OpenAI (v1 endpoint, model = deployment name)
llm:
  base_url: "https://MY-RESOURCE.openai.azure.com/openai/v1"
  api_key_env: AZURE_OPENAI_API_KEY
  auth_header: api-key
  auth_scheme: ""
  extra_body: {}

Run locally

# Git Bash
export OPENROUTER_API_KEY=... GRAV_API_KEY=... BASIC_AUTH_USER=... BASIC_AUTH_PASS=...
make run
# PowerShell
$env:OPENROUTER_API_KEY="..."; $env:GRAV_API_KEY="..."
$env:BASIC_AUTH_USER="..."; $env:BASIC_AUTH_PASS="..."
make run

The UI is then at http://127.0.0.1:8080/.

Docker

make docker
docker run -d --name bumo-blogger --stop-timeout 45 -p 8080:8080 -v bumo-data:/data --env-file bumo.env bumo-blogger:dev
  • bumo.env: holds the four variables as NAME=value. Do not commit it.
  • User and mounts: the image runs as user nonroot (UID 65532). With a bind mount instead of a named volume, that user must own the directory: chown 65532:65532 <dir>.
  • Own configuration: -v ./config.yaml:/etc/bumo-blogger/config.yaml:ro.
  • Stopping: the service waits up to 40 s for a running Grav upload before it closes the database. Docker kills a container after 10 s by default, so keep --stop-timeout 45 (Compose: stop_grace_period: 45s).

Daily run and health check

  • The run starts every day at 06:00 Europe/Berlin (schedule.daily_at).
  • Catch-up: if the service starts after 06:00 and there has been no successful run today, it runs once to catch up. A further restart on the same day starts no second run.
  • Manual run: „Lauf starten“ in the UI starts a run. Only one run happens at a time.
  • Health check: GET /healthz needs no login and answers 200 as long as the database responds. The Docker HEALTHCHECK calls bumo-blogger healthcheck.

Reverse proxy

Basic-Auth is only safe over HTTPS, so TLS must terminate at the reverse proxy. If the proxy rewrites the Host header, the public address must be listed in server.trusted_origins (for example https://blogger.example.org). Otherwise the cross-origin protection rejects the htmx POSTs with 403.

Set server.client_ip_header to the header the proxy writes the client IP into (for example X-Forwarded-For; the last entry is used). Without a proxy leave it empty: then the remote address counts and a client cannot fake its IP.

HTTPS for API endpoints

llm.base_url and grav.base_url must use https, because the API keys travel with every request. Plain http is accepted only for localhost, 127.0.0.0/8 and ::1. The LLM and Grav clients never follow redirects, so a key is never resent to another host.

Failed logins and fail2ban

The service does not limit login attempts itself. Every request with wrong Basic-Auth credentials logs one warning with the client IP; a request without credentials (the browser's first try) does not. Let fail2ban on the host ban repeat offenders, for example with the Docker journald log driver:

# /etc/fail2ban/filter.d/bumo-blogger.conf
[Definition]
failregex = msg="basic auth failed" client_ip=<HOST>$
            "msg":"basic auth failed","client_ip":"<HOST>"

# /etc/fail2ban/jail.d/bumo-blogger.conf
[bumo-blogger]
enabled = true
backend = systemd
journalmatch = CONTAINER_NAME=bumo-blogger
filter = bumo-blogger
maxretry = 10
findtime = 10m
bantime = 1h

Behind a reverse proxy the ban must happen where the client connects (the proxy host), and server.client_ip_header must be set so the log names the real client.

Grav

The service needs its own Grav API user with the permissions api.access and api.pages.*. Generate the key with bin/plugin api keys:generate. Uploaded pages are unpublished and not routable, so the „In Grav öffnen“ link returns 404 publicly until the page is published in Grav.

Backup

Stop the container, then copy bumo.db* (the database plus its -wal and -shm files) out of the volume, for example with docker run --rm -v bumo-data:/data -v "$PWD":/backup alpine sh -c "cp /data/bumo.db* /backup/".