Skip to content

Commit f926de1

Browse files
authored
[codex] add duplicati backup status to landingpage (#94)
* feat: add duplicati backup status to landingpage * fix: strip duplicati prefix before n8n proxy * fix: use duplicati namespaced webhook path * fix: require mTLS for duplicati webhook endpoints * fix: replace invalid handle_path matcher usage * refactor: use standard webhook auth flow for duplicati * refactor: simplify landing duplicati status path * fix: switch landing backup status paths to generic backup endpoints * feat: show backup dashboard list on landing page * fix: revert telegram reissue matcher change * fix: narrow landing backup proxy routes to explicit endpoints * fix: restore telegram webhook secret header matcher * fix: prevent xss in backup list rendering * test: enforce auth-protected endpoint policy in CI * test: add isolated e2e auth endpoint checks * fix: make auth tests portable and dclint-compliant --------- Co-authored-by: sidey79 <sidey79>
1 parent 2114fdd commit f926de1

10 files changed

Lines changed: 524 additions & 0 deletions

File tree

.github/workflows/lint.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,23 @@ jobs:
4646
-v "$RUNNER_TEMP/caddy-ca/private:/etc/ssl/ca/private:ro" \
4747
-w /work \
4848
"$CADDY_IMAGE" sh -lc 'caddy validate --config /work/caddy/Caddyfile'
49+
50+
endpoint-authz-policy:
51+
name: runner / endpoint-authz-policy
52+
runs-on: ubuntu-latest
53+
steps:
54+
- name: Check out code
55+
uses: actions/checkout@v6
56+
- name: Verify auth-protected endpoint policy
57+
run: |
58+
bash scripts/test-auth-protected-endpoints.sh caddy/Caddyfile
59+
60+
endpoint-authz-e2e:
61+
name: runner / endpoint-authz-e2e
62+
runs-on: ubuntu-latest
63+
steps:
64+
- name: Check out code
65+
uses: actions/checkout@v6
66+
- name: Run E2E auth endpoint checks
67+
run: |
68+
bash scripts/test-auth-endpoints-e2e.sh

caddy/Caddyfile

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,12 +275,30 @@ landing.{$CADDY_SUBDOMAIN} {
275275
method GET HEAD
276276
path /api/me
277277
}
278+
@landingBackupStatus {
279+
method GET HEAD
280+
path /backup/status
281+
}
282+
@landingBackupNames {
283+
method GET HEAD
284+
path /backup/names
285+
}
278286
route {
279287
handle @landingApiMe {
280288
import authelia_forward_auth
281289
rewrite * /webhook/landing/api/me
282290
import n8n_upstream
283291
}
292+
handle @landingBackupStatus {
293+
import authelia_forward_auth
294+
rewrite * /webhook/backup/status-public
295+
import n8n_upstream
296+
}
297+
handle @landingBackupNames {
298+
import authelia_forward_auth
299+
rewrite * /webhook/backup/names
300+
import n8n_upstream
301+
}
284302
handle {
285303
import authelia_forward_auth
286304
root * /srv/landingpage

scripts/test-auth-endpoints-e2e.sh

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5+
COMPOSE_FILE="$ROOT_DIR/tests/e2e/docker-compose.e2e.yml"
6+
BASE_URL="http://127.0.0.1:18080"
7+
8+
cleanup() {
9+
docker compose -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
10+
}
11+
trap cleanup EXIT
12+
13+
req_code() {
14+
local method="$1" host="$2" path="$3" auth="$4" extra_header="${5:-}"
15+
local -a args=( -sS -o /dev/null -w "%{http_code}" -X "$method" -H "Host: $host" )
16+
if [[ "$auth" == "auth" ]]; then
17+
args+=( -H "X-Test-Auth: allow" )
18+
fi
19+
if [[ -n "$extra_header" ]]; then
20+
args+=( -H "$extra_header" )
21+
fi
22+
curl "${args[@]}" "$BASE_URL$path"
23+
}
24+
25+
req_header() {
26+
local method="$1" host="$2" path="$3" auth="$4" header_name="$5" extra_header="${6:-}"
27+
local -a args=( -sS -D - -o /dev/null -X "$method" -H "Host: $host" )
28+
if [[ "$auth" == "auth" ]]; then
29+
args+=( -H "X-Test-Auth: allow" )
30+
fi
31+
if [[ -n "$extra_header" ]]; then
32+
args+=( -H "$extra_header" )
33+
fi
34+
curl "${args[@]}" "$BASE_URL$path" | awk -v h="$header_name" 'BEGIN{IGNORECASE=1} $0 ~ "^"h":" {print; found=1} END{if(!found) exit 1}'
35+
}
36+
37+
assert_eq() {
38+
local got="$1" want="$2" msg="$3"
39+
if [[ "$got" != "$want" ]]; then
40+
echo "FAIL: $msg (got=$got want=$want)" >&2
41+
exit 1
42+
fi
43+
echo "OK: $msg -> $got"
44+
}
45+
46+
assert_no_upstream_header() {
47+
local method="$1" host="$2" path="$3" auth="$4"
48+
if req_header "$method" "$host" "$path" "$auth" "X-Upstream" >/dev/null 2>&1; then
49+
echo "FAIL: unexpected upstream proxy for $host$path" >&2
50+
exit 1
51+
fi
52+
echo "OK: no upstream proxy header for $host$path"
53+
}
54+
55+
echo "Starting isolated E2E stack..."
56+
docker compose -f "$COMPOSE_FILE" up -d --wait
57+
58+
# landing.* protected endpoints
59+
assert_eq "$(req_code GET landing.test /backup/status none)" "401" "landing /backup/status requires auth"
60+
assert_eq "$(req_code GET landing.test /backup/status auth)" "200" "landing /backup/status works with auth"
61+
assert_eq "$(req_code GET landing.test /backup/names none)" "401" "landing /backup/names requires auth"
62+
assert_eq "$(req_code GET landing.test /backup/names auth)" "200" "landing /backup/names works with auth"
63+
assert_eq "$(req_code GET landing.test /api/me none)" "401" "landing /api/me requires auth"
64+
assert_eq "$(req_code GET landing.test /api/me auth)" "200" "landing /api/me works with auth"
65+
66+
# Ensure no broad /backup/* passthrough remains
67+
assert_eq "$(req_code GET landing.test /backup/rest/workflows auth)" "404" "landing /backup/rest/workflows is not proxied"
68+
assert_no_upstream_header GET landing.test /backup/rest/workflows auth
69+
70+
# workflow.* protected webhook path
71+
assert_eq "$(req_code POST workflow.test /webhook/protected none)" "401" "workflow protected webhook requires auth without mTLS/exception"
72+
assert_eq "$(req_code POST workflow.test /webhook/protected auth)" "200" "workflow protected webhook works with auth"
73+
74+
# Explicit Telegram exception (no auth but header secret)
75+
assert_eq "$(req_code POST workflow.test /webhook/scanservjs/telegram/reissue none "X-Telegram-Bot-Api-Secret-Token: test-telegram-secret")" "200" "telegram exception works without auth when secret header matches"
76+
assert_eq "$(req_code POST workflow.test /webhook/scanservjs/telegram/reissue none)" "401" "telegram path without secret remains protected"
77+
78+
echo "All E2E auth endpoint checks passed."
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
CADDYFILE="${1:-caddy/Caddyfile}"
5+
6+
if [[ ! -f "$CADDYFILE" ]]; then
7+
echo "ERROR: Caddyfile not found: $CADDYFILE" >&2
8+
exit 1
9+
fi
10+
11+
fail() {
12+
echo "ERROR: $*" >&2
13+
exit 1
14+
}
15+
16+
has_fixed_string() {
17+
local pattern="$1"
18+
local file="$2"
19+
if command -v rg >/dev/null 2>&1; then
20+
rg -n --fixed-strings "$pattern" "$file" >/dev/null
21+
else
22+
grep -nF -- "$pattern" "$file" >/dev/null
23+
fi
24+
}
25+
26+
require_pattern() {
27+
local pattern="$1"
28+
local msg="$2"
29+
if ! has_fixed_string "$pattern" "$CADDYFILE"; then
30+
fail "$msg"
31+
fi
32+
}
33+
34+
forbid_pattern() {
35+
local pattern="$1"
36+
local msg="$2"
37+
if has_fixed_string "$pattern" "$CADDYFILE"; then
38+
fail "$msg"
39+
fi
40+
}
41+
42+
# Landing host must expose only narrow backup aliases and protect them via Authelia.
43+
require_pattern "path /backup/status" "Missing landing alias /backup/status"
44+
require_pattern "path /backup/names" "Missing landing alias /backup/names"
45+
forbid_pattern "path /backup/*" "Broad /backup/* alias is forbidden on landing host"
46+
require_pattern "rewrite * /webhook/backup/status-public" "Missing rewrite for /backup/status"
47+
require_pattern "rewrite * /webhook/backup/names" "Missing rewrite for /backup/names"
48+
require_pattern "handle @landingBackupStatus {" "Missing landingBackupStatus handle"
49+
require_pattern "handle @landingBackupNames {" "Missing landingBackupNames handle"
50+
51+
# Workflow host webhook protections must remain explicit.
52+
require_pattern 'header X-Telegram-Bot-Api-Secret-Token {$TELEGRAM_WEBHOOK_SECRET}' 'Missing Telegram secret header matcher'
53+
require_pattern "path /webhook/scanservjs/telegram/reissue /webhook-test/scanservjs/telegram/reissue" "Missing Telegram webhook path matcher"
54+
require_pattern "handle @webhooksAuthelia {" "Missing Authelia webhook handling branch"
55+
require_pattern "import authelia_forward_auth" "Missing authelia_forward_auth import"
56+
57+
# Quick structural sanity for webhook mTLS split (must both exist).
58+
require_pattern "@webhooksMtls {" "Missing mTLS webhook matcher"
59+
require_pattern "@webhooksAuthelia {" "Missing non-mTLS webhook matcher"
60+
61+
echo "Auth endpoint policy checks passed: $CADDYFILE"

site/landingpage/app.js

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,90 @@ function renderFallback(error) {
8686
rolePill.textContent = "family";
8787
}
8888

89+
function mapStatusLabel(status) {
90+
const map = {
91+
ok: "Backup OK",
92+
warn: "Backup mit Warnungen",
93+
error: "Backup fehlgeschlagen",
94+
unknown: "Backupstatus unbekannt",
95+
};
96+
return map[status] || map.unknown;
97+
}
98+
99+
function renderBackupStatusList(payload) {
100+
const statusTarget = document.getElementById("backup-status");
101+
const listTarget = document.getElementById("backup-list");
102+
if (!statusTarget || !listTarget) return;
103+
104+
const backups = Array.isArray(payload?.backups) ? payload.backups : [];
105+
106+
if (!backups.length) {
107+
statusTarget.textContent = "Keine Backupdaten vorhanden.";
108+
listTarget.replaceChildren();
109+
return;
110+
}
111+
112+
const latestTs = backups
113+
.map((b) => (b.lastCheckedAt ? Date.parse(b.lastCheckedAt) : 0))
114+
.reduce((max, ts) => (ts > max ? ts : max), 0);
115+
const latestText = latestTs ? new Date(latestTs).toLocaleString("de-DE") : "keine Daten";
116+
117+
statusTarget.textContent = `Backups: ${backups.length} • Letztes Update: ${latestText}`;
118+
119+
listTarget.replaceChildren();
120+
backups.forEach((backup) => {
121+
const name = backup.backupName || "unbekannt";
122+
const label = mapStatusLabel(backup.status);
123+
const checkedAt = backup.lastCheckedAt ? new Date(backup.lastCheckedAt).toLocaleString("de-DE") : "keine Daten";
124+
125+
const li = document.createElement("li");
126+
const strong = document.createElement("strong");
127+
strong.textContent = name;
128+
li.appendChild(strong);
129+
li.appendChild(document.createTextNode(`: ${label} • Stand: ${checkedAt}`));
130+
listTarget.appendChild(li);
131+
});
132+
}
133+
134+
async function loadBackupStatus() {
135+
const target = document.getElementById("backup-status");
136+
const listTarget = document.getElementById("backup-list");
137+
138+
try {
139+
const response = await fetch("/backup/names", {
140+
credentials: "same-origin",
141+
headers: {
142+
Accept: "application/json",
143+
},
144+
cache: "no-store",
145+
});
146+
147+
if (!response.ok) {
148+
throw new Error(`HTTP ${response.status}`);
149+
}
150+
151+
const contentType = response.headers.get("content-type") || "";
152+
if (!contentType.includes("application/json")) {
153+
const text = await response.text();
154+
throw new Error(
155+
`Unerwartete Antwort (${contentType || "kein Content-Type"}). ` +
156+
`Haeufige Ursache: Landing/Proxy liefert HTML statt /backup/names-JSON. ` +
157+
`Vorschau: ${text.slice(0, 80)}`
158+
);
159+
}
160+
161+
const payload = await response.json();
162+
renderBackupStatusList(payload);
163+
} catch (error) {
164+
if (target) {
165+
target.textContent = `Backupstatus konnte nicht geladen werden (${error instanceof Error ? error.message : String(error)})`;
166+
}
167+
if (listTarget) {
168+
listTarget.replaceChildren();
169+
}
170+
}
171+
}
172+
89173
async function loadProfile() {
90174
try {
91175
const response = await fetch("/api/me", {
@@ -110,3 +194,4 @@ async function loadProfile() {
110194
initializeThemeToggle();
111195
hydrateLinks();
112196
loadProfile();
197+
loadBackupStatus();

site/landingpage/index.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ <h2>Admin</h2>
130130
<div class="section-head">
131131
<h2>Hinweis</h2>
132132
</div>
133+
<p id="backup-status">Backup-Status wird geladen ...</p>
134+
<ul id="backup-list"></ul>
133135
<p>
134136
Diese Seite blendet nur Links ein oder aus. Die Zielsysteme muessen weiterhin selbst ueber
135137
Authelia, mTLS oder eigene Anmeldung geschuetzt bleiben.

0 commit comments

Comments
 (0)