Compare commits

...

10 commits

Author SHA1 Message Date
Tero Halla-aho
be0b194737 Move Forgejo SSH to port 2223 and document conflicts
Some checks are pending
CI / checks (push) Waiting to run
2025-12-11 22:16:18 +02:00
Tero Halla-aho
47b5fd7f87 Move Forgejo HTTP port to 3200 to avoid conflicts 2025-12-11 22:13:19 +02:00
Tero Halla-aho
c626b84324 Add Forgejo deployment scaffolding and CI workflow 2025-12-11 22:10:50 +02:00
Tero Halla-aho
562452c6c7 Add footer cookie notice 2025-12-11 21:32:10 +02:00
Tero Halla-aho
0d332dfe85 Add reports index generation 2025-12-11 21:08:02 +02:00
Tero Halla-aho
6a5003ffda Add placeholder test script 2025-12-11 20:40:24 +02:00
Tero Halla-aho
dbb2781c23 Add console summary to test suite runner 2025-12-11 20:36:04 +02:00
Tero Halla-aho
c3ac96ec02 Add helper for per-user age keys 2025-12-11 13:51:35 +02:00
Tero Halla-aho
728cb73faf Wire sops+age for secrets 2025-12-11 13:37:55 +02:00
Tero Halla-aho
f3437f2f0e Add unified secrets dotenv loader 2025-12-10 16:05:29 +02:00
22 changed files with 503 additions and 21 deletions

View file

@ -5,5 +5,38 @@ NEXT_PUBLIC_API_BASE=https://api.lomavuokraus.fi
# Runtime env flag used in UI
APP_ENV=local
# Secrets (override in Kubernetes Secret)
APP_SECRET=change-me
# Core app secrets (override in Kubernetes Secret)
APP_URL=http://localhost:3000
AUTH_SECRET=change-me
DATABASE_URL=postgresql://user:password@host:5432/lomavuokraus?sslmode=disable
# Mail (fill in SMTP_USER/SMTP_PASS)
SMTP_HOST=smtp.lomavuokraus.fi
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM=noreply@lomavuokraus.fi
SMTP_TLS=true
SMTP_SSL=false
SMTP_REJECT_UNAUTHORIZED=true
DKIM_SELECTOR=mail2025
DKIM_DOMAIN=lomavuokraus.fi
DKIM_PRIVATE_KEY_PATH=creds/dkim/lomavuokraus.fi/mail2025.private
# Feature flags / behaviour
AUTO_APPROVE_LISTINGS=false
# External APIs / infra
OPENAI_API_KEY=
OPENAI_TRANSLATIONS_KEY=
HETZNER_API_TOKEN=
HCLOUD_TOKEN=
HETZNER_TOKEN=
JOKER_DYNDNS_USERNAME=
JOKER_DYNDNS_PASSWORD=
REGISTRY_USERNAME=
REGISTRY_PASSWORD=
# Admin bootstrap (used by seed/reset scripts)
ADMIN_EMAIL=
ADMIN_INITIAL_PASSWORD=

18
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,18 @@
name: CI
on:
push:
pull_request:
jobs:
checks:
runs-on: docker
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm run lint
- run: npm run type-check
- run: npm run format:check

1
.gitignore vendored
View file

@ -18,6 +18,7 @@ coverage
deploy/.last-image
creds/
!creds/secrets.enc.env
k3s.yaml
# Local-only documentation (now tracked in docs/)

7
.sops.yaml Normal file
View file

@ -0,0 +1,7 @@
creation_rules:
- paths:
- creds/secrets.enc.env
key_groups:
- age:
- age1hkehkc2rryjl975c2mg5cghmjr54n4wjshncl292h2eg5l394fhs4uydrh
encrypted_regex: '^(AUTH_SECRET|DATABASE_URL|DB_.*|APP_URL|SMTP_.*|DKIM_.*|AUTO_APPROVE_LISTINGS|OPENAI_.*|H(ETZNER|CLOUD)_TOKEN|JOKER_DYNDNS_.*|REGISTRY_.*|NETDATA_.*|ADMIN_.*)$'

View file

@ -22,7 +22,7 @@
- Pushes (docker, ctr, skopeo from k3s node) fail: connection closed while uploading blobs (`http://registry.halla-aho.net:443/... use of closed network connection`). Suspect registry reverse-proxy dropping uploads/HTTPS handling.
- Need to inspect registry host logs/config and retry push once fixed.
- Secrets:
- `APP_SECRET` removed from `deploy/env.sh`; export it in shell before deploy.
- `AUTH_SECRET` removed from `deploy/env.sh`; export it in shell (or via `scripts/load-secrets.sh`) before deploy.
- `creds/` and `k3s.yaml` are git-ignored; contains joker DYNDNS creds and registry auth.
# Lomavuokraus app progress (Nov 24)
@ -81,3 +81,5 @@
- Added site favicon generated from the updated logo (`public/favicon.ico`).
- New admin monitoring dashboard at `/admin/monitor` surfaces Hetzner node status, Kubernetes nodes/pods health, and PostgreSQL connection/size checks with auto-refresh.
- Netdata installed on k3s node (`node1.lomavuokraus.fi:8443`) and DB host (`db1.lomavuokraus.fi:8443`) behind self-signed TLS + basic auth; DB Netdata includes Postgres metrics via dedicated `netdata` role.
- Footer now includes a minimal cookie usage statement (essential cookies only; site requires acceptance).
- Forgejo deployment scaffolding added: Docker Compose + runner config guidance and Apache vhost for git.halla-aho.net, plus CI workflow placeholder under `.forgejo/workflows/`.

View file

@ -19,6 +19,7 @@ export default function SiteFooter() {
Version <code>{version}</code>
</span>
</div>
<p className="footer-cookie">{t('footerCookieNotice')}</p>
</footer>
);
}

View file

@ -466,6 +466,13 @@ p {
font-size: 12px;
}
.footer-cookie {
margin: 12px 0 0;
font-size: 12px;
color: var(--muted);
line-height: 1.4;
}
.privacy-block ul {
margin: 8px 0 0;
padding-left: 18px;

44
creds/secrets.enc.env Normal file
View file

@ -0,0 +1,44 @@
# Encrypted with sops (age). To edit: sops creds/secrets.enc.env
AUTH_SECRET=ENC[AES256_GCM,data:DQl4MEDrsUe76/NYdI03BZb31/NTTA0nBTUALhsULvT1EvMRRiR1,iv:rXjuUm3Z5OFMa0Jc/h8BSF1DWxddrtH2DyvPwX8KZ3c=,tag:eL7NZj0WE44y9AxVoCXrFA==,type:str]
DATABASE_URL=ENC[AES256_GCM,data:87ysYMPY0lI8LrL46dHPjcZOYo4IlP/aQ4h+psSXS4EKzUiBJuExtF8f+kAZNJBc4Xj+B82Q34CfbbsBC8NdQoeebqvXF6/0IluvmpCBhpN2MKWqwlx/mQZTENZA3J0QiAP+BapeZXuXLc//,iv:n4W+hyWf1oH8IahJJs1XQ3d3i5hJjLXaNE+b4Z66Zbw=,tag:JYsBd5d0APkikYYg8Ea/Fg==,type:str]
DB_HOST=ENC[AES256_GCM,data:qlmMVJEkDwfiRYGFUdP/,iv:syUEM2Cx64jE7IW8zRSqfe9uAL+JEMl9Tu5nw12kdWI=,tag:VoAJgXgGy8OXksUvn/SpUQ==,type:str]
DB_PORT=ENC[AES256_GCM,data:vLjEIHpK,iv:ym9R0E/S5WKLKF+0thz9tmwI4pYa7skrY4sDpY42y0E=,tag:SK8gDGrnx8/YAPi8qB7KeQ==,type:str]
DB_USER=ENC[AES256_GCM,data:KIlxtjxjx0EIDxXxHKY=,iv:dlhX4pIe6AE8fzT+TVB3hg9gJ46sq85Qp8f4pkxO9n8=,tag:WCMaOPpnI8SA8WMRDA392w==,type:str]
DB_PASS=ENC[AES256_GCM,data:CJEh2+yQrPptlTnthe2UWsc/baNXBIAC0lpxtm8DI47gTg==,iv:UPrDDlMbWaiAiz5QcjRqXRIiVohMRF7wtmi5ioONcLE=,tag:jLSfXcefy8+cll8bVclkSQ==,type:str]
APP_URL=ENC[AES256_GCM,data:1vZsUQ/d4MTbBM+juPJeVwseAVz4O2M=,iv:KrsOr3w1i3j3R19KAowFxFbdSm1B9IikqRoqU9tmQus=,tag:BwR3v/2R7bAFxHnB5waFIA==,type:str]
SMTP_HOST=ENC[AES256_GCM,data:H+47VDofg1EEBo6q1UEleQ==,iv:KlAxFj/bKOMikubGbqPYkKPZt18oIdXxrLjEVnbxhN8=,tag:LpC930G7lsjt9YuSEFxW8A==,type:str]
SMTP_PORT=ENC[AES256_GCM,data:F6fiIz4=,iv:Pelfn6ciIGvqF4nMPX5WrsWqkLtAxPBmBOSBvn4RUmo=,tag:UWBGXnDAYf5zCQJagiSE9Q==,type:str]
SMTP_USER=ENC[AES256_GCM,data:CnbiHSRC0w/TOwwUwto=,iv:s8tJxs3X3BaFf5LrR+hHVvRIbDEfNI6ZszdTi+lg3PQ=,tag:DAbkk3szBzmhUrNTPZdA6A==,type:str]
SMTP_PASS=ENC[AES256_GCM,data:GmmDQ2mROFz9zpGpXgtwlUvU2dmWg9KHsw==,iv:pl7O1QCjkaaqZ0sGS6kIBUKJ5glE8FTyA1vCYz0BfoU=,tag:/eE1ZWrLlKsa043VY3p3DQ==,type:str]
SMTP_FROM=ENC[AES256_GCM,data:XY9BFPRmpO5KblsSMB2680DNx/bvUndfXA==,iv:yCUsaOMu1MrHtfR9AwfuIscyiEqsl0OYvO8UiKO6IYk=,tag:MpEIu2UJXEjE+9n1MVaHRQ==,type:str]
SMTP_TLS=ENC[AES256_GCM,data:sBPcDhWs,iv:lZRjMBtrtAinC1blRFE0RH5jzyEcdLf+UOgEHuKitEk=,tag:R3Q4+ri3m5LB+xzohsYJHA==,type:str]
SMTP_SSL=ENC[AES256_GCM,data:dYAo31bHTw==,iv:OG0c0HYzewLwlsgPlyn0nz+vwdeuSCjJ5ifdE9bfQvI=,tag:EJ5CX6+weXcrcjdrr1uicg==,type:str]
SMTP_REJECT_UNAUTHORIZED=ENC[AES256_GCM,data:HhBgopyX,iv:7W9TYVN1aEH5QWK1NXGE9mw9gCd7yagg/LFVNHSdMV4=,tag:rcWGdoXH2wXtqWaImLEfHA==,type:str]
DKIM_SELECTOR=ENC[AES256_GCM,data:taqEDa6Ah1G6yQ==,iv:n4KfE3lFBIyndBFkgvoBvzAlqP2HV4RIyL8c7lLoQSU=,tag:nos80rdqlj7YQshANBoTvw==,type:str]
DKIM_DOMAIN=ENC[AES256_GCM,data:ZQkoRT8bQ7/Kdt0O/M4kRB8=,iv:wSbk2ALpbcxRT8neFrcQBEj8ja54ZtWeKTfllm8Pr5s=,tag:o5ZvS1enT31pV4+EM8i5LA==,type:str]
DKIM_PRIVATE_KEY_PATH=ENC[AES256_GCM,data:8hbyhw/+I1VWGzoDNIKMY3avRvSmt17EmHiwv7WpeJZzalEXqTmDB+bpkVEQ,iv:/7bUU6boXo1HPccvNBCDnLyql0hGaMXmGIeQEqpFnwM=,tag:nTE+dR0JB/Y1ri5h/FaC3A==,type:str]
AUTO_APPROVE_LISTINGS=ENC[AES256_GCM,data:bAHiVYhjXw==,iv:/jKFQ5L+meSNF6tn54Dc8dpK1I0+zG/o/F0QqMInmwU=,tag:9f7mb2QN17AoualTjkLeSQ==,type:str]
OPENAI_API_KEY=ENC[AES256_GCM,data:+Avly5xD/jgkeiiHg7WNVH/OyGRf9L+iKDh/SvD7LrFuJlltORwzY253l29vD6cucaYfFanTWwDaV6TKuatNiRXxzy7aSoOfs0y4qonXofIRb3BH2/DarGWxQtRi5LRrXFIpCRQfN7x+XGbeF8ZZBW/DHFYywH0E2fU9li/iafXVOKBRcV4WYTIA3sJJuObHFjxgRYMB2SWtw8noZvkK+pzQi6lCKA==,iv:WTbvAzkjoDLAuDIdbFjt9BU5+2pGDv5Ksc4xru0Wx20=,tag:b8ddMgRoq+kMlS994yPx9A==,type:str]
OPENAI_TRANSLATIONS_KEY=ENC[AES256_GCM,data:QGIhIhzn775yXZq9OYKTwGwcLnQAIJn8TM95TRFb5jWY/gTevspeYLDVrrGDKv/WHX8aTg4r4z9t6cbgbSX677fSlrNy2c2YEb5uCLGh0QUK2LoPt9R9yF/iyi5CU2obRlqamtF6knUGG5mdOCewKx/k+updDLmOe+CPD91H6nWbE9+1Pmbdf22bM+rOt9t4+MS6UP0UbIYU0sBEtfPTvN68260fUA==,iv:eYzIudN/i0A38X1up+Jnxv7qE5aiUEu0WQHEtQ4DvlA=,tag:qM7noB1GvXt2F69aVd0KVQ==,type:str]
HETZNER_API_TOKEN="hoRULGviS8G3OGaJ68josx00M53efhuntVM5Rfft1AOvUR0ZQTXlO6yivhGqBM5o"
HCLOUD_TOKEN=ENC[AES256_GCM,data:0qm+oR2daCd9e0qc+2wApobRod0RsUeA2QL1kQarQW77FvPAgQ7jBkML39kBP3xsYsrdmiZuIdg63Q3xeBQ9mVq2,iv:JUjR7vGyX00TOlUCOD8M9jiB6iio4IiX73sLzW/CADA=,tag:9p0kgDD7ul8s5T0cL7vsfA==,type:str]
HETZNER_TOKEN=ENC[AES256_GCM,data:e95QAx49/qkoBnAmyup7NBs/Xm/fkWutMIWzlAv4P1ybSJNoCyC4ZibpNTCtYtK/3Hux5ItdOBm/jhssjlX363cI,iv:4UzDqJd0mYP3IJgeMn9xz9UjsVyODZ44CDAz5PPaXbk=,tag:ZEAqueoXY4kBfe+89nA+Kg==,type:str]
JOKER_DYNDNS_USERNAME=ENC[AES256_GCM,data:FBAh4B+vxosvxfHbrZukSNea,iv:4FexfnCieAaZ7iUSvaiATr5THpqoHaebX/QigA9Wl58=,tag:dY6lQnHTJWOZa7WhHwPELA==,type:str]
JOKER_DYNDNS_PASSWORD=ENC[AES256_GCM,data:1neBKFvQhFGfOgdPI6EHZKKI,iv:fC1J7QjAoQMUKxx6lMO32n6zjQ5tQa/icBDn19Lih2k=,tag:W110zqgwbsrVbkZzav/F0Q==,type:str]
REGISTRY_USERNAME=ENC[AES256_GCM,data:Mt1mk15DPH8=,iv:Hxg4fRLRZ2hKRACTIIUMSODRBOgRv5Y6KfZ8477cwc0=,tag:Ufq2jYQjYzjiB88PF0gpBA==,type:str]
REGISTRY_PASSWORD=ENC[AES256_GCM,data:4l2oMR65+X+1,iv:cfh6Esayb6zJEqKrtfY0LAncIrPi7lj/ckcbMgTMFys=,tag:vhsiagOyGVjN7+GNPwZ8LA==,type:str]
NETDATA_USER=ENC[AES256_GCM,data:6laH3H7JKgpU,iv:PCI8HL6S68AaBRg11YlNbK9R12G1ROMqRIpXKIlsBDQ=,tag:qL1uWrtWz5+c6f6Xbv4Low==,type:str]
NETDATA_PASS=ENC[AES256_GCM,data:CZxY7WeoHuUNmECOiL6XLVeTt/k5PAHFyV4dVBrF,iv:Zp00OVvA5hI8c9AD4pvDB1s99yw9iOlgBV4K4nbFFVg=,tag:Y077Bp8qk/SoEOajOdvMLg==,type:str]
NETDATA_HOST_NODE1=ENC[AES256_GCM,data:QEHB348q+BQubmvIJkH/8mie2fjb0mE=,iv:ZUNZA64cuflqQq+JS/e0wByTzny0eE/4rvaxzNN1JFw=,tag:YfmJWJCd1MQW2Q2bWferLg==,type:str]
NETDATA_HOST_DB1=ENC[AES256_GCM,data:B8mSqI+rusKxazFcziGabYMM1i1q,iv:1hDrheKjdo7dMF1m7qOWQ9llye2glEQm79LONauLiYk=,tag:H1BVhIKfymLBzGS6CZEp3Q==,type:str]
NETDATA_PG_USER=ENC[AES256_GCM,data:n0G+MZWEWQK9,iv:11id08TbMpc5NUqtKsoXiIFfM5GJCFStzJ5aY1AGplc=,tag:gXWcgEXko4P7qMwVLGnF1Q==,type:str]
NETDATA_PG_PASS=ENC[AES256_GCM,data:ylpbhrcFFKC5qiyt+RRPZhCWt098tPhGWxabgsO31tR7+g==,iv:O4b5DsNfCpqlhZ3GmXMRzp2/fUpMtLnhSqmoaD4+Q04=,tag:TG7ge49hjzkWcBO93hoXuA==,type:str]
NETDATA_PG_ROLE=ENC[AES256_GCM,data:6/ESugRIiDgBM2e8,iv:kXJOKx7fbO4k9zV2APTXCxOrGJ/93dCkmVA33v9fzrY=,tag:HwEWzVagm6cDnPychaekaw==,type:str]
ADMIN_EMAIL=ENC[AES256_GCM,data:AuhvA1iUOnMcE7el4TSE7cbLAg==,iv:eWNxWDA978OWQO10/ywc8CpKuUhC0uTqObmD5Tefgtc=,tag:pI4SXW1ZrZPthD2qRAPrIw==,type:str]
ADMIN_INITIAL_PASSWORD=ENC[AES256_GCM,data:WiJm5VO57/qLu8fK0rZqFDk=,iv:2SwLMvMUjwkEDZo8VRtPHfs+cabz2L+9ZQAaWyn1o9c=,tag:+TunF3tuLxYvMeJsYNQuCQ==,type:str]
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBsNlZ6dWs5VnJXa0hrekdp\nYlpzYll6NUFnTnV0Sk0zbnB2WEw5alVwT2swCm82NmlUbjdabEJ2d1pHTGhYRG1m\nZmIyUHFQcVhvZHNzRTJuZVNqYThaWGMKLS0tIGtHTEhzQnh2SFVxdlBvRlpMblVW\nOWdVY0ErK2pVSzdtckc4Y0lPRTdrdmsKv5M0ojCoW5SQhnjXY116SmjvyCtSnehg\nQqtL6jElOv4MeLASHwYLYzznU6dxkZK3OKvcLh6mu+41Pnbl8u26yw==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_0__map_recipient=age1hkehkc2rryjl975c2mg5cghmjr54n4wjshncl292h2eg5l394fhs4uydrh
sops_encrypted_regex=^(AUTH_SECRET|DATABASE_URL|DB_.*|APP_URL|SMTP_.*|DKIM_.*|AUTO_APPROVE_LISTINGS|OPENAI_.*|H(ETZNER|CLOUD)_TOKEN|JOKER_DYNDNS_.*|REGISTRY_.*|NETDATA_.*|ADMIN_.*)$
sops_lastmodified=2025-12-11T11:36:56Z
sops_mac=ENC[AES256_GCM,data:bnDwxj/t2X+vkq1nd2Bej23GBn3hALXW6PAp4FyoAlvwajztp9U2eyF7voLQDeX1kurVBuACPExzzMnerEXOebF9l5SGcIYfvtVj9kk4I0WRCbVBt/QKgEtqYJ3l1TXrDe8ZPTj6O2rK6WW36RDExFDu3tzzvVEaHErZMjAhD1U=,iv:hBwrHOvabZEqeWHSFGvk5sHYbJiF6/3wY1JXgaevB9w=,tag:2fZQ9AQoHQJ+zte2yLpi5g==,type:str]
sops_version=3.11.0

View file

@ -12,5 +12,5 @@ export APP_ENV="production"
export CLUSTER_ISSUER="$PROD_CLUSTER_ISSUER"
export INGRESS_CLASS
# optionally set APP_SECRET in the environment before running
# optionally set AUTH_SECRET (and other secrets) in the environment before running
bash deploy/deploy.sh

View file

@ -12,5 +12,5 @@ export APP_ENV="staging"
export CLUSTER_ISSUER="$STAGING_CLUSTER_ISSUER"
export INGRESS_CLASS
# optionally set APP_SECRET in the environment before running
# optionally set AUTH_SECRET (and other secrets) in the environment before running
bash deploy/deploy.sh

View file

@ -14,5 +14,5 @@ export CLUSTER_ISSUER="${TEST_CLUSTER_ISSUER}"
export INGRESS_CLASS
export APP_REPLICAS="${APP_REPLICAS:-1}"
# optionally set APP_SECRET and DATABASE_URL (pointing to lomavuokraus_testing) in the environment before running
# optionally set AUTH_SECRET and DATABASE_URL (pointing to lomavuokraus_testing) in the environment before running
bash deploy/deploy.sh

View file

@ -2,6 +2,10 @@
set -euo pipefail
cd "$(dirname "$0")/.."
if [[ -f scripts/load-secrets.sh ]]; then
# Export secrets from creds/secrets.env (dotenv) when available.
source scripts/load-secrets.sh
fi
source deploy/env.sh
if [[ ! -f deploy/.last-image ]]; then

View file

@ -36,6 +36,7 @@ flowchart LR
<ul>
<li>Source: Next.js app with TypeScript and Prisma.</li>
<li>Env: <code>.env</code> (local), K8s Secret <code>lomavuokraus-web-secrets</code> in cluster.</li>
<li>Local secrets: <code>creds/secrets.env</code> (dotenv) loadable via <code>scripts/load-secrets.sh</code>.</li>
<li>Prisma schema: <code>prisma/schema.prisma</code>, migrations in <code>prisma/migrations/</code>.</li>
</ul>
</section>
@ -84,7 +85,7 @@ flowchart LR
<h2>Config & Env Vars</h2>
<ul>
<li>From ConfigMap (public): <code>NEXT_PUBLIC_SITE_URL</code>, <code>NEXT_PUBLIC_API_BASE</code>, <code>APP_ENV</code>.</li>
<li>From Secret: DB URL, AUTH_SECRET, SMTP, DKIM, etc.</li>
<li>From Secret: DB URL, AUTH_SECRET, SMTP, DKIM, etc. (materialize from <code>creds/secrets.env</code>).</li>
<li>App env resolution: <code>process.env.*</code> in Next server code.</li>
</ul>
</section>

41
docs/secrets.md Normal file
View file

@ -0,0 +1,41 @@
# Secrets workflow (sops + age)
## Files
- `creds/age-key.txt`: age private key (keep out of git; store in a password manager). Public key is in the header.
- `creds/secrets.enc.env`: encrypted dotenv managed by sops/age (committable).
- `creds/secrets.env`: decrypted dotenv (git-ignored) produced when loading secrets; not committed.
- Legacy plaintext secrets moved to `creds/deprecated/` for reference.
## Editing secrets
```bash
# Ensure sops+age binaries are available
sops creds/secrets.enc.env
```
Sops will decrypt, open in $EDITOR, and re-encrypt on save. The age recipient is configured in `.sops.yaml`.
## Loading secrets locally
```bash
source scripts/load-secrets.sh
```
This decrypts `creds/secrets.enc.env` to `creds/secrets.env` if needed (requires sops) and exports all variables.
## Adding developers
- Share `creds/age-key.txt` securely (password manager). They need the age secret key to decrypt.
- No change to `.sops.yaml` is needed unless you rotate keys.
## Deploys/CI
- `deploy/deploy.sh` sources `scripts/load-secrets.sh`, so providing `creds/secrets.enc.env` + age key is enough for secret env injection.
## Rotating keys
- Generate a new age key: `age-keygen -o creds/age-key.txt` (keep old backup if you need to reencrypt).
- Update `.sops.yaml` recipient to the new public key.
- Re-encrypt: `SOPS_AGE_KEY_FILE=creds/age-key.txt sops --encrypt --in-place creds/secrets.enc.env`.
## Per-user age keys
- Keys live under `creds/age/<user>.key` (git-ignored) and carry a public key in the header.
- Helper: `./scripts/manage-age-key.sh add alice` generates a key and appends the recipient to `.sops.yaml`.
- Remove: `./scripts/manage-age-key.sh remove alice` deletes the key file and strips the recipient (re-encrypt afterwards).
- List: `./scripts/manage-age-key.sh list`.
- After adding/removing recipients, re-encrypt secrets: `sops --encrypt --in-place creds/secrets.enc.env`.
Share each users private key securely (password manager). Multiple recipients in `.sops.yaml` allow any listed user to decrypt.

View file

@ -27,7 +27,7 @@
<ul>
<li>Script: <code>scripts/run-test-suite.sh</code></li>
<li>Runs: <code>npm audit</code> (high), Trivy fs scan, ZAP baseline.</li>
<li>Outputs: <code>reports/runs/&lt;timestamp&gt;/summary.html</code> with links to all tool reports.</li>
<li>Outputs: <code>reports/runs/&lt;timestamp&gt;/summary.html</code> with links to all tool reports and a textual summary printed to the console. Index of all runs: <code>reports/index.html</code>.</li>
<li>Config:
<ul>
<li><code>TARGET</code>: ZAP target URL (default test env).</li>

55
forgejo/README.md Normal file
View file

@ -0,0 +1,55 @@
Forgejo on halla-aho.net
========================
Lightweight Git hosting + CI with Forgejo (Gitea fork) behind Apache on halla-aho.net.
Whats included
- Docker Compose for Forgejo + SSH and an Actions runner (`forgejo/docker-compose.yml`).
- Apache vhost snippet (added to `default-ssl.conf`) to reverse-proxy `git.halla-aho.net` to the Forgejo container on port 3200.
- SSH for git is exposed on host port 2223 (mapped to container 22); change in compose if that port is taken.
Prereqs
- Docker installed on halla-aho.net.
- SSLMate certs for `git.halla-aho.net` placed on the host (paths referenced in `default-ssl.conf`).
- A DNS record for `git.halla-aho.net` pointing to the server.
Deploy Forgejo
1) Create host dirs for data:
```
sudo mkdir -p /srv/forgejo/data /srv/forgejo/runner
sudo chown -R $USER:$USER /srv/forgejo
```
2) Start the Forgejo service:
```
docker compose -f forgejo/docker-compose.yml up -d forgejo
```
- If port 2223 is already in use, edit `forgejo/docker-compose.yml` (`ports:` and `FORGEJO__SERVER__SSH_PORT`) to another free port, then rerun the command.
3) Configure Apache (already added to `default-ssl.conf`):
- VirtualHost `git.halla-aho.net:9443` proxies to `http://127.0.0.1:3200/`.
- TLS files: `/etc/apache2/ssl/git.halla-aho.net.{crt,key,chain.crt}` (update if different).
- Enable the site and reload Apache.
4) Finish setup in the UI at `https://git.halla-aho.net/`:
- Create the admin user.
- Configure SMTP in the admin UI (Mail settings).
- Set `ROOT_URL`/`SSH_DOMAIN` if you change ports/domains.
Register the Actions runner
1) In Forgejo, create a runner registration token (Site Admin → Runners).
2) Register the runner (writes `/srv/forgejo/runner/config.yaml`):
```
docker compose -f forgejo/docker-compose.yml run --rm runner \
forgejo-runner register \
--instance https://git.halla-aho.net \
--token <REGISTRATION_TOKEN> \
--name halla-runner \
--labels docker \
--config /data/config.yaml
```
3) Start the runner:
```
docker compose -f forgejo/docker-compose.yml up -d runner
```
CI workflow for this repo
- Add workflows under `.forgejo/workflows/`.
- Example included: `ci.yml` runs npm install + lint + type-check + format check on push/PR using the `docker` runner label.

View file

@ -0,0 +1,36 @@
version: "3.8"
services:
forgejo:
image: codeberg.org/forgejo/forgejo:10
container_name: forgejo
restart: unless-stopped
environment:
- USER_UID=1000
- USER_GID=1000
- TZ=Europe/Helsinki
- FORGEJO__SERVER__DOMAIN=git.halla-aho.net
- FORGEJO__SERVER__ROOT_URL=https://git.halla-aho.net/
- FORGEJO__SERVER__HTTP_PORT=3000
- FORGEJO__SERVER__PROTOCOL=http
- FORGEJO__SERVER__SSH_DOMAIN=git.halla-aho.net
- FORGEJO__SERVER__SSH_PORT=2223
- FORGEJO__DATABASE__DB_TYPE=sqlite3
- FORGEJO__DATABASE__PATH=/data/forgejo.db
- FORGEJO__MAILER__ENABLED=false
volumes:
- /srv/forgejo/data:/data
ports:
- "3200:3000" # HTTP (Apache will reverse proxy)
- "2223:22" # SSH for git
runner:
image: codeberg.org/forgejo/runner:4
container_name: forgejo-runner
restart: unless-stopped
depends_on:
- forgejo
volumes:
- /srv/forgejo/runner:/data
- /var/run/docker.sock:/var/run/docker.sock
command: ["forgejo-runner", "daemon", "--config", "/data/config.yaml"]

View file

@ -48,6 +48,8 @@ const baseMessages = {
footerAbout: 'About',
footerPricing: 'Pricing',
footerPrivacy: 'Privacy & cookies',
footerCookieNotice:
'We use only essential cookies for login and security. By using this site you consent; if you do not accept, please do not use the site.',
loginTitle: 'Login',
emailLabel: 'Email',
passwordLabel: 'Password',
@ -389,6 +391,8 @@ const baseMessages = {
footerAbout: 'Tietoa',
footerPricing: 'Hinnasto',
footerPrivacy: 'Tietosuoja ja evästeet',
footerCookieNotice:
'Käytämme vain välttämättömiä evästeitä kirjautumiseen ja turvallisuuteen. Käyttämällä sivustoa hyväksyt evästeet; jos et hyväksy, älä käytä sivustoa.',
loginTitle: 'Kirjaudu sisään',
emailLabel: 'Sähköposti',
passwordLabel: 'Salasana',
@ -723,6 +727,8 @@ const svMessages: Record<keyof typeof baseMessages.en, string> = {
evChargingNo: 'Ingen laddning i närheten',
evChargingAny: 'Alla',
evChargingExplain: 'Finns det EV-laddning på plats eller i närheten?',
footerCookieNotice:
'Vi använder endast nödvändiga cookies för inloggning och säkerhet. Genom att använda sajten godkänner du cookies; om du inte gör det, använd inte webbplatsen.',
};
export const messages = { ...baseMessages, sv: svMessages } as const;

View file

@ -9,7 +9,8 @@
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
"format:check": "prettier --check ."
"format:check": "prettier --check .",
"test": "echo \"No tests yet\""
},
"dependencies": {
"@prisma/adapter-pg": "^7.0.0",

31
scripts/load-secrets.sh Normal file
View file

@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Shell helper to export secrets from a single dotenv file.
# Usage: source scripts/load-secrets.sh
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SECRETS_FILE="${SECRETS_FILE:-$ROOT_DIR/creds/secrets.env}"
ENCRYPTED_FILE="${ENCRYPTED_FILE:-$ROOT_DIR/creds/secrets.enc.env}"
ensure_decrypted() {
if [[ -f "$SECRETS_FILE" ]]; then
return 0
fi
if [[ -f "$ENCRYPTED_FILE" ]]; then
if command -v sops >/dev/null 2>&1; then
echo "Decrypting $ENCRYPTED_FILE -> $SECRETS_FILE"
sops -d "$ENCRYPTED_FILE" >"$SECRETS_FILE"
else
echo "sops not found and $SECRETS_FILE is missing. Install sops or set SECRETS_FILE." >&2
return 1
fi
fi
}
ensure_decrypted || exit 0
echo "Loading secrets from $SECRETS_FILE"
set -a
source "$SECRETS_FILE"
set +a

114
scripts/manage-age-key.sh Executable file
View file

@ -0,0 +1,114 @@
#!/usr/bin/env bash
# Manage per-user age keys and sops recipients.
# - Creates a new age keypair under creds/age/<user>.key
# - Updates .sops.yaml recipients list for creds/secrets.enc.env
# - Can delete a user (removes key file and recipient entry)
#
# Usage:
# ./scripts/manage-age-key.sh add <username>
# ./scripts/manage-age-key.sh remove <username>
# ./scripts/manage-age-key.sh list
#
# Notes:
# - Keep private keys out of git; creds/age/ is git-ignored.
# - After add/remove, re-encrypt secrets: sops --encrypt --in-place creds/secrets.enc.env
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
AGE_DIR="$ROOT_DIR/creds/age"
SOPS_CONFIG="$ROOT_DIR/.sops.yaml"
usage() {
sed -n '1,12p' "${BASH_SOURCE[0]}"
exit 1
}
list_keys() {
if [[ -d "$AGE_DIR" ]]; then
find "$AGE_DIR" -maxdepth 1 -type f -name '*.key' -printf '%f\n'
fi
}
ensure_age_dir() {
mkdir -p "$AGE_DIR"
}
add_user() {
local user="$1"
ensure_age_dir
local key_file="$AGE_DIR/${user}.key"
if [[ -f "$key_file" ]]; then
echo "Key already exists: $key_file"
exit 1
fi
echo "Generating age key for ${user} -> ${key_file}"
age-keygen -o "$key_file" >/dev/null
local pub
pub=$(grep '^# public key:' "$key_file" | awk '{print $4}')
echo "Public key: $pub"
if [[ ! -f "$SOPS_CONFIG" ]]; then
echo "sops config not found at $SOPS_CONFIG; skipping recipient update."
echo "Add this recipient manually: $pub"
exit 0
fi
if grep -q "$pub" "$SOPS_CONFIG"; then
echo "Recipient already present in $SOPS_CONFIG"
else
echo "Adding recipient to $SOPS_CONFIG"
# Append under the first age key list.
tmp=$(mktemp)
awk -v pub="$pub" '
/^ *- age:/ { in_age=1; print; next }
in_age && /^ *- age1/ { print " - " pub; in_age=0 }
{ print }
' "$SOPS_CONFIG" >"$tmp"
mv "$tmp" "$SOPS_CONFIG"
fi
echo "Done. Re-encrypt secrets after updating recipients:"
echo " sops --encrypt --in-place creds/secrets.enc.env"
}
remove_user() {
local user="$1"
local key_file="$AGE_DIR/${user}.key"
if [[ -f "$key_file" ]]; then
rm -f "$key_file"
echo "Removed key $key_file"
else
echo "No key file for $user at $key_file"
fi
if [[ -f "$SOPS_CONFIG" ]]; then
local pub
pub=$(grep '^# public key:' "$key_file" 2>/dev/null | awk '{print $4}')
if [[ -n "${pub:-}" ]]; then
tmp=$(mktemp)
awk -v pub="$pub" '!($0 ~ pub)' "$SOPS_CONFIG" >"$tmp"
mv "$tmp" "$SOPS_CONFIG"
echo "Removed recipient from $SOPS_CONFIG (if present)."
fi
fi
echo "Re-encrypt secrets to drop removed recipients if needed:"
echo " sops --encrypt --in-place creds/secrets.enc.env"
}
cmd="${1:-}"
case "$cmd" in
add)
[[ $# -eq 2 ]] || usage
add_user "$2"
;;
remove|rm|delete)
[[ $# -eq 2 ]] || usage
remove_user "$2"
;;
list|ls)
list_keys
;;
*)
usage
;;
esac

View file

@ -21,6 +21,10 @@ RUN_DIR="reports/runs/${RUN_TS}"
mkdir -p "$RUN_DIR"
SUMMARY_ROWS=()
SUMMARY_TEXT_ROWS=()
PASS_COUNT=0
FAIL_COUNT=0
SKIP_COUNT=0
log() {
echo "[$(date +"%H:%M:%S")] $*"
@ -30,7 +34,67 @@ record_result() {
local name="$1"; shift
local status="$1"; shift
local detail="$1"; shift
local detail_text="$1"; shift
SUMMARY_ROWS+=("<tr><td>${name}</td><td>${status}</td><td>${detail}</td></tr>")
SUMMARY_TEXT_ROWS+=("${name}: ${status}${detail_text:+ - ${detail_text}}")
case "$status" in
PASS) PASS_COUNT=$((PASS_COUNT + 1)) ;;
FAIL) FAIL_COUNT=$((FAIL_COUNT + 1)) ;;
SKIP) SKIP_COUNT=$((SKIP_COUNT + 1)) ;;
esac
}
update_index() {
local runs_dir="reports/runs"
local index_file="reports/index.html"
local items=""
if [ -d "$runs_dir" ]; then
while IFS= read -r run_dir; do
local meta_file="${runs_dir}/${run_dir}/meta.txt"
[ -f "$meta_file" ] || continue
local run_ts target pass fail skip
run_ts=$(grep -E '^RUN_TS=' "$meta_file" | head -n1 | cut -d= -f2-)
target=$(grep -E '^TARGET=' "$meta_file" | head -n1 | cut -d= -f2-)
pass=$(grep -E '^PASS_COUNT=' "$meta_file" | head -n1 | cut -d= -f2-)
fail=$(grep -E '^FAIL_COUNT=' "$meta_file" | head -n1 | cut -d= -f2-)
skip=$(grep -E '^SKIP_COUNT=' "$meta_file" | head -n1 | cut -d= -f2-)
run_ts="${run_ts:-$run_dir}"
target="${target:-unknown}"
pass="${pass:-0}"
fail="${fail:-0}"
skip="${skip:-0}"
items+=$'\n'" <li><div class=\"run\"><a href=\"runs/${run_ts}/summary.html\">${run_ts}</a></div><div class=\"meta\">Target: ${target} | Pass: ${pass} | Fail: ${fail} | Skip: ${skip}</div></li>"
done < <(ls "$runs_dir" 2>/dev/null | sort -r)
fi
cat >"$index_file" <<EOF
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Lomavuokraus Test Suite Runs</title>
<style>
body { font-family: Arial, sans-serif; padding: 16px; background: #0b0d11; color: #e9ecf1; }
a { color: #7cc7ff; text-decoration: none; }
a:hover { text-decoration: underline; }
ul { list-style: none; padding: 0; }
li { margin-bottom: 10px; padding: 10px; border: 1px solid #1f2937; border-radius: 6px; background: #111827; }
.run { font-weight: bold; }
.meta { font-size: 0.95em; color: #cfd6e0; margin-top: 2px; }
</style>
</head>
<body>
<h1>Test Suite Runs</h1>
<ul>
${items:-" <li>No runs found.</li>"}
</ul>
</body>
</html>
EOF
}
# 1) npm audit
@ -39,13 +103,13 @@ if command -v npm >/dev/null 2>&1; then
AUDIT_JSON="$RUN_DIR/npm-audit.json"
AUDIT_TXT="$RUN_DIR/npm-audit.txt"
if npm audit --audit-level=high --json >"$AUDIT_JSON" 2>"$AUDIT_TXT"; then
record_result "npm audit" "PASS" "<a href=\"npm-audit.txt\">text</a> | <a href=\"npm-audit.json\">json</a>"
record_result "npm audit" "PASS" "<a href=\"npm-audit.txt\">text</a> | <a href=\"npm-audit.json\">json</a>" "reports: ${AUDIT_TXT}, ${AUDIT_JSON}"
else
record_result "npm audit" "FAIL" "<a href=\"npm-audit.txt\">text</a> | <a href=\"npm-audit.json\">json</a>"
record_result "npm audit" "FAIL" "<a href=\"npm-audit.txt\">text</a> | <a href=\"npm-audit.json\">json</a>" "reports: ${AUDIT_TXT}, ${AUDIT_JSON}"
fi
else
log "npm not found; skipping npm audit"
record_result "npm audit" "SKIP" "npm not available"
record_result "npm audit" "SKIP" "npm not available" "npm not available"
fi
# 2) Lint / type-check / format / tests
@ -55,20 +119,20 @@ run_npm_check() {
if ! command -v npm >/dev/null 2>&1; then
log "npm not found; skipping ${name}"
record_result "${name}" "SKIP" "npm not available"
record_result "${name}" "SKIP" "npm not available" "npm not available"
return
fi
if npm run 2>/dev/null | grep -qE "^ ${name}$"; then
log "Running ${name}..."
if npm run "${name}" >"$outfile" 2>&1; then
record_result "${name}" "PASS" "<a href=\"${name}.txt\">log</a>"
record_result "${name}" "PASS" "<a href=\"${name}.txt\">log</a>" "log: ${outfile}"
else
record_result "${name}" "FAIL" "<a href=\"${name}.txt\">log</a>"
record_result "${name}" "FAIL" "<a href=\"${name}.txt\">log</a>" "log: ${outfile}"
fi
else
log "npm script '${name}' not defined; skipping"
record_result "${name}" "SKIP" "script not defined"
record_result "${name}" "SKIP" "script not defined" "script not defined"
fi
}
@ -84,13 +148,13 @@ if command -v trivy >/dev/null 2>&1; then
log "Running Trivy (${TRIVY_MODE}) on ${TRIVY_TARGET}..."
TRIVY_TXT="$RUN_DIR/trivy.txt"
if trivy "${TRIVY_MODE}" --severity HIGH,CRITICAL --timeout 5m "$TRIVY_TARGET" >"$TRIVY_TXT"; then
record_result "Trivy (${TRIVY_MODE})" "PASS" "<a href=\"trivy.txt\">report</a>"
record_result "Trivy (${TRIVY_MODE})" "PASS" "<a href=\"trivy.txt\">report</a>" "report: ${TRIVY_TXT}"
else
record_result "Trivy (${TRIVY_MODE})" "FAIL" "<a href=\"trivy.txt\">report</a>"
record_result "Trivy (${TRIVY_MODE})" "FAIL" "<a href=\"trivy.txt\">report</a>" "report: ${TRIVY_TXT}"
fi
else
log "Trivy not found; skipping"
record_result "Trivy" "SKIP" "trivy not available"
record_result "Trivy" "SKIP" "trivy not available" "trivy not available"
fi
# 4) OWASP ZAP baseline
@ -99,9 +163,9 @@ ZAP_DIR="$RUN_DIR/zap"
mkdir -p "$ZAP_DIR"
log "Running ZAP baseline against ${TARGET}..."
if TARGET="$TARGET" REPORT_DIR="$ZAP_DIR" "${BASH_SOURCE%/*}/zap-baseline.sh"; then
record_result "OWASP ZAP baseline" "PASS" "<a href=\"zap/zap-report.html\">HTML</a> | <a href=\"zap/zap-report.json\">JSON</a>"
record_result "OWASP ZAP baseline" "PASS" "<a href=\"zap/zap-report.html\">HTML</a> | <a href=\"zap/zap-report.json\">JSON</a>" "reports: ${ZAP_DIR}/zap-report.html, ${ZAP_DIR}/zap-report.json"
else
record_result "OWASP ZAP baseline" "FAIL" "<a href=\"zap/zap-report.html\">HTML</a> | <a href=\"zap/zap-report.json\">JSON</a>"
record_result "OWASP ZAP baseline" "FAIL" "<a href=\"zap/zap-report.html\">HTML</a> | <a href=\"zap/zap-report.json\">JSON</a>" "reports: ${ZAP_DIR}/zap-report.html, ${ZAP_DIR}/zap-report.json"
fi
# Summary HTML
@ -134,5 +198,21 @@ cat >"$SUMMARY_FILE" <<EOF
</html>
EOF
META_FILE="$RUN_DIR/meta.txt"
cat >"$META_FILE" <<EOF
RUN_TS=${RUN_TS}
TARGET=${TARGET}
PASS_COUNT=${PASS_COUNT}
FAIL_COUNT=${FAIL_COUNT}
SKIP_COUNT=${SKIP_COUNT}
EOF
update_index
log "Summary:"
for row in "${SUMMARY_TEXT_ROWS[@]}"; do
echo " - ${row}"
done
log "Done. Reports in ${RUN_DIR}"
echo "Open ${SUMMARY_FILE} in a browser for the summary."