fix(security): keep internal IPs out of shipped code and docs/ out of the public image

Audit leak-hygiene findings:
- The operator's real internal dev-VM/WireGuard IPs (10.10.90.136/.162/.165) were shipped in
  vite.config.js (hmr.host), lang hint/placeholder strings, a code comment and the CHANGELOG.
  vite HMR host is now env-driven (VITE_HMR_HOST, dev .env only); all other occurrences are replaced
  with RFC5737 documentation addresses (203.0.113.x). Tests updated in lockstep.
- docs/ is export-ignored from the git tree and named in promote.sh's refuse-to-publish list, but was
  never .dockerignore'd, so `COPY . .` baked the private docs (with private host/URL refs) into the
  public image, which the git-tree leak-guard never inspects. Added docs/ (and art/) to .dockerignore.
- /update.log (new repo-root update transcript) gitignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-07-04 10:25:10 +02:00
parent d7156ddc96
commit a6176341e2
12 changed files with 27 additions and 16 deletions

View File

@ -23,7 +23,10 @@ storage/framework/sessions/*
storage/framework/views/*
database/*.sqlite
# Dev tooling / tests / docs (not needed at runtime)
# Dev tooling / tests / docs (not needed at runtime). NOTE: docs/ is internal-only (export-ignored
# from the public git tree AND named in promote.sh's refuse-to-publish list) — it MUST also be kept
# out of the built image, or `COPY . .` bakes the private docs (with private host/URL refs) into the
# public GHCR image, which the git-tree leak-guard never inspects. Likewise art/ (README assets only).
tests
phpunit.xml
.phpunit.cache
@ -33,6 +36,8 @@ kickoff-prompt.md
rules.md
CLAUDE.md
README.md
docs
art
# Dev-only release bridge — must NOT bake into the PUBLIC prod image (it carries the private
# upstream references). Also export-ignored from the public repo. Dev keeps it via the bind-mount;

4
.gitignore vendored
View File

@ -39,6 +39,10 @@ yarn-error.log
/run/*
!/run/.gitkeep
# host-side update transcript (written at the repo root — NOT under ./run — so a compromised
# container cannot symlink-redirect the root updater's tee; see docker/restart-sentinel/watch.sh)
/update.log
# internal / AI-collaboration docs — kept on the dev box, never committed, mirrored or shipped
/CLAUDE.md
/rules.md

View File

@ -213,14 +213,14 @@ _Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `v
ein ausgewogenes Maß (~240 px) reduziert — weiterhin pixelgenau (`crispEdges`) auf weißem Rahmen,
damit das Scannen am Handy zuverlässig bleibt.
- **Download-Dateiname als Tunnel-Name.** Die heruntergeladene Client-Config heißt jetzt
`<endpoint-host>-<peer>.conf` (z. B. `10.10.90.165-laptop.conf`) statt `clusev-wireguard.conf`.
`<endpoint-host>-<peer>.conf` (z. B. `203.0.113.10-laptop.conf`) statt `clusev-wireguard.conf`.
WireGuard-Apps benennen einen Tunnel nach der Datei, sodass der Tunnel sinnvoll vorbenannt ist.
(Beim QR-Scan fragt die WireGuard-App selbst nach einem Namen — das gibt der QR-Standard nicht her;
ein Hinweis im Einmal-Dialog erklärt beide Wege.)
### Behoben
- **Endpoint ohne Port machte jede Client-Config ungültig.** Wurde beim Einrichten/Ändern nur ein
Host ohne Port eingegeben (z. B. `10.10.90.165` statt `10.10.90.165:51820`), landete dieser
Host ohne Port eingegeben (z. B. `203.0.113.10` statt `203.0.113.10:51820`), landete dieser
Wert unverändert in den Peer-Configs — WireGuard verlangt aber zwingend `host:port`, sodass die
WireGuard-App den (QR- oder Datei-)Import als „keine gültige WireGuard-Konfiguration" ablehnte.
Der Listen-Port wird jetzt automatisch ergänzt, wenn der Endpoint keinen Port enthält (sowohl bei

View File

@ -382,7 +382,7 @@ class Index extends Component
/**
* Download the show-once client config as a .conf file. The filename becomes the tunnel name when
* imported (WireGuard apps name a tunnel after the file), so build it from the endpoint host + peer
* name e.g. "10.10.90.165-laptop.conf". (A QR scan can't carry a name; the app prompts for one.)
* name e.g. "203.0.113.10-laptop.conf". (A QR scan can't carry a name; the app prompts for one.)
*/
public function downloadConfig(): ?StreamedResponse
{

View File

@ -720,7 +720,7 @@ class FleetService
{
$ips = [];
foreach ($this->lines($addr) as $line) {
// "2: ens18 inet 10.10.90.162/24 brd ..."
// "2: ens18 inet 203.0.113.11/24 brd ..."
if (preg_match('/^\d+:\s+(\S+)\s+inet\s+([\d.]+)/', $line, $m)) {
$ips[$m[1]][] = $m[2];
}

View File

@ -26,7 +26,7 @@ return [
'name_label' => 'Name',
'name_placeholder' => 'z. B. Prod-Web-01',
'ip_host_label' => 'IP/Host',
'ip_host_placeholder' => '10.10.90.10 oder host.example.com',
'ip_host_placeholder' => '203.0.113.10 oder host.example.com',
'ssh_port_label' => 'SSH-Port',
'user_label' => 'Benutzer',
'auth_label' => 'Authentifizierung',

View File

@ -69,7 +69,7 @@ return [
'ssh_lock_off_title' => 'SSH-Sperre aufheben?',
'ssh_lock_off_body' => 'Port 22 wird danach wieder normal erreichbar (vorbehaltlich einer Cloud-/Host-Firewall).',
'endpoint_label' => 'Öffentlicher Endpoint (Host)',
'endpoint_hint' => 'Nur den Host eintragen (z. B. 10.10.90.165) — der Port kommt automatisch vom Listen-Port. Einen abweichenden Port nur bei Portweiterleitung angeben (host:port). Betrifft nur künftige Peer-Configs.',
'endpoint_hint' => 'Nur den Host eintragen (z. B. 203.0.113.10) — der Port kommt automatisch vom Listen-Port. Einen abweichenden Port nur bei Portweiterleitung angeben (host:port). Betrifft nur künftige Peer-Configs.',
'dns_label' => 'DNS-Server',
'dns_hint' => 'DNS für neue Peer-Configs (z. B. dein eigenes Gateway 10.0.0.1). Mehrere durch Komma. Betrifft nur künftige Clients.',
'dns_invalid' => 'Ungültige DNS-Adresse(n) — nur IPv4, kommagetrennt.',
@ -89,7 +89,7 @@ return [
'setup_subnet' => 'Subnetz',
'setup_port' => 'Listen-Port (UDP)',
'setup_endpoint' => 'Öffentlicher Endpoint (optional)',
'setup_endpoint_hint' => 'Leer = automatisch erkannte öffentliche IP. Sonst nur den Host (z. B. 10.10.90.165) — der Port kommt automatisch vom Listen-Port.',
'setup_endpoint_hint' => 'Leer = automatisch erkannte öffentliche IP. Sonst nur den Host (z. B. 203.0.113.10) — der Port kommt automatisch vom Listen-Port.',
'setup_peer' => 'Name des ersten Clients',
'setup_submit' => 'Einrichten',
'no_host_response' => 'Keine Antwort vom Host — der WireGuard-Host-Dienst läuft nicht. Stack aktualisieren (Version-Seite) und erneut versuchen.',

View File

@ -25,7 +25,7 @@ return [
'name_label' => 'Name',
'name_placeholder' => 'e.g. Prod-Web-01',
'ip_host_label' => 'IP/Host',
'ip_host_placeholder' => '10.10.90.10 or host.example.com',
'ip_host_placeholder' => '203.0.113.10 or host.example.com',
'ssh_port_label' => 'SSH port',
'user_label' => 'User',
'auth_label' => 'Authentication',

View File

@ -69,7 +69,7 @@ return [
'ssh_lock_off_title' => 'Unlock SSH?',
'ssh_lock_off_body' => 'Port 22 becomes reachable normally again (subject to any cloud/host firewall).',
'endpoint_label' => 'Public endpoint (host)',
'endpoint_hint' => 'Enter just the host (e.g. 10.10.90.165) — the port comes from the listen port automatically. Only set a different port for port-forwarding (host:port). Affects only future peer configs.',
'endpoint_hint' => 'Enter just the host (e.g. 203.0.113.10) — the port comes from the listen port automatically. Only set a different port for port-forwarding (host:port). Affects only future peer configs.',
'dns_label' => 'DNS server',
'dns_hint' => 'DNS for new peer configs (e.g. your own gateway 10.0.0.1). Multiple comma-separated. Only affects future clients.',
'dns_invalid' => 'Invalid DNS address(es) — IPv4 only, comma-separated.',
@ -89,7 +89,7 @@ return [
'setup_subnet' => 'Subnet',
'setup_port' => 'Listen port (UDP)',
'setup_endpoint' => 'Public endpoint (optional)',
'setup_endpoint_hint' => 'Empty = auto-detected public IP. Otherwise just the host (e.g. 10.10.90.165) — the port comes from the listen port automatically.',
'setup_endpoint_hint' => 'Empty = auto-detected public IP. Otherwise just the host (e.g. 203.0.113.10) — the port comes from the listen port automatically.',
'setup_peer' => 'First client name',
'setup_submit' => 'Set up',
'no_host_response' => 'No response from the host — the WireGuard host service is not running. Update the stack (Versions page) and try again.',

View File

@ -16,7 +16,7 @@ class DashboardAuditTest extends TestCase
public function test_dashboard_recent_events_use_readable_labels(): void
{
$this->actingAs(User::factory()->create(['must_change_password' => false]));
AuditEvent::create(['actor' => 'Administrator', 'action' => 'wg.set-endpoint', 'target' => '10.10.90.165', 'ip' => '127.0.0.1']);
AuditEvent::create(['actor' => 'Administrator', 'action' => 'wg.set-endpoint', 'target' => '203.0.113.10', 'ip' => '127.0.0.1']);
Livewire::test(Dashboard::class)
->assertSee('WireGuard-Endpoint geändert') // readable label

View File

@ -270,10 +270,10 @@ class WireguardPageTest extends TestCase
{
// The .conf filename becomes the tunnel name on import — build it from endpoint host + peer.
Livewire::test(Index::class)
->set('resultConfig', "[Interface]\nPrivateKey = x\n\n[Peer]\nEndpoint = 10.10.90.165:51820")
->set('resultConfig', "[Interface]\nPrivateKey = x\n\n[Peer]\nEndpoint = 203.0.113.10:51820")
->set('resultName', 'laptop')
->call('downloadConfig')
->assertFileDownloaded('10.10.90.165-laptop.conf');
->assertFileDownloaded('203.0.113.10-laptop.conf');
}
public function test_download_config_falls_back_to_a_default_filename(): void

View File

@ -16,9 +16,11 @@ export default defineConfig({
port: 5173,
strictPort: true,
cors: true,
// Remote HMR: hot file + websocket point at the VM, not 0.0.0.0/localhost.
// Remote HMR: hot file + websocket point at the dev VM, not 0.0.0.0/localhost. The host is
// env-driven (VITE_HMR_HOST) so no private/internal address is committed or baked into the
// shipped image; set it in the dev .env only. Dev-only — prod builds assets, no HMR server.
hmr: {
host: '10.10.90.136',
host: process.env.VITE_HMR_HOST || 'localhost',
},
// The repo is rooted in $HOME — keep the watcher off heavy/irrelevant trees.
watch: {