Websted og forum bag SEOSofts reverse proxy på appserveren
Some checks are pending
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
deploy hub / deploy (push) Successful in 24s
CI / Build og unit tests (push) Successful in 56s
deploy community / deploy (push) Successful in 1s
CI / MSI-installer (push) Successful in 33s

- deploy-community.yml: bygger og starter webstedet ved push (som hubben),
  bundet til DMZ-adressen. Springes over, indtil COMMUNITY_PORT er sat.
  Feedet læses skrivebeskyttet fra hubbens feed-mappe; download-links går
  til hub.sqlinsight.seosoft.dk/updates/ (ny indstilling DownloadUrl).
- deploy-forum.yml (manuel): kører discourse_docker-launcheren i en
  helper-container med /var/discourse og docker.sock fra værten.
  app.yml er en skabelon, som render.py udfylder fra secrets.
- Discourse uden egen TLS: lytter på DMZ-adressen, tvinger https-links og
  tager klient-IP fra X-Forwarded-For fra proxyen (ellers rammer rate
  limiting alle brugere samlet).
- Caddy-opsætningen er fjernet; docs/community.md beskriver secrets og
  proxy-opsætning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
This commit is contained in:
Claude 2026-09-29 13:59:53 +00:00
parent be35e50335
commit 69fa641f26
No known key found for this signature in database
13 changed files with 452 additions and 124 deletions

View file

@ -0,0 +1,141 @@
# =============================================================================
# Deploy SQLInsight Pro-webstedet (sqlinsight.seosoft.dk) til appserveren ved push til main.
#
# Samme mønster som deploy-hub.yml: runneren er host-mode, så .env og compose-filen skrives til INSTALL_DIR
# via en kortlivet helper-container, og 'docker compose up' køres i docker:cli.
#
# Påkrævede secrets: HOST_BIND COMMUNITY_PORT COMMUNITY_CONTACT
# Valgfri: TRUSTED_KEY_ID + TRUSTED_KEY (offentlig nøgle, som feedet er signeret med, hvis den ikke er indbygget)
# =============================================================================
name: deploy community
on:
push:
branches: [main]
paths:
- "src/**"
- "docs/**"
- "rules/**"
- "deploy/community/**"
- "Directory.*.props"
- "global.json"
- ".forgejo/workflows/deploy-community.yml"
workflow_dispatch:
concurrency:
group: deploy-sqlinsight-community
cancel-in-progress: false
env:
INSTALL_DIR: /opt/sqlinsight-community
IMAGE: sqlinsight-community
CONTAINER: sqlinsight-community
jobs:
deploy:
runs-on: [self-hosted, appserver]
timeout-minutes: 30
steps:
- name: Configured?
id: check
env:
COMMUNITY_PORT: ${{ secrets.COMMUNITY_PORT }}
run: |
# Indtil secrets er sat, springes deploy over i stedet for at fejle hvert push.
if [ -n "${COMMUNITY_PORT:-}" ]; then
echo "configured=true" >> "$GITHUB_OUTPUT"
else
echo "::warning::COMMUNITY_PORT er ikke sat; webstedet deployes ikke (se docs/community.md)"
echo "configured=false" >> "$GITHUB_OUTPUT"
fi
- name: Checkout
if: steps.check.outputs.configured == 'true'
uses: actions/checkout@v4
- name: Build image
if: steps.check.outputs.configured == 'true'
run: |
set -e
docker build -f deploy/community/Dockerfile -t "${IMAGE}:latest" -t "${IMAGE}:${{ github.sha }}" .
- name: Write .env + docker-compose.yml to INSTALL_DIR
if: steps.check.outputs.configured == 'true'
env:
HOST_BIND: ${{ secrets.HOST_BIND }}
COMMUNITY_PORT: ${{ secrets.COMMUNITY_PORT }}
COMMUNITY_CONTACT: ${{ secrets.COMMUNITY_CONTACT }}
TRUSTED_KEY_ID: ${{ secrets.TRUSTED_KEY_ID }}
TRUSTED_KEY: ${{ secrets.TRUSTED_KEY }}
run: |
set -eu
required="HOST_BIND COMMUNITY_PORT COMMUNITY_CONTACT"
missing=0
for v in $required; do
if [ -z "$(printenv "$v" || true)" ]; then
echo "ERROR: required secret $v is not set in repo settings"
missing=1
fi
done
[ "$missing" -eq 0 ] || exit 1
for v in $required TRUSTED_KEY_ID TRUSTED_KEY; do
case "$(printenv "$v" || true)" in
*"'"*|*";"*) echo "ERROR: secret $v indeholder ' eller ; – ikke understøttet"; exit 1 ;;
esac
done
tmp="$(mktemp -d)"
{
printf "HOST_BIND='%s'\n" "$HOST_BIND"
printf "COMMUNITY_PORT='%s'\n" "$COMMUNITY_PORT"
printf "SQLInsight__Community__ContactEmail='%s'\n" "$COMMUNITY_CONTACT"
if [ -n "${TRUSTED_KEY_ID:-}" ] && [ -n "${TRUSTED_KEY:-}" ]; then
printf "SQLInsight__Community__TrustedKeys__%s='%s'\n" "$TRUSTED_KEY_ID" "$TRUSTED_KEY"
fi
} > "$tmp/.env"
cp deploy/community/docker-compose.prod.yml "$tmp/docker-compose.yml"
tar -C "$tmp" -cf - .env docker-compose.yml | docker run --rm -i \
-v "${INSTALL_DIR}:/out" alpine:3 \
sh -c 'tar -C /out -xf - && chown root:root /out/.env /out/docker-compose.yml && chmod 600 /out/.env && chmod 644 /out/docker-compose.yml'
rm -rf "$tmp"
- name: Deploy (compose up)
if: steps.check.outputs.configured == 'true'
run: |
set -eu
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "${INSTALL_DIR}:${INSTALL_DIR}" \
-w "${INSTALL_DIR}" \
docker:cli \
docker compose --project-name sqlinsight-community up -d --force-recreate --remove-orphans
- name: Wait for /health
if: steps.check.outputs.configured == 'true'
env:
HOST_BIND: ${{ secrets.HOST_BIND }}
COMMUNITY_PORT: ${{ secrets.COMMUNITY_PORT }}
run: |
set -eu
for i in $(seq 1 30); do
if docker run --rm alpine:3 wget -qO- -T 3 "http://${HOST_BIND}:${COMMUNITY_PORT}/health" 2>/dev/null | grep -q '"ok"'; then
echo "${CONTAINER} is healthy"
exit 0
fi
echo "attempt $i: not ready"
sleep 5
done
echo "${CONTAINER} did not answer /health within 150s — last 100 log lines:"
docker logs --tail 100 "${CONTAINER}" || true
exit 1
- name: Prune old images
if: always() && steps.check.outputs.configured == 'true'
run: |
docker image prune -f || true
docker images "${IMAGE}" --format '{{.Tag}} {{.CreatedAt}}' \
| grep -v '^latest ' | sort -k2 -r | awk 'NR>3 {print $1}' \
| xargs -r -I{} docker image rm "${IMAGE}:{}" || true

View file

@ -0,0 +1,73 @@
# =============================================================================
# Installér eller genopbyg forummet (Discourse, forum.sqlinsight.seosoft.dk) på appserveren.
#
# Kun manuelt (Actions → deploy forum → Run workflow): en rebuild tager 10-15 minutter, og forummet er nede imens.
# Discourse opdateres ellers fra /admin/upgrade.
#
# Runneren er host-mode, så launcheren køres i en helper-container med /var/discourse og docker.sock fra værten.
# app.yml udfyldes fra secrets af render.py; intet hemmeligt ligger i repoet.
#
# Påkrævede secrets: HOST_BIND FORUM_PORT KNOWN_PROXY FORUM_ADMIN_EMAIL FORUM_SMTP_ADDRESS FORUM_SMTP_USER FORUM_SMTP_PASSWORD
# Valgfri: FORUM_SMTP_PORT (587) FORUM_NOTIFICATION_EMAIL (= FORUM_SMTP_USER) FORUM_WORKERS (4) FORUM_DB_SHARED_BUFFERS (512MB)
# =============================================================================
name: deploy forum
on:
workflow_dispatch:
concurrency:
group: deploy-sqlinsight-forum
cancel-in-progress: false
jobs:
deploy:
runs-on: [self-hosted, appserver]
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build launcher helper
run: docker build -t sqlinsight-forum-launcher deploy/forum
- name: Rebuild Discourse
env:
FORUM_BIND: ${{ secrets.HOST_BIND }}
FORUM_PORT: ${{ secrets.FORUM_PORT }}
KNOWN_PROXY: ${{ secrets.KNOWN_PROXY }}
FORUM_ADMIN_EMAIL: ${{ secrets.FORUM_ADMIN_EMAIL }}
FORUM_SMTP_ADDRESS: ${{ secrets.FORUM_SMTP_ADDRESS }}
FORUM_SMTP_PORT: ${{ secrets.FORUM_SMTP_PORT }}
FORUM_SMTP_USER: ${{ secrets.FORUM_SMTP_USER }}
FORUM_SMTP_PASSWORD: ${{ secrets.FORUM_SMTP_PASSWORD }}
FORUM_NOTIFICATION_EMAIL: ${{ secrets.FORUM_NOTIFICATION_EMAIL }}
FORUM_WORKERS: ${{ secrets.FORUM_WORKERS }}
FORUM_DB_SHARED_BUFFERS: ${{ secrets.FORUM_DB_SHARED_BUFFERS }}
run: |
set -eu
# Samme sti inde og ude, så launcherens bind mounts (/var/discourse/shared/...) peger på værtens mapper.
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /var/discourse:/var/discourse \
-e FORUM_BIND -e FORUM_PORT -e KNOWN_PROXY -e FORUM_ADMIN_EMAIL \
-e FORUM_SMTP_ADDRESS -e FORUM_SMTP_PORT -e FORUM_SMTP_USER -e FORUM_SMTP_PASSWORD \
-e FORUM_NOTIFICATION_EMAIL -e FORUM_WORKERS -e FORUM_DB_SHARED_BUFFERS \
sqlinsight-forum-launcher
- name: Wait for the forum
env:
HOST_BIND: ${{ secrets.HOST_BIND }}
FORUM_PORT: ${{ secrets.FORUM_PORT }}
run: |
set -eu
for i in $(seq 1 60); do
if docker run --rm alpine:3 wget -qO- -T 3 "http://${HOST_BIND}:${FORUM_PORT}/srv/status" 2>/dev/null | grep -q ok; then
echo "forum is up"
exit 0
fi
echo "attempt $i: not ready"
sleep 5
done
docker logs --tail 100 app || true
exit 1

View file

@ -1,5 +0,0 @@
{$COMMUNITY_HOST} {
encode zstd gzip
reverse_proxy community:8080
header Strict-Transport-Security "max-age=31536000"
}

View file

@ -0,0 +1,31 @@
# SQLInsight Pro-webstedet (sqlinsight.seosoft.dk) i SEOSofts drift (appserver). Kopieres til INSTALL_DIR af
# .forgejo/workflows/deploy-community.yml sammen med en .env, der genereres fra Forgejo repo-secrets.
# Reverse proxyen terminerer TLS, som for hubben.
#
# Feedet læses direkte fra hubbens feed-mappe (skrivebeskyttet), så webstedet viser præcis det, agenterne får.
# Download-links går til hubbens offentlige /updates/.
#
# Manuel drift på appserveren:
# cd /opt/sqlinsight-community && docker compose ps | logs -f | restart
services:
community:
image: sqlinsight-community:latest
container_name: sqlinsight-community
restart: unless-stopped
env_file:
- .env
environment:
SQLInsight__Community__Feed: /feed
SQLInsight__Community__DownloadUrl: https://hub.sqlinsight.seosoft.dk/updates/
SQLInsight__Community__ForumUrl: https://forum.sqlinsight.seosoft.dk/
volumes:
- /opt/sqlinsight-hub/data/feed:/feed:ro
ports:
# Bind til DMZ-IP (HOST_BIND i .env). Reverse proxyen terminerer TLS.
- "${HOST_BIND}:${COMMUNITY_PORT}:8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"

View file

@ -1,39 +0,0 @@
# The SQLInsight Pro website behind Caddy, which gets and renews the TLS certificate itself.
# DNS for COMMUNITY_HOST must point at this server, and ports 80 and 443 must be free (Discourse runs on its own server).
#
# COMMUNITY_HOST=sqlinsight.seosoft.dk docker compose up -d
services:
community:
build:
context: ../..
dockerfile: deploy/community/Dockerfile
image: sqlinsight-community
restart: unless-stopped
environment:
# The signed update feed: the hub's /updates/ (or a folder mounted into the container).
SQLInsight__Community__Feed: ${COMMUNITY_FEED:-https://hub.sqlinsight.seosoft.dk/updates/}
SQLInsight__Community__ForumUrl: ${COMMUNITY_FORUM:-https://forum.sqlinsight.seosoft.dk/}
SQLInsight__Community__ContactEmail: ${COMMUNITY_CONTACT:?set COMMUNITY_CONTACT (the address for support enquiries)}
# The public key releases are signed with, if it is not embedded in the build (the vendor tool's keygen prints it):
# SQLInsight__Community__TrustedKeys__k20260929: "MFkw..."
expose:
- "8080"
caddy:
image: caddy:2-alpine
restart: unless-stopped
depends_on:
- community
environment:
COMMUNITY_HOST: ${COMMUNITY_HOST:?set COMMUNITY_HOST}
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:

8
deploy/forum/Dockerfile Normal file
View file

@ -0,0 +1,8 @@
# Helper image that runs the discourse_docker launcher from the self-hosted runner (host-mode):
# /var/discourse and docker.sock are mounted from the host, so the launcher's host paths are the real ones.
ARG BASE=docker:cli
FROM ${BASE}
RUN apk add --no-cache bash git python3 coreutils sed grep gawk procps
WORKDIR /src
COPY app.yml render.py install.sh configure.py ./
ENTRYPOINT ["bash", "/src/install.sh"]

View file

@ -1,26 +1,27 @@
## SQLInsight Pro community forum: Discourse, installed with the official discourse_docker launcher.
## See docs/community.md. Copy to /var/discourse/containers/app.yml, fill in the values marked CHANGE, and run
## ./launcher rebuild app
## (deploy/forum/install.sh does this for you.)
## SQLInsight Pro community forum (forum.sqlinsight.seosoft.dk): Discourse with the official discourse_docker launcher.
## See docs/community.md.
##
## This is a template: the double-braced names are filled in by render.py from environment variables (Forgejo secrets in
## .forgejo/workflows/deploy-forum.yml, or exported by hand before deploy/forum/install.sh). Nothing secret is committed.
##
## The forum runs behind SEOSoft's reverse proxy, which terminates TLS (like the hub). The proxy must send
## Host, X-Forwarded-For and X-Forwarded-Proto: https, and allow WebSocket/long polling (/message-bus/).
templates:
- "templates/postgres.template.yml"
- "templates/redis.template.yml"
- "templates/web.template.yml"
- "templates/web.ratelimited.template.yml"
## TLS with Let's Encrypt. Remove both lines if a reverse proxy in front already terminates TLS.
- "templates/web.ssl.template.yml"
- "templates/web.letsencrypt.ssl.template.yml"
## Only on the DMZ address, where the reverse proxy reaches it.
expose:
- "80:80"
- "443:443"
- "{{FORUM_BIND}}:{{FORUM_PORT}}:80"
params:
## Danish stemming for search.
db_default_text_search_config: "pg_catalog.danish"
## About 25 % of the server's memory.
db_shared_buffers: "512MB"
## About 25 % of the memory Discourse may use.
db_shared_buffers: "{{FORUM_DB_SHARED_BUFFERS}}"
env:
LC_ALL: en_US.UTF-8
@ -28,24 +29,22 @@ env:
LANGUAGE: en_US.UTF-8
DISCOURSE_DEFAULT_LOCALE: da
## About 2 per CPU core.
UNICORN_WORKERS: 4
UNICORN_WORKERS: {{FORUM_WORKERS}}
## CHANGE: the forum's address (a DNS record must point to this server).
DISCOURSE_HOSTNAME: forum.sqlinsight.seosoft.dk
DISCOURSE_HOSTNAME: '{{FORUM_HOSTNAME}}'
## TLS is terminated by the reverse proxy; all links must still be https.
DISCOURSE_FORCE_HTTPS: true
## CHANGE: who becomes administrator when signing up with these addresses.
DISCOURSE_DEVELOPER_EMAILS: "admin@example.com"
## Who becomes administrator when signing up with these addresses (comma separated).
DISCOURSE_DEVELOPER_EMAILS: '{{FORUM_ADMIN_EMAIL}}'
## CHANGE: mail server. Discourse cannot work without it (sign-up, notifications, password reset).
DISCOURSE_SMTP_ADDRESS: smtp.example.com
DISCOURSE_SMTP_PORT: 587
DISCOURSE_SMTP_USER_NAME: forum@example.com
DISCOURSE_SMTP_PASSWORD: "CHANGE"
## Mail server. Discourse cannot work without it (sign-up, notifications, password reset).
DISCOURSE_SMTP_ADDRESS: '{{FORUM_SMTP_ADDRESS}}'
DISCOURSE_SMTP_PORT: {{FORUM_SMTP_PORT}}
DISCOURSE_SMTP_USER_NAME: '{{FORUM_SMTP_USER}}'
DISCOURSE_SMTP_PASSWORD: '{{FORUM_SMTP_PASSWORD}}'
DISCOURSE_SMTP_ENABLE_START_TLS: true
DISCOURSE_NOTIFICATION_EMAIL: forum@example.com
## CHANGE: address Let's Encrypt writes to about expiring certificates.
LETSENCRYPT_ACCOUNT_EMAIL: admin@example.com
DISCOURSE_NOTIFICATION_EMAIL: '{{FORUM_NOTIFICATION_EMAIL}}'
volumes:
- volume:
@ -65,4 +64,14 @@ hooks:
run:
- exec: echo "Beginning of custom commands"
## Real client addresses from the reverse proxy. Without this every visitor looks like the proxy, and
## web.ratelimited would throttle them all together.
- replace:
filename: "/etc/nginx/conf.d/discourse.conf"
from: /server \{/
to: |
server {
set_real_ip_from {{KNOWN_PROXY}};
real_ip_header X-Forwarded-For;
real_ip_recursive on;
- exec: echo "End of custom commands"

View file

@ -1,46 +1,44 @@
#!/usr/bin/env bash
# Installs the SQLInsight Pro forum (Discourse) on a fresh Linux server with the official discourse_docker launcher.
# Run as root on the forum server, from a checkout of this repository, after filling in deploy/forum/app.yml:
# sudo deploy/forum/install.sh
# Needs: DNS for the forum's host name pointing here, ports 80 and 443 free, 2 GB memory (4 GB recommended) and a mail server.
# Installs or rebuilds the SQLInsight Pro forum (Discourse) with the official discourse_docker launcher.
#
# Normally run by .forgejo/workflows/deploy-forum.yml (in a helper container with /var/discourse and docker.sock
# from the host). By hand, as root on the server, with the values render.py needs exported:
# sudo -E deploy/forum/install.sh
# Needs 4 GB memory (2 GB minimum), 20 GB disk, and a mail server.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
TARGET=/var/discourse
if [[ $EUID -ne 0 ]]; then
echo "Kør som root (sudo)." >&2
echo "Kør som root (sudo -E)." >&2
exit 1
fi
# Refuse to install with the example values still in place: Discourse is useless without a working mail server.
if grep -nE 'example\.com|"CHANGE"' "$HERE/app.yml" | grep -vE '^[0-9]+:\s*#'; then
echo "Udfyld værdierne markeret CHANGE i $HERE/app.yml først (linjerne ovenfor)." >&2
exit 1
fi
if ! command -v docker >/dev/null; then
echo "Installerer Docker ..."
curl -fsSL https://get.docker.com | sh
fi
command -v docker >/dev/null || { echo "Docker mangler." >&2; exit 1; }
if [[ ! -d $TARGET/.git ]]; then
git clone --depth 1 https://github.com/discourse/discourse_docker.git "$TARGET"
else
git -C "$TARGET" pull -q --ff-only
fi
install -m 600 "$HERE/app.yml" "$TARGET/containers/app.yml"
cd "$TARGET"
./launcher rebuild app
mkdir -p "$TARGET/containers"
python3 "$HERE/render.py" "$HERE/app.yml" "$TARGET/containers/app.yml"
host=$(sed -n 's/^\s*DISCOURSE_HOSTNAME:\s*//p' "$HERE/app.yml" | tr -d '"' | head -1)
cd "$TARGET"
# The launcher asks before continuing on low memory; answer yes when running unattended.
./launcher rebuild app --skip-prereqs
host="${FORUM_HOSTNAME:-forum.sqlinsight.seosoft.dk}"
cat <<NEXT
Discourse kører. Næste skridt:
1. Åbn https://$host og opret den første administrator med en af adresserne i DISCOURSE_DEVELOPER_EMAILS.
2. Lav en API-nøgle til opsætningen:
cd $TARGET && ./launcher enter app
cd /var/www/discourse && su discourse -c 'RAILS_ENV=production bundle exec rake api_key:create_master[forum-setup]'
3. Opsæt kategorier, sprog og velkomstemne (kan køres igen):
DISCOURSE_URL=https://$host DISCOURSE_API_KEY=<nøglen> CONTACT_EMAIL=<supportadresse> python3 $HERE/configure.py
4. Slet API-nøglen igen under Admin → API, når opsætningen er færdig.
Discourse kører på ${FORUM_BIND}:${FORUM_PORT}. Første gang:
1. Peg reverse proxyen for $host på http://${FORUM_BIND}:${FORUM_PORT} (med X-Forwarded-Proto: https og WebSockets).
2. Åbn https://$host og opret administratoren med adressen i FORUM_ADMIN_EMAIL.
3. Lav en API-nøgle til opsætningen:
docker exec app su discourse -c 'cd /var/www/discourse && RAILS_ENV=production bundle exec rake api_key:create_master[forum-setup]'
4. Opsæt kategorier, sprog og velkomstemne (kan køres igen):
DISCOURSE_URL=https://$host DISCOURSE_API_KEY=<nøglen> CONTACT_EMAIL=<supportadresse> python3 deploy/forum/configure.py
5. Slet API-nøglen igen under Admin → API.
NEXT

65
deploy/forum/render.py Executable file
View file

@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Fills in deploy/forum/app.yml from environment variables and writes the result (mode 600).
render.py <template> <output>
Required: FORUM_BIND FORUM_PORT KNOWN_PROXY FORUM_ADMIN_EMAIL FORUM_SMTP_ADDRESS FORUM_SMTP_USER FORUM_SMTP_PASSWORD
Optional: FORUM_HOSTNAME (forum.sqlinsight.seosoft.dk), FORUM_SMTP_PORT (587), FORUM_NOTIFICATION_EMAIL (FORUM_SMTP_USER),
FORUM_WORKERS (4), FORUM_DB_SHARED_BUFFERS (512MB)
"""
import ipaddress
import os
import re
import sys
DEFAULTS = {
"FORUM_HOSTNAME": "forum.sqlinsight.seosoft.dk",
"FORUM_SMTP_PORT": "587",
"FORUM_WORKERS": "4",
"FORUM_DB_SHARED_BUFFERS": "512MB",
}
# Values that end up unquoted in YAML or in nginx/docker syntax must look exactly like this.
FORMATS = {
"FORUM_PORT": r"\d{1,5}",
"FORUM_SMTP_PORT": r"\d{1,5}",
"FORUM_WORKERS": r"\d{1,2}",
"FORUM_DB_SHARED_BUFFERS": r"\d+[MG]B",
"FORUM_HOSTNAME": r"[a-z0-9.-]+",
}
def value(name):
v = os.environ.get(name) or DEFAULTS.get(name)
if name == "FORUM_NOTIFICATION_EMAIL" and not v:
v = os.environ.get("FORUM_SMTP_USER")
if not v:
sys.exit(f"ERROR: {name} is not set")
if "\n" in v or "{{" in v:
sys.exit(f"ERROR: {name} contains a line break or {{{{")
if name in FORMATS and not re.fullmatch(FORMATS[name], v):
sys.exit(f"ERROR: {name} has an unexpected format")
if name in ("FORUM_BIND", "KNOWN_PROXY"):
try:
ipaddress.ip_network(v, strict=False) if name == "KNOWN_PROXY" else ipaddress.ip_address(v)
except ValueError:
sys.exit(f"ERROR: {name} is not an IP address" + (" or network" if name == "KNOWN_PROXY" else ""))
# All free-text values sit in single-quoted YAML strings.
return v.replace("'", "''")
def main():
if len(sys.argv) != 3:
sys.exit(__doc__)
template, output = sys.argv[1], sys.argv[2]
with open(template, encoding="utf-8") as f:
text = f.read()
names = sorted(set(re.findall(r"\{\{([A-Z_]+)\}\}", text)))
text = re.sub(r"\{\{([A-Z_]+)\}\}", lambda m: value(m.group(1)), text)
fd = os.open(output, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(text)
print(f"{output}: {len(names)} values filled in")
if __name__ == "__main__":
main()

View file

@ -2,35 +2,53 @@
SQLInsight Pro har to offentlige websteder. Begge drives af SEOSoft:
| Websted | Adresse (eksempel) | Hvad |
| Websted | Adresse | Hvad |
|---|---|---|
| Community-webstedet | `https://sqlinsight.seosoft.dk` | Forside, funktioner, download af signerede udgivelser, dokumentation, SQL Server-versioner og supportdatoer, regler og supportaftaler. Projektet `src/SQLInsight.Community`. |
| Forummet | `https://forum.sqlinsight.seosoft.dk` | Discourse på dansk: spørgsmål og svar (med løste svar), installation og drift, performance, regler, idéer og annonceringer. |
Adresserne er eksempler. Ret dem i `deploy/forum/app.yml`, `deploy/community/docker-compose.yml` og i webbrugerfladens `SQLInsight:Web:CommunityUrl` og `SQLInsight:Web:ForumUrl`.
Alle tre navne (også `hub.sqlinsight.seosoft.dk`) peger på SEOSofts offentlige IP. Reverse proxyen dér terminerer TLS og sender videre til appserverens DMZ-adresse (`HOST_BIND`), på hver sin port:
Kør de to på hver sin server (eller VM). Discourse vil selv eje port 80 og 443 og er svær at dele en server med.
| Navn | Container | Port (secret) | Deploy |
|---|---|---|---|
| `hub.sqlinsight.seosoft.dk` | `sqlinsight-hub` | `HOST_PORT` | `deploy-hub.yml`, ved push |
| `sqlinsight.seosoft.dk` | `sqlinsight-community` | `COMMUNITY_PORT` | `deploy-community.yml`, ved push |
| `forum.sqlinsight.seosoft.dk` | `app` (Discourse) | `FORUM_PORT` | `deploy-forum.yml`, kun manuelt |
Proxyen skal sende `Host`, `X-Forwarded-For` og `X-Forwarded-Proto: https`. For forummet skal den også tillade WebSockets og lange forespørgsler (Discourse bruger long polling på `/message-bus/`, så timeout skal være mindst 60 sekunder).
## Forummet (Discourse)
Discourse installeres med den officielle `discourse_docker`-launcher. Det er den eneste måde, Discourse understøtter.
Discourse installeres med den officielle `discourse_docker`-launcher, som er den eneste måde, Discourse understøtter. Workflowet `deploy-forum.yml` kører launcheren på appserveren i en helper-container, med `/var/discourse` og docker.sock fra værten. `deploy/forum/app.yml` er en skabelon, som `render.py` udfylder fra secrets. Intet hemmeligt ligger i repoet.
1. **Server:** Ubuntu LTS eller Debian, 2 GB RAM (4 GB anbefales), 2 CPU'er og 20 GB disk.
2. **DNS:** Opret en A-post (og evt. AAAA) for forummets navn, der peger på serveren.
3. **Mail:** Discourse kan ikke fungere uden SMTP. Tilmelding, notifikationer og nulstilling af adgangskode sker på mail. Brug en rigtig mailudbyder med SPF og DKIM for afsenderdomænet.
4. **Konfiguration:** Udfyld værdierne markeret `CHANGE` i `deploy/forum/app.yml`: værtsnavn, administratoradresse, SMTP og Let's Encrypt-adresse.
5. **Installation:** Kør `sudo deploy/forum/install.sh`. Scriptet:
- stopper, hvis der stadig står eksempelværdier;
- installerer Docker, hvis det mangler;
- kloner `discourse_docker` til `/var/discourse` og kopierer `app.yml` på plads;
- kører `./launcher rebuild app`. Det tager 10-15 minutter.
6. **Administrator:** Åbn forummet, og opret en bruger med en af adresserne i `DISCOURSE_DEVELOPER_EMAILS`. Den bliver administrator.
7. **Opsætning som kode:** Lav en midlertidig API-nøgle (scriptet skriver kommandoen), og kør:
1. **Krav:** Discourse bruger 2-4 GB RAM og 20 GB disk under `/var/discourse` på appserveren.
2. **Mail:** Discourse kan ikke fungere uden SMTP. Tilmelding, notifikationer og nulstilling af adgangskode sker på mail. Opret en postkasse til forummet, og sørg for SPF og DKIM for afsenderdomænet.
3. **Secrets** i repoet (*Indstillinger → Actions → Secrets*):
| Secret | Eksempel | |
|---|---|---|
| `HOST_BIND`, `KNOWN_PROXY` | (findes allerede til hubben) | DMZ-adresse og reverse proxyens adresse |
| `FORUM_PORT` | `8082` | Port på DMZ-adressen |
| `FORUM_ADMIN_EMAIL` | din adresse | Bliver administrator ved tilmelding |
| `FORUM_SMTP_ADDRESS`, `FORUM_SMTP_USER`, `FORUM_SMTP_PASSWORD` | | Mailserver (STARTTLS) |
| `FORUM_SMTP_PORT` | `587` | Valgfri |
| `FORUM_NOTIFICATION_EMAIL` | `forum@seosoft.dk` | Valgfri, ellers `FORUM_SMTP_USER` |
| `FORUM_WORKERS`, `FORUM_DB_SHARED_BUFFERS` | `4`, `512MB` | Valgfri: ca. 2 pr. CPU-kerne og 25 % af den RAM, Discourse må bruge |
4. **Reverse proxy:** Peg `forum.sqlinsight.seosoft.dk` på `http://HOST_BIND:FORUM_PORT`.
5. **Installation:** Kør *Actions → deploy forum → Run workflow*. Første gang tager det 10-15 minutter. Det samme workflow bruges, når `app.yml` eller secrets ændres. Selve Discourse opdateres fra */admin/upgrade*.
6. **Administrator:** Åbn forummet, og opret en bruger med `FORUM_ADMIN_EMAIL`. Den bliver administrator.
7. **Opsætning som kode:** Lav en midlertidig API-nøgle på appserveren:
```
docker exec app su discourse -c 'cd /var/www/discourse && RAILS_ENV=production bundle exec rake api_key:create_master[forum-setup]'
```
Kør derefter:
```
DISCOURSE_URL=https://forum.sqlinsight.seosoft.dk DISCOURSE_API_KEY=... \
COMMUNITY_URL=https://sqlinsight.seosoft.dk CONTACT_EMAIL=support@... \
python3 deploy/forum/configure.py
CONTACT_EMAIL=support@... python3 deploy/forum/configure.py
```
`configure.py` gør følgende:
@ -41,8 +59,10 @@ Discourse installeres med den officielle `discourse_docker`-launcher. Det er den
Scriptet kan køres igen. Det ændrer kun det, der afviger. Slet API-nøglen bagefter under *Admin → API*.
Uden Forgejo: Eksportér de samme værdier (`FORUM_BIND` i stedet for `HOST_BIND`), og kør `sudo -E deploy/forum/install.sh` på serveren.
**Drift:**
- Opdatering: `cd /var/discourse && git pull && ./launcher rebuild app`, eller via */admin/upgrade*.
- Opdatering: via */admin/upgrade*. Discourses basisimage opdateres ved at køre `deploy-forum.yml` igen.
- Backup: Discourse tager dagligt backup til `/var/discourse/shared/standalone/backups`. Slå upload til S3 eller kopiering væk fra serveren til under *Admin → Backups*.
- Logge: `./launcher logs app`.
@ -60,16 +80,19 @@ Webstedet er en lille ASP.NET Core-applikation uden database. Det viser:
- **Dokumentation:** Filerne i `docs/`, bygget ind i programmet. Links mellem dokumenterne bliver til sider, og links til andre filer i repositoriet går til Forgejo. HTML i Markdown vises ikke.
- **Forum:** De seneste emner fra forummets offentlige `/latest.json`. Hvis forummet er nede, virker webstedet stadig.
Kør det med Docker på sin egen server. Caddy henter og fornyr selv TLS-certifikatet:
`deploy-community.yml` bygger og starter webstedet ved hvert push til `main`, der ændrer kode, dokumentation eller regler. Det kræver tre secrets:
- `HOST_BIND` (findes allerede);
- `COMMUNITY_PORT`, fx `8081`;
- `COMMUNITY_CONTACT`: adressen til henvendelser om supportaftaler.
```
cd deploy/community
COMMUNITY_HOST=sqlinsight.seosoft.dk COMMUNITY_CONTACT=salg@... docker compose up -d
```
Peg `sqlinsight.seosoft.dk` på `http://HOST_BIND:COMMUNITY_PORT` i reverse proxyen.
Webstedet læser hubbens feed-mappe (`/opt/sqlinsight-hub/data/feed`) direkte og skrivebeskyttet. Download-links går til `https://hub.sqlinsight.seosoft.dk/updates/`. Dermed afhænger webstedet ikke af, at appserveren kan nå sin egen offentlige adresse. Forumemnerne på forsiden hentes derimod fra forummets offentlige adresse. Kan appserveren ikke nå den, vises forsiden bare uden emner.
| Indstilling (`SQLInsight:Community:…`) | Standard | Betydning |
|---|---|---|
| `Feed` | (ingen) | Det signerede opdateringsfeed. Normalt hubbens `https://<hub>/updates/`, eller en mappe. Uden feed vises ingen udgivelser, og versionssiden bruger den indbyggede regelpakke. |
| `Feed` | (ingen) | Det signerede opdateringsfeed: en mappe eller en https-adresse. Uden feed vises ingen udgivelser, og versionssiden bruger den indbyggede regelpakke. |
| `DownloadUrl` | `Feed` | Offentlig adresse til download-links, når `Feed` er en mappe eller en intern adresse. |
| `ForumUrl` | (ingen) | Forummets adresse. Bruges til links, `/forum` og de seneste emner. |
| `SourceUrl` | Forgejo-repositoriet | Link til kildekoden og til filer, som dokumentationen henviser til. |
| `ContactEmail` | `salg@example.com` | Adressen på supportsiden. Skal sættes. |

View file

@ -7,6 +7,12 @@ public sealed class CommunityOptions
/// <summary>The signed update feed (https or folder), where releases and rule packs are published.</summary>
public string? Feed { get; set; }
/// <summary>
/// Public address of the feed, for download links, when <see cref="Feed"/> is a folder or an internal address
/// (e.g. https://hub.sqlinsight.seosoft.dk/updates/). Defaults to <see cref="Feed"/> when that is http(s).
/// </summary>
public Uri? DownloadUrl { get; set; }
/// <summary>The Discourse forum, e.g. https://forum.sqlinsight.seosoft.dk/.</summary>
public Uri? ForumUrl { get; set; }

View file

@ -45,9 +45,11 @@ public sealed partial class ReleaseService(IOptions<CommunityOptions> options, I
public Uri? DownloadUrl(FileRef file)
{
ArgumentNullException.ThrowIfNull(file);
return Uri.TryCreate(options.Value.Feed, UriKind.Absolute, out var root) && root.Scheme is "https" or "http"
? new Uri(new Uri(root.AbsoluteUri.EndsWith('/') ? root.AbsoluteUri : root.AbsoluteUri + "/"), file.Path)
: null;
var root = options.Value.DownloadUrl
?? (Uri.TryCreate(options.Value.Feed, UriKind.Absolute, out var feed) && feed.Scheme is "https" or "http" ? feed : null);
return root is null
? null
: new Uri(new Uri(root.AbsoluteUri.EndsWith('/') ? root.AbsoluteUri : root.AbsoluteUri + "/"), file.Path);
}
private async Task<FeedState> LoadAsync(CancellationToken cancellationToken)

View file

@ -81,6 +81,17 @@ public sealed class SiteTests : IDisposable
}
}
[Fact]
public async Task A_folder_feed_links_downloads_to_the_public_address()
{
_feed.Publish("stable", release: _feed.MsiRelease("1.2.0"));
using var factory = Site(downloadUrl: "https://hub.example/updates/");
var html = await Get(factory, "/download");
Assert.Contains("href=\"https://hub.example/updates/releases/SQLInsight-Pro-1.2.0-x64.msi\"", html, StringComparison.Ordinal);
}
[Fact]
public async Task A_manifest_with_a_bad_signature_is_not_shown()
{
@ -181,7 +192,7 @@ public sealed class SiteTests : IDisposable
Assert.Equal(new Uri("https://forum.example/"), response.Headers.Location);
}
private WebApplicationFactory<Program> Site(bool trustKey = true)
private WebApplicationFactory<Program> Site(bool trustKey = true, string? downloadUrl = null)
{
var settings = new Dictionary<string, string?>
{
@ -189,6 +200,11 @@ public sealed class SiteTests : IDisposable
["SQLInsight:Community:ForumUrl"] = "https://forum.example/",
["SQLInsight:Community:SourceUrl"] = "https://git.example/seo/sqlinsight-pro",
};
if (downloadUrl is not null)
{
settings["SQLInsight:Community:DownloadUrl"] = downloadUrl;
}
if (trustKey)
{
settings[$"SQLInsight:Community:TrustedKeys:{TestFeed.KeyId}"] = _feed.PublicKey;