Compare commits
9
Commits
v1.7.5
..
67f66c8441
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67f66c8441 | ||
|
|
8bb482925f | ||
|
|
f84c8516df | ||
|
|
9fd1aa1f8a | ||
|
|
da356fb310 | ||
|
|
bdba80e72d | ||
|
|
c26dd7d320 | ||
|
|
841311a6bd | ||
|
|
dd19be8bbf |
No files matched your search
@@ -11,6 +11,38 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.7.8] — 2026-06-16
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Blank results grid after a browser refresh (especially after a server restart)** — restoring the last scan session on page load was one-shot: `_sseWatchdog()` called `loadHistorySession(null)` a single time, guarded by `_initialStatusChecked`. If that attempt was blocked — a completed scan's replayed `scan_phase` event leaves a `_*ScanRunning` flag set, and the `loadHistorySession` guard then bails — nothing retried, because `sse_replay_done` (the other retry path) only fires when the SSE replay buffer is non-empty, and the buffer is empty after a server restart (so refreshing after the in-app self-update reliably showed an empty grid even though the results were in the database). The watchdog now re-attempts the restore on every 4-second poll while nothing is shown and no scan is running, clearing stale running flags first (both scan locks are confirmed free at that point). Additionally, `/api/scan/status` now reports `google_running` separately from `running` (which only ever reflected the M365 + file lock), so a refresh during a live Google scan is detected instead of being treated as idle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.7] — 2026-06-15
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Share modal no longer leaves a stale link in the create box** — after clicking "Create", the generated-link preview row ("Copy link:") stayed visible at the top of the modal even though the new link was already listed under Active links with its own Copy button — so it looked like the form hadn't cleared. The redundant preview row is removed; creating a link now resets the form and briefly highlights the new entry in the Active links list, where it can be copied. (The 1.7.4 fix cleared the input fields but not this preview row.)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Reverse-proxy / HTTPS setup guide** — new `docs/setup/ZORAXY_SETUP.md` walks through putting the scanner behind Zoraxy with a Let's Encrypt certificate on a LAN-only deployment: DNS A-record to a private IP, ACME via DNS-01 challenge (HTTP-01 cannot reach a LAN-only host), proxy rule to `127.0.0.1:5100`, binding the app to loopback with `--host 127.0.0.1`, and scanner-specific verification (SSE streaming, HTTPS share links, self-update). Linked from the README (new "HTTPS / reverse proxy" section) and SECURITY.md.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **SECURITY.md corrections** — the web UI binds to `0.0.0.0` by default, not `127.0.0.1` as claimed; the MSAL token cache path was still the pre-1.x `~/.gdpr_scanner_config.json` (actual: `~/.gdprscanner/token.json`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.6] — 2026-06-11
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Update restart leaked the listening socket and hopped to port 5101** — Werkzeug marks its server socket inheritable (`srv.socket.set_inheritable(True)`, unconditionally, for its debug reloader), so the in-app update's `os.execv` restart carried the old listening socket into the new process as a zombie listener: same PID listening on both 5100 (never accepted — clients hang) and 5101 (the actual server). The 1.7.3 `SO_REUSEADDR`/grace-period fix couldn't help because the port genuinely was occupied — by the restarting process itself. `_restart_self()` now marks every fd above stderr close-on-exec before the exec (`_mark_fds_cloexec()`, enumerating `/proc/self/fd` on Linux), so the old socket dies with the exec and the new server rebinds 5100 immediately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.7.5] — 2026-06-11
|
## [1.7.5] — 2026-06-11
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -28,13 +28,15 @@ python -m pytest tests/ -q
|
|||||||
|
|
||||||
**`_scan_bytes` injection pattern** — `scan_engine.py` defines no-op stubs at module level (avoids circular import). `gdpr_scanner.py` overwrites them at startup. `routes/google_scan.py` resolves them lazily via `gdpr_scanner.__getattr__`. Do not import them directly in those modules.
|
**`_scan_bytes` injection pattern** — `scan_engine.py` defines no-op stubs at module level (avoids circular import). `gdpr_scanner.py` overwrites them at startup. `routes/google_scan.py` resolves them lazily via `gdpr_scanner.__getattr__`. Do not import them directly in those modules.
|
||||||
|
|
||||||
**Blueprints** in `routes/` — see `routes/CLAUDE.md` for SSE constraints, export, preview, scheduler, NER, audit log, viewer, and other route-specific rules.
|
**Blueprints** in `routes/` — see `routes/CLAUDE.md` for SSE constraints, export, preview, scheduler, NER, audit log, viewer, software update, and other route-specific rules.
|
||||||
|
|
||||||
|
**Self-update (server only)** — `routes/updates.py` powers **Settings → General → Software update**: git fetch → ff-only merge → conditional `pip install` → `os.execv` restart (same PID; marks fds close-on-exec first so Werkzeug's inheritable listening socket doesn't leak and squat the port). Only enabled for git checkouts (`_supported()` is false for frozen desktop builds). `update_gdpr.sh` is the CLI/cron equivalent. Refused while a scan runs; optional daily auto-update thread (`config.json["auto_update"]`). Restart keeps port 5100 (the port probe uses `SO_REUSEADDR` + a 10s grace). See `routes/CLAUDE.md` → "Software update".
|
||||||
|
|
||||||
**Frontend:** `templates/index.html` (SPA), `static/style.css` (all styles), `static/js/*.js` (11 ES modules + `state.js`). `static/app.js` is an archived monolith — no longer loaded.
|
**Frontend:** `templates/index.html` (SPA), `static/style.css` (all styles), `static/js/*.js` (11 ES modules + `state.js`). `static/app.js` is an archived monolith — no longer loaded.
|
||||||
|
|
||||||
**Checkpoint / resume** — all three scan engines save progress to `~/.gdprscanner/checkpoint_{prefix}.json` every 25 items. Prefixes: `m365`, `google`, `file_{source_id}`. Use `_cp_path(prefix)` — do not hard-code filenames. The Scan button calls `checkCheckpoint(() => startScan(false))` so a resume banner is offered before any grid clearing. `POST /api/scan/clear_checkpoint` globs and deletes all `checkpoint_*.json` files.
|
**Checkpoint / resume** — all three scan engines save progress to `~/.gdprscanner/checkpoint_{prefix}.json` every 25 items. Prefixes: `m365`, `google`, `file_{source_id}`. Use `_cp_path(prefix)` — do not hard-code filenames. The Scan button calls `checkCheckpoint(() => startScan(false))` so a resume banner is offered before any grid clearing. `POST /api/scan/clear_checkpoint` globs and deletes all `checkpoint_*.json` files.
|
||||||
|
|
||||||
**Data dir** `~/.gdprscanner/`: `scanner.db`, `config.json`, `settings.json`, `schedule.json`, `token.json`, `delta.json`, `checkpoint_m365.json`, `checkpoint_google.json`, `checkpoint_file_*.json`, `smtp.json`, `machine_id` (**never delete** — Fernet key), `role_overrides.json`, `google_sa.json`, `google.json`, `src_toggles.json`, `app.lock`, `viewer_tokens.json`
|
**Data dir** `~/.gdprscanner/`: `scanner.db`, `config.json` (also holds `claude_api_key`/`claude_ner` and the `auto_update` flag), `settings.json`, `schedule.json`, `token.json`, `delta.json`, `checkpoint_m365.json`, `checkpoint_google.json`, `checkpoint_file_*.json`, `smtp.json`, `machine_id` (**never delete** — Fernet key), `role_overrides.json`, `google_sa.json`, `google.json`, `src_toggles.json`, `app.lock`, `viewer_tokens.json`. Static files are served with `SEND_FILE_MAX_AGE_DEFAULT=0` (ETag revalidation) so the UI is fresh after a self-update — do not re-add long static caching.
|
||||||
|
|
||||||
## Non-obvious files
|
## Non-obvious files
|
||||||
|
|
||||||
@@ -44,15 +46,18 @@ python -m pytest tests/ -q
|
|||||||
| `routes/state.py` | Shared mutable state + scan locks (not a typical Flask state file) |
|
| `routes/state.py` | Shared mutable state + scan locks (not a typical Flask state file) |
|
||||||
| `routes/google_scan.py` | Google scan execution lives here, not in `google_connector.py` |
|
| `routes/google_scan.py` | Google scan execution lives here, not in `google_connector.py` |
|
||||||
| `routes/viewer.py` | Viewer token + PIN API; also owns brute-force rate-limit state |
|
| `routes/viewer.py` | Viewer token + PIN API; also owns brute-force rate-limit state |
|
||||||
| `static/js/viewer.js` | Share modal, token CRUD, viewer PIN settings UI |
|
| `static/js/viewer.js` | Share modal, token CRUD, viewer PIN settings UI. Also defines `window._copyText` (HTTP-safe clipboard helper reused by `log.js`) |
|
||||||
| `lang/da.json` | Primary language — source of truth is `en.json` |
|
| `lang/da.json` | Primary language — source of truth is `en.json` |
|
||||||
| `build_gdpr.py` | Desktop app builder; contains embedded `LAUNCHER_CODE` for PyInstaller |
|
| `build_gdpr.py` | Desktop app builder; contains embedded `LAUNCHER_CODE` for PyInstaller |
|
||||||
|
| `routes/updates.py` | Self-update routes + `os.execv` restart with fd-cleanup; git-checkout only |
|
||||||
|
| `update_gdpr.sh` | CLI/cron self-update (fetch, ff-merge, deps, service restart) |
|
||||||
|
| `docs/setup/ZORAXY_SETUP.md` | HTTPS via Zoraxy reverse proxy (LAN-only, Let's Encrypt DNS-01) |
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
212 tests in `tests/`. No integration tests for live M365/Google connections.
|
215 tests in `tests/`. No integration tests for live M365/Google connections.
|
||||||
|
|
||||||
**`tests/test_updates.py`** — 11 tests for the software-update routes (`routes/updates.py`). All git interaction goes through a mocked `_git()`; `_schedule_restart` is patched so no test re-execs the process, and `gdpr_db.log_audit_event` is patched so no test writes the real database.
|
**`tests/test_updates.py`** — 12 tests for the software-update routes (`routes/updates.py`). All git interaction goes through a mocked `_git()`; `_schedule_restart` is patched so no test re-execs the process, and `gdpr_db.log_audit_event` is patched so no test writes the real database. Includes `_mark_fds_cloexec` (the socket-leak guard for the restart).
|
||||||
|
|
||||||
**`tests/test_google_scan.py`** — 19 tests for the Google Workspace scan module. Route tests for `GET /api/google/scan/users`, `POST /api/google/scan/start`, `POST /api/google/scan/cancel`. Engine tests for `_run_google_scan` using synchronous invocation with mocked `broadcast`, `_scan_bytes`, `checkpoint.*`, `scan_engine._with_disposition`, and `gdpr_db.get_db`. The `clean_google_state` autouse fixture releases `_google_scan_lock` and clears `_google_scan_abort` after each test.
|
**`tests/test_google_scan.py`** — 19 tests for the Google Workspace scan module. Route tests for `GET /api/google/scan/users`, `POST /api/google/scan/start`, `POST /api/google/scan/cancel`. Engine tests for `_run_google_scan` using synchronous invocation with mocked `broadcast`, `_scan_bytes`, `checkpoint.*`, `scan_engine._with_disposition`, and `gdpr_db.get_db`. The `clean_google_state` autouse fixture releases `_google_scan_lock` and clears `_google_scan_abort` after each test.
|
||||||
|
|
||||||
|
|||||||
@@ -534,6 +534,14 @@ API endpoints: `GET /api/update/check`, `POST /api/update/apply`, `GET/POST /api
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### HTTPS / reverse proxy
|
||||||
|
|
||||||
|
The scanner itself serves plain HTTP. For encrypted transport on a LAN — recommended, since scan results contain CPR numbers — put it behind a TLS-terminating reverse proxy and bind the app to loopback (`--host 127.0.0.1`) so the proxy is the only way in. Share links automatically follow the HTTPS hostname, and the browser Clipboard API (Copy buttons) works natively in a secure context.
|
||||||
|
|
||||||
|
See [ZORAXY_SETUP.md](docs/setup/ZORAXY_SETUP.md) for a complete walkthrough: Zoraxy, Let's Encrypt via DNS-01 challenge (required when the hostname resolves to a private IP), proxy rule, and the scanner-specific verification steps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Article 30 report
|
### Article 30 report
|
||||||
|
|
||||||
The **Art.30** button in the filter bar generates a GDPR **Article 30 Register of Processing Activities** as a Word document (`.docx`).
|
The **Art.30** button in the filter bar generates a GDPR **Article 30 Register of Processing Activities** as a Word document (`.docx`).
|
||||||
@@ -737,6 +745,7 @@ See [SUGGESTIONS.md](SUGGESTIONS.md) for the full feature roadmap with implement
|
|||||||
| `docs/manuals/MANUAL-DA.md` | End-user manual in Danish (15 sections) — served at `/manual?lang=da` |
|
| `docs/manuals/MANUAL-DA.md` | End-user manual in Danish (15 sections) — served at `/manual?lang=da` |
|
||||||
| `docs/setup/M365_SETUP.md` | Step-by-step Microsoft 365 setup guide |
|
| `docs/setup/M365_SETUP.md` | Step-by-step Microsoft 365 setup guide |
|
||||||
| `docs/setup/GOOGLE_SETUP.md` | Step-by-step Google Workspace setup guide |
|
| `docs/setup/GOOGLE_SETUP.md` | Step-by-step Google Workspace setup guide |
|
||||||
|
| `docs/setup/ZORAXY_SETUP.md` | HTTPS via Zoraxy reverse proxy — LAN-only deployment with Let's Encrypt DNS-01 |
|
||||||
| `build_gdpr.py` | PyInstaller build script — generates `m365_launcher.py`, packages desktop app |
|
| `build_gdpr.py` | PyInstaller build script — generates `m365_launcher.py`, packages desktop app |
|
||||||
| `lang/en.json` | English translations (source of truth) |
|
| `lang/en.json` | English translations (source of truth) |
|
||||||
| `lang/da.json` | Danish translations (primary language) |
|
| `lang/da.json` | Danish translations (primary language) |
|
||||||
|
|||||||
+2
-2
@@ -55,9 +55,9 @@ Out of scope:
|
|||||||
|
|
||||||
- CPR numbers are stored in the SQLite database as **SHA-256 hashes only** — never in plaintext
|
- CPR numbers are stored in the SQLite database as **SHA-256 hashes only** — never in plaintext
|
||||||
- SMTP passwords are stored in `~/.gdprscanner/smtp.json` with chmod 600
|
- SMTP passwords are stored in `~/.gdprscanner/smtp.json` with chmod 600
|
||||||
- Microsoft OAuth tokens are stored in the MSAL token cache in `~/.gdpr_scanner_config.json`
|
- Microsoft OAuth tokens are stored in the MSAL token cache in `~/.gdprscanner/token.json`
|
||||||
- Scan results are stored locally in `~/.gdprscanner/scanner.db` — never transmitted externally
|
- Scan results are stored locally in `~/.gdprscanner/scanner.db` — never transmitted externally
|
||||||
- The web UI binds to `127.0.0.1` by default — it is not designed to be exposed to the internet
|
- The web UI binds to `0.0.0.0` by default so reviewers on the LAN can reach it — it is not designed to be exposed to the internet. For encrypted transport, put it behind a TLS-terminating reverse proxy and bind the app to loopback with `--host 127.0.0.1` — see [docs/setup/ZORAXY_SETUP.md](docs/setup/ZORAXY_SETUP.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# GDPR Scanner — Brugermanual
|
# GDPR Scanner — Brugermanual
|
||||||
|
|
||||||
Version 1.7.5
|
Version 1.7.8
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -441,8 +441,8 @@ Klik på **🔗**-knappen øverst til højre i topbjælken for at åbne delingsp
|
|||||||
- **Bruger** — modtageren ser kun elementer tilhørende en bestemt medarbejder. Vælg personen fra søgefeltet; scanneren matcher automatisk både deres M365- og Google Workspace-e-mailadresser. Brug denne mulighed, når du vil give en enkelt medarbejder adgang til sine egne scanningsresultater.
|
- **Bruger** — modtageren ser kun elementer tilhørende en bestemt medarbejder. Vælg personen fra søgefeltet; scanneren matcher automatisk både deres M365- og Google Workspace-e-mailadresser. Brug denne mulighed, når du vil give en enkelt medarbejder adgang til sine egne scanningsresultater.
|
||||||
3. Angiv eventuelt et **Datointerval** — brug felterne "Elementer fra" og "Elementer til" for at begrænse modtagerens visning til elementer ændret inden for en bestemt periode. Lad begge felter stå tomme for ingen datobegrænsning.
|
3. Angiv eventuelt et **Datointerval** — brug felterne "Elementer fra" og "Elementer til" for at begrænse modtagerens visning til elementer ændret inden for en bestemt periode. Lad begge felter stå tomme for ingen datobegrænsning.
|
||||||
4. Vælg en **Udløbsdato** — 7 dage, 30 dage, 90 dage, 1 år eller Aldrig.
|
4. Vælg en **Udløbsdato** — 7 dage, 30 dage, 90 dage, 1 år eller Aldrig.
|
||||||
5. Klik på **Opret**. Der genereres et unikt link: `http://host:5100/view?token=…`
|
5. Klik på **Opret**. Formularen ryddes, og det nye link vises øverst i listen **Aktive links** nedenfor, kortvarigt fremhævet.
|
||||||
6. Klik på **Kopiér** for at kopiere linket til udklipsholderen, og send det til gennemgangeren.
|
6. Klik på **Kopiér** i linkets række for at kopiere det til udklipsholderen, og send det til gennemgangeren.
|
||||||
|
|
||||||
Gennemgangeren åbner linket i en browser. De kan se resultatgitteret (afgrænset til det tilladte rolleomfang) og mærke dispositioner, men kan ikke starte scanninger, ændre indstillinger, se loginoplysninger eller slette elementer.
|
Gennemgangeren åbner linket i en browser. De kan se resultatgitteret (afgrænset til det tilladte rolleomfang) og mærke dispositioner, men kan ikke starte scanninger, ændre indstillinger, se loginoplysninger eller slette elementer.
|
||||||
|
|
||||||
@@ -671,4 +671,4 @@ For en typisk skole- eller kommunescanning er omkostningen ubetydelig — Claude
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*GDPR Scanner v1.7.5 — teknisk opsætning og konfiguration: se README.md*
|
*GDPR Scanner v1.7.8 — teknisk opsætning og konfiguration: se README.md*
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# GDPR Scanner — User Manual
|
# GDPR Scanner — User Manual
|
||||||
|
|
||||||
Version 1.7.5
|
Version 1.7.8
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -441,8 +441,8 @@ Click the **🔗** button in the top-right of the top bar to open the Share pane
|
|||||||
- **User** — the recipient sees only the items belonging to a specific employee. Select the person from the search box; the scanner matches both their M365 and Google Workspace email addresses automatically. Use this when you want to give an individual employee access to their own scan results.
|
- **User** — the recipient sees only the items belonging to a specific employee. Select the person from the search box; the scanner matches both their M365 and Google Workspace email addresses automatically. Use this when you want to give an individual employee access to their own scan results.
|
||||||
3. Optionally set a **Date range** — use the "Items from" and "Items until" date fields to limit the recipient to items modified within a specific period. This lets you, for example, create a link covering only last year's scan results. Leave both fields blank for no date restriction.
|
3. Optionally set a **Date range** — use the "Items from" and "Items until" date fields to limit the recipient to items modified within a specific period. This lets you, for example, create a link covering only last year's scan results. Leave both fields blank for no date restriction.
|
||||||
4. Choose an **Expiry** — 7 days, 30 days, 90 days, 1 year, or Never.
|
4. Choose an **Expiry** — 7 days, 30 days, 90 days, 1 year, or Never.
|
||||||
5. Click **Create**. A unique link is generated: `http://host:5100/view?token=…`
|
5. Click **Create**. The form clears and the new link appears at the top of the **Active links** list below, briefly highlighted.
|
||||||
6. Click **Copy** to copy the link to your clipboard, then send it to the reviewer.
|
6. Click **Copy** on that link's row to copy it to your clipboard, then send it to the reviewer.
|
||||||
|
|
||||||
The reviewer opens the link in any browser. They see the results grid (filtered to their permitted scope) and can tag dispositions but cannot start scans, change settings, view credentials, or delete items.
|
The reviewer opens the link in any browser. They see the results grid (filtered to their permitted scope) and can tag dispositions but cannot start scans, change settings, view credentials, or delete items.
|
||||||
|
|
||||||
@@ -671,4 +671,4 @@ For a typical school or municipality scan the cost is negligible — Claude Haik
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*GDPR Scanner v1.7.5 — for technical setup and configuration see README.md*
|
*GDPR Scanner v1.7.8 — for technical setup and configuration see README.md*
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# HTTPS via Zoraxy Reverse Proxy
|
||||||
|
|
||||||
|
Step-by-step guide for putting GDPRScanner behind [Zoraxy](https://github.com/tobychui/zoraxy) with a Let's Encrypt certificate, on a LAN-only deployment.
|
||||||
|
|
||||||
|
Why bother on an internal network:
|
||||||
|
|
||||||
|
- **Encryption in transit** — the scanner streams CPR numbers, document previews, and share links. Serving that over plain HTTP to DPO reviewers is itself a compliance finding.
|
||||||
|
- **Secure context** — the browser Clipboard API (share-link Copy buttons) only exists on HTTPS or localhost. Over plain HTTP the app falls back to a legacy copy mechanism.
|
||||||
|
- **A real hostname** — `https://gdprscanner.example.dk` instead of `http://10.x.x.x:5100` in share links, bookmarks, and emails.
|
||||||
|
|
||||||
|
This guide assumes Zoraxy runs **on the same host** as the scanner. If it runs elsewhere, replace `127.0.0.1:5100` with the scanner host's LAN IP and firewall port 5100 to the Zoraxy host only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. DNS record
|
||||||
|
|
||||||
|
Create an A-record for the hostname pointing at the server's **LAN IP**:
|
||||||
|
|
||||||
|
```
|
||||||
|
gdprscanner.example.dk A 10.x.x.x
|
||||||
|
```
|
||||||
|
|
||||||
|
A public DNS record pointing at a private IP is fine — outsiders can resolve the name but cannot route to the address, which is exactly the "LAN-only" goal.
|
||||||
|
|
||||||
|
> **Consequence:** because the server is not reachable from the internet, Let's Encrypt's default HTTP-01 challenge cannot work. The certificate **must** be issued via the **DNS-01 challenge** (step 4). If you prefer not to publish the internal IP at all, use an internal/split-horizon DNS record instead — DNS-01 still works since it validates against the public DNS zone, not the server.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Install Zoraxy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /opt/zoraxy && cd /opt/zoraxy
|
||||||
|
wget -O zoraxy https://github.com/tobychui/zoraxy/releases/latest/download/zoraxy_linux_amd64
|
||||||
|
chmod +x zoraxy
|
||||||
|
```
|
||||||
|
|
||||||
|
`/etc/systemd/system/zoraxy.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Zoraxy reverse proxy
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
WorkingDirectory=/opt/zoraxy
|
||||||
|
ExecStart=/opt/zoraxy/zoraxy
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl daemon-reload && systemctl enable --now zoraxy
|
||||||
|
```
|
||||||
|
|
||||||
|
Open the management UI at `http://<server-ip>:8000` and create the admin account.
|
||||||
|
|
||||||
|
> Menu names below may differ slightly between Zoraxy versions — the concepts to look for are: ACME certificate with DNS challenge, host-based proxy rule, TLS on the incoming port.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Incoming port and TLS
|
||||||
|
|
||||||
|
In Zoraxy's global settings:
|
||||||
|
|
||||||
|
- Set the incoming proxy port to **443** and enable **TLS**.
|
||||||
|
- Enable **force-redirect port 80 → 443** so plain-HTTP visits upgrade automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Certificate via ACME (DNS-01)
|
||||||
|
|
||||||
|
In **TLS / SSL Certificates → ACME**:
|
||||||
|
|
||||||
|
1. Enter the hostname (`gdprscanner.example.dk`).
|
||||||
|
2. Enable the **DNS challenge** and select the DNS provider that hosts your zone (Cloudflare, Simply.com, etc.).
|
||||||
|
3. Paste the provider's **API token/credentials** — created in the DNS provider's control panel.
|
||||||
|
4. Request the certificate. Zoraxy renews it automatically.
|
||||||
|
|
||||||
|
If your DNS host has no API, Zoraxy can generate a **self-signed certificate** as a fallback — it works, but every client machine must trust it manually. Getting a DNS API token is the better one-time investment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Proxy rule
|
||||||
|
|
||||||
|
**HTTP Proxy → New Proxy Rule**:
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Matching hostname | `gdprscanner.example.dk` |
|
||||||
|
| Target | `127.0.0.1:5100` |
|
||||||
|
| TLS to target | Off (the scanner speaks plain HTTP locally) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Close the side doors
|
||||||
|
|
||||||
|
**Bind the scanner to loopback** so only Zoraxy can reach Flask. Wherever the scanner is started (systemd unit or `start_gdpr.sh`), add:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
--host 127.0.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
After a restart, `http://<server-ip>:5100` stops responding by design. The in-app self-update restart preserves the argument.
|
||||||
|
|
||||||
|
Optional hardening:
|
||||||
|
|
||||||
|
- Add a Zoraxy **Access Rule** whitelisting your LAN CIDR (e.g. `10.0.0.0/8`) on the proxy rule.
|
||||||
|
- Firewall the Zoraxy **management port 8000** to admin machines only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Verify the scanner-specific behaviour
|
||||||
|
|
||||||
|
1. `https://gdprscanner.example.dk` loads with a valid padlock; `http://` redirects.
|
||||||
|
2. **Run a scan and watch result cards stream in live** — that is the Server-Sent Events connection (`/api/scan/stream`) passing through the proxy. If progress stalls while the scan log advances, look at proxy buffering/timeout settings.
|
||||||
|
3. Create a **share link** — it must start with `https://gdprscanner.example.dk/view?token=…`. The app uses the page origin automatically on HTTPS (the LAN-IP rewrite only applies when browsing at localhost). The Copy buttons now use the native Clipboard API.
|
||||||
|
4. **Settings → General → Software update → Check for updates** still works (outbound git fetch is unaffected by the proxy).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Cause / fix |
|
||||||
|
|---|---|
|
||||||
|
| Certificate request fails | HTTP-01 attempted against an unreachable host — make sure the **DNS challenge** is selected and the API credentials are for the zone's actual DNS host |
|
||||||
|
| Cards don't stream during scans | Proxy buffering the SSE response — check Zoraxy timeout/buffering settings for the rule |
|
||||||
|
| Share links still show the LAN IP | Page was loaded via the old `http://<ip>:5100` URL — use the HTTPS hostname; links follow the page origin |
|
||||||
|
| `http://<ip>:5100` still reachable | The `--host 127.0.0.1` flag is missing from the scanner's launch command |
|
||||||
+6
-2
@@ -76,9 +76,13 @@ def scan_status():
|
|||||||
acquired = state._scan_lock.acquire(blocking=False)
|
acquired = state._scan_lock.acquire(blocking=False)
|
||||||
if acquired:
|
if acquired:
|
||||||
state._scan_lock.release()
|
state._scan_lock.release()
|
||||||
|
g_acquired = state._google_scan_lock.acquire(blocking=False)
|
||||||
|
if g_acquired:
|
||||||
|
state._google_scan_lock.release()
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"running": not acquired,
|
"running": not acquired, # M365 + file scan lock
|
||||||
"scan_id": _sse_mod._current_scan_id or None,
|
"google_running": not g_acquired, # Google scan lock (separate)
|
||||||
|
"scan_id": _sse_mod._current_scan_id or None,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+23
-2
@@ -118,13 +118,34 @@ def apply_update() -> dict:
|
|||||||
"from": chk["current"], "to": chk["latest"]}
|
"from": chk["current"], "to": chk["latest"]}
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_fds_cloexec() -> None:
|
||||||
|
"""Mark every fd above stderr close-on-exec.
|
||||||
|
|
||||||
|
Werkzeug calls ``srv.socket.set_inheritable(True)`` unconditionally
|
||||||
|
(for its debug reloader), so without this the listening socket leaks
|
||||||
|
into the exec'd process: it sits on the port as a zombie listener no
|
||||||
|
one accepts from, the port probe sees the port as busy, and the new
|
||||||
|
server hops to port+1 while clients hang against the dead socket.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
fds = [int(f) for f in os.listdir("/proc/self/fd")] # Linux
|
||||||
|
except (OSError, ValueError):
|
||||||
|
fds = list(range(3, 4096))
|
||||||
|
for fd in fds:
|
||||||
|
if fd > 2:
|
||||||
|
try:
|
||||||
|
os.set_inheritable(fd, False)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _restart_self() -> None:
|
def _restart_self() -> None:
|
||||||
"""Re-exec the current process so the updated code is loaded.
|
"""Re-exec the current process so the updated code is loaded.
|
||||||
|
|
||||||
Keeps the same PID, so it works both under systemd and when launched
|
Keeps the same PID, so it works both under systemd and when launched
|
||||||
manually via start_gdpr.sh. Listening sockets are close-on-exec, so
|
manually via start_gdpr.sh.
|
||||||
the new process can rebind the port.
|
|
||||||
"""
|
"""
|
||||||
|
_mark_fds_cloexec()
|
||||||
try:
|
try:
|
||||||
os.execv(sys.executable, [sys.executable] + sys.argv)
|
os.execv(sys.executable, [sys.executable] + sys.argv)
|
||||||
except OSError:
|
except OSError:
|
||||||
|
|||||||
+1
-1
@@ -41,7 +41,7 @@ Never revert to `!!window._googleConnected` / `_fileSources.length > 0` — thos
|
|||||||
## Scan history browser — history.js + results.js
|
## Scan history browser — history.js + results.js
|
||||||
|
|
||||||
- **`S._historyRefScanId`** — `null` = live/SSE mode; positive int = viewing a past session. Set by `loadHistorySession()`; cleared by `exitHistoryMode()`.
|
- **`S._historyRefScanId`** — `null` = live/SSE mode; positive int = viewing a past session. Set by `loadHistorySession()`; cleared by `exitHistoryMode()`.
|
||||||
- **Auto-load on page load** — `results.js` calls `window.loadHistorySession?.(null)` once when the SSE watchdog confirms `!status.running`. `_initialStatusChecked` guard ensures this fires at most once per page load. The `sse_replay_done` handler in `scan.js` retries if `loadHistorySession` bailed due to stale running flags set during replay.
|
- **Auto-load on page load** — `_sseWatchdog()` in `results.js` calls `window.loadHistorySession?.(null)` whenever `/api/scan/status` reports neither `running` (M365 + file lock) nor `google_running` (Google lock) **and** nothing is shown yet (`!S._historyRefScanId && !S.flaggedData.length`). This is **not one-shot** — it retries on every 4s poll until a session is restored, because (a) the replay buffer is empty after a server restart so `sse_replay_done` never fires, and (b) a completed scan's replayed `scan_phase` can leave a running flag set that would otherwise block the load forever. Because both locks are confirmed free, the watchdog clears the stale `_m365/_google/_fileScanRunning` flags before calling. Do not revert to a one-shot `_initialStatusChecked` gate — that reintroduces the "blank grid after refresh/restart" bug. `/api/scan/status` **must** report `google_running` separately; `running` alone misses live Google scans. The `sse_replay_done` handler in `scan.js` still retries for the non-empty-buffer (no-restart) case.
|
||||||
- **History banner** (`#historyBanner`) — shown when `S._historyRefScanId` is set. Do not hide/show from outside `history.js`.
|
- **History banner** (`#historyBanner`) — shown when `S._historyRefScanId` is set. Do not hide/show from outside `history.js`.
|
||||||
- **Session picker** (`#historyDropdown`) — rendered inside `[data-history-wrap]` so the outside-click handler works correctly. Do not move the picker outside this wrapper.
|
- **Session picker** (`#historyDropdown`) — rendered inside `[data-history-wrap]` so the outside-click handler works correctly. Do not move the picker outside this wrapper.
|
||||||
- **Cache invalidation** — `invalidateHistoryCache()` clears `_sessions` and `_latestRefScanId`. All three `*_done` SSE handlers call `window.invalidateHistoryCache?.()`.
|
- **Cache invalidation** — `invalidateHistoryCache()` clears `_sessions` and `_latestRefScanId`. All three `*_done` SSE handlers call `window.invalidateHistoryCache?.()`.
|
||||||
|
|||||||
+19
-10
@@ -736,25 +736,34 @@ function _ensureSSE() {
|
|||||||
|
|
||||||
function _sseWatchdog() {
|
function _sseWatchdog() {
|
||||||
fetch('/api/scan/status').then(function(r) { return r.json(); }).then(function(status) {
|
fetch('/api/scan/status').then(function(r) { return r.json(); }).then(function(status) {
|
||||||
if (status.running) {
|
var anyRunning = status.running || status.google_running;
|
||||||
|
if (anyRunning) {
|
||||||
// A scan is in progress — make sure SSE is connected and progress UI is visible
|
// A scan is in progress — make sure SSE is connected and progress UI is visible
|
||||||
_ensureSSE();
|
_ensureSSE();
|
||||||
if (!S._m365ScanRunning && !S._googleScanRunning && !S._fileScanRunning) {
|
if (status.running && !S._m365ScanRunning && !S._googleScanRunning && !S._fileScanRunning) {
|
||||||
document.getElementById('scanBtn').disabled = true;
|
document.getElementById('scanBtn').disabled = true;
|
||||||
document.getElementById('stopBtn').style.display = 'inline-block';
|
document.getElementById('stopBtn').style.display = 'inline-block';
|
||||||
// /api/scan/status checks the M365 lock — if running=true it's an M365 scan
|
// status.running reflects the M365 + file lock; treat as an M365 reconnect
|
||||||
S._m365ScanRunning = true; _renderProgressSegments();
|
S._m365ScanRunning = true; _renderProgressSegments();
|
||||||
document.getElementById('progressFile').textContent = t('m365_sse_reconnecting', 'Reconnecting to running scan…');
|
document.getElementById('progressFile').textContent = t('m365_sse_reconnecting', 'Reconnecting to running scan…');
|
||||||
log(t('m365_sse_reconnecting', 'Reconnecting to running scan…'));
|
log(t('m365_sse_reconnecting', 'Reconnecting to running scan…'));
|
||||||
}
|
}
|
||||||
|
} else if (!S._historyRefScanId && !(S.flaggedData && S.flaggedData.length)) {
|
||||||
|
// No scan of any kind is running (authoritative, both locks free) and
|
||||||
|
// nothing is shown yet — restore the last saved session from the DB.
|
||||||
|
// Retried on every poll, not one-shot: the initial attempt can be blocked
|
||||||
|
// by running flags that SSE replay of a *completed* scan set but never
|
||||||
|
// cleared, and sse_replay_done only fires for a non-empty buffer (so it
|
||||||
|
// never retries after a server restart clears the replay buffer).
|
||||||
|
// Both locks are confirmed free, so clear any stale flags first.
|
||||||
|
S._m365ScanRunning = false;
|
||||||
|
S._googleScanRunning = false;
|
||||||
|
S._fileScanRunning = false;
|
||||||
|
window.loadHistorySession?.(null);
|
||||||
}
|
}
|
||||||
if (!_initialStatusChecked) {
|
_initialStatusChecked = true;
|
||||||
_initialStatusChecked = true;
|
// Keep polling even when idle — the SSE connection may have died and we
|
||||||
if (!status.running) window.loadHistorySession?.(null);
|
// need to detect the next scheduled scan (SSE is only opened on demand).
|
||||||
}
|
|
||||||
// When no scan is running, we still keep polling — the SSE connection
|
|
||||||
// may have died and we need to detect the *next* scheduled scan.
|
|
||||||
// The SSE itself is only opened/reopened when a scan is detected.
|
|
||||||
}).catch(function(err) {
|
}).catch(function(err) {
|
||||||
// Status endpoint unavailable — server might be restarting
|
// Status endpoint unavailable — server might be restarting
|
||||||
console.warn('[SSE] status poll failed:', err);
|
console.warn('[SSE] status poll failed:', err);
|
||||||
|
|||||||
+16
-20
@@ -154,7 +154,6 @@ function _resetShareForm() {
|
|||||||
|
|
||||||
function openShareModal() {
|
function openShareModal() {
|
||||||
document.getElementById('shareBackdrop').classList.add('open');
|
document.getElementById('shareBackdrop').classList.add('open');
|
||||||
document.getElementById('shareNewLinkRow').style.display = 'none';
|
|
||||||
_resetShareForm();
|
_resetShareForm();
|
||||||
_renderTokenList();
|
_renderTokenList();
|
||||||
fetch('/api/viewer/pin').then(function(r){ return r.json(); }).then(function(d) {
|
fetch('/api/viewer/pin').then(function(r){ return r.json(); }).then(function(d) {
|
||||||
@@ -167,7 +166,7 @@ function closeShareModal() {
|
|||||||
document.getElementById('shareBackdrop').classList.remove('open');
|
document.getElementById('shareBackdrop').classList.remove('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _renderTokenList() {
|
async function _renderTokenList(highlightToken) {
|
||||||
const list = document.getElementById('shareTokenList');
|
const list = document.getElementById('shareTokenList');
|
||||||
list.innerHTML = '<div style="font-size:12px;color:var(--muted);padding:4px 0">' + t('lbl_loading', 'Loading…') + '</div>';
|
list.innerHTML = '<div style="font-size:12px;color:var(--muted);padding:4px 0">' + t('lbl_loading', 'Loading…') + '</div>';
|
||||||
try {
|
try {
|
||||||
@@ -222,6 +221,17 @@ async function _renderTokenList() {
|
|||||||
'<button title="' + t('share_revoke', 'Revoke') + '" onclick="revokeToken(\'' + tok.token + '\',this.closest(\'div[style]\'))" ' +
|
'<button title="' + t('share_revoke', 'Revoke') + '" onclick="revokeToken(\'' + tok.token + '\',this.closest(\'div[style]\'))" ' +
|
||||||
'style="height:24px;padding:0 8px;background:none;border:1px solid var(--danger);color:var(--danger);border-radius:4px;font-size:11px;cursor:pointer;flex-shrink:0">' + t('share_revoke', 'Revoke') + '</button>';
|
'style="height:24px;padding:0 8px;background:none;border:1px solid var(--danger);color:var(--danger);border-radius:4px;font-size:11px;cursor:pointer;flex-shrink:0">' + t('share_revoke', 'Revoke') + '</button>';
|
||||||
list.appendChild(row);
|
list.appendChild(row);
|
||||||
|
// Briefly highlight a freshly created link so it is easy to find and copy.
|
||||||
|
if (highlightToken && tok.token === highlightToken) {
|
||||||
|
row.style.transition = 'border-color .3s, background .3s';
|
||||||
|
row.style.borderColor = 'var(--accent)';
|
||||||
|
row.style.background = 'rgba(80,160,80,.18)';
|
||||||
|
setTimeout(function() { row.scrollIntoView({block: 'nearest'}); }, 0);
|
||||||
|
setTimeout(function() {
|
||||||
|
row.style.borderColor = 'var(--border)';
|
||||||
|
row.style.background = 'var(--bg)';
|
||||||
|
}, 2500);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
list.innerHTML = '<div style="font-size:12px;color:var(--danger);padding:4px 0">' + t('share_load_error', 'Failed to load links.') + '</div>';
|
list.innerHTML = '<div style="font-size:12px;color:var(--danger);padding:4px 0">' + t('share_load_error', 'Failed to load links.') + '</div>';
|
||||||
@@ -264,23 +274,16 @@ async function createShareLink() {
|
|||||||
});
|
});
|
||||||
if (!r.ok) throw new Error('Server error ' + r.status);
|
if (!r.ok) throw new Error('Server error ' + r.status);
|
||||||
const entry = await r.json();
|
const entry = await r.json();
|
||||||
const url = (await _getShareBaseUrl()) + '/view?token=' + encodeURIComponent(entry.token);
|
// The new link appears in the active-links list below (each row has its
|
||||||
const urlInput = document.getElementById('shareNewLinkUrl');
|
// own Copy button) — reset the form and highlight the just-created row
|
||||||
urlInput.value = url;
|
// rather than leaving a stale link preview in the create box.
|
||||||
document.getElementById('shareNewLinkRow').style.display = 'block';
|
|
||||||
document.getElementById('shareCopyBtn').textContent = t('log_copy', 'Copy');
|
|
||||||
_resetShareForm();
|
_resetShareForm();
|
||||||
_renderTokenList();
|
_renderTokenList(entry.token);
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
alert(t('share_create_error', 'Failed to create link:') + ' ' + e.message);
|
alert(t('share_create_error', 'Failed to create link:') + ' ' + e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyShareLink() {
|
|
||||||
const url = document.getElementById('shareNewLinkUrl').value;
|
|
||||||
_copyText(url, document.getElementById('shareCopyBtn'));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyTokenLink(token, btn) {
|
async function copyTokenLink(token, btn) {
|
||||||
const url = (await _getShareBaseUrl()) + '/view?token=' + encodeURIComponent(token);
|
const url = (await _getShareBaseUrl()) + '/view?token=' + encodeURIComponent(token);
|
||||||
_copyText(url, btn);
|
_copyText(url, btn);
|
||||||
@@ -328,12 +331,6 @@ async function revokeToken(token, rowEl) {
|
|||||||
if (!list.children.length) {
|
if (!list.children.length) {
|
||||||
list.innerHTML = '<div style="font-size:12px;color:var(--muted);padding:4px 0">' + t('share_no_links', 'No active links.') + '</div>';
|
list.innerHTML = '<div style="font-size:12px;color:var(--muted);padding:4px 0">' + t('share_no_links', 'No active links.') + '</div>';
|
||||||
}
|
}
|
||||||
// Hide the copy row if the just-revoked token was the last created
|
|
||||||
const newRow = document.getElementById('shareNewLinkRow');
|
|
||||||
if (newRow) {
|
|
||||||
const shownUrl = document.getElementById('shareNewLinkUrl')?.value || '';
|
|
||||||
if (shownUrl.includes(token)) newRow.style.display = 'none';
|
|
||||||
}
|
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
alert(t('share_revoke_error', 'Failed to revoke:') + ' ' + e.message);
|
alert(t('share_revoke_error', 'Failed to revoke:') + ' ' + e.message);
|
||||||
}
|
}
|
||||||
@@ -502,7 +499,6 @@ window._shareScopeTypeChanged = _shareScopeTypeChanged;
|
|||||||
window.openShareModal = openShareModal;
|
window.openShareModal = openShareModal;
|
||||||
window.closeShareModal = closeShareModal;
|
window.closeShareModal = closeShareModal;
|
||||||
window.createShareLink = createShareLink;
|
window.createShareLink = createShareLink;
|
||||||
window.copyShareLink = copyShareLink;
|
|
||||||
window._copyText = _copyText;
|
window._copyText = _copyText;
|
||||||
window.copyTokenLink = copyTokenLink;
|
window.copyTokenLink = copyTokenLink;
|
||||||
window.revokeToken = revokeToken;
|
window.revokeToken = revokeToken;
|
||||||
|
|||||||
@@ -1094,13 +1094,6 @@ document.addEventListener('DOMContentLoaded', applyI18n);
|
|||||||
</div>
|
</div>
|
||||||
<button onclick="createShareLink()" style="height:30px;padding:0 14px;background:var(--accent);color:#fff;border:none;border-radius:5px;font-size:12px;cursor:pointer;flex-shrink:0" data-i18n="share_create">Create</button>
|
<button onclick="createShareLink()" style="height:30px;padding:0 14px;background:var(--accent);color:#fff;border:none;border-radius:5px;font-size:12px;cursor:pointer;flex-shrink:0" data-i18n="share_create">Create</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="shareNewLinkRow" style="display:none;margin-top:10px">
|
|
||||||
<div style="font-size:11px;color:var(--muted);margin-bottom:4px" data-i18n="share_copy_link_prompt">Copy link:</div>
|
|
||||||
<div style="display:flex;gap:6px;align-items:center">
|
|
||||||
<input id="shareNewLinkUrl" type="text" readonly style="flex:1;font-size:11px;padding:5px 8px;background:var(--bg2,var(--bg));border:1px solid var(--border);border-radius:5px;color:var(--text);min-width:0">
|
|
||||||
<button onclick="copyShareLink()" id="shareCopyBtn" style="height:26px;padding:0 10px;background:none;border:1px solid var(--border);color:var(--muted);border-radius:5px;font-size:11px;cursor:pointer;flex-shrink:0" data-i18n="log_copy">Copy</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Existing tokens -->
|
<!-- Existing tokens -->
|
||||||
|
|||||||
@@ -97,6 +97,22 @@ class TestScanStatus:
|
|||||||
assert "scan_id" in data
|
assert "scan_id" in data
|
||||||
assert data["scan_id"] is None
|
assert data["scan_id"] is None
|
||||||
|
|
||||||
|
def test_idle_reports_google_not_running(self, client):
|
||||||
|
# The refresh/restore path relies on google_running being reported
|
||||||
|
# separately — running alone misses live Google scans.
|
||||||
|
data = client.get("/api/scan/status").get_json()
|
||||||
|
assert data["google_running"] is False
|
||||||
|
|
||||||
|
def test_google_lock_held_reports_google_running(self, client):
|
||||||
|
from routes import state
|
||||||
|
assert state._google_scan_lock.acquire(blocking=False)
|
||||||
|
try:
|
||||||
|
data = client.get("/api/scan/status").get_json()
|
||||||
|
assert data["google_running"] is True
|
||||||
|
assert data["running"] is False # M365/file lock still free
|
||||||
|
finally:
|
||||||
|
state._google_scan_lock.release()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# /api/scan/start
|
# /api/scan/start
|
||||||
|
|||||||
@@ -188,6 +188,23 @@ def test_apply_installs_requirements_when_changed(client, monkeypatch):
|
|||||||
assert "pip" in pip_calls[0] and "-r" in pip_calls[0]
|
assert "pip" in pip_calls[0] and "-r" in pip_calls[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Restart fd hygiene ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_mark_fds_cloexec_unmarks_inheritable_socket():
|
||||||
|
"""Werkzeug sets the listening socket inheritable; the restart must undo
|
||||||
|
that or the socket leaks through execv and squats on the port."""
|
||||||
|
import socket
|
||||||
|
import routes.updates as upd
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
try:
|
||||||
|
s.set_inheritable(True)
|
||||||
|
assert s.get_inheritable() is True
|
||||||
|
upd._mark_fds_cloexec()
|
||||||
|
assert s.get_inheritable() is False
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
# ── /api/update/settings ──────────────────────────────────────────────────────
|
# ── /api/update/settings ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_settings_roundtrip(client, monkeypatch):
|
def test_settings_roundtrip(client, monkeypatch):
|
||||||
|
|||||||
Reference in new issue
Block a user