mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Compose billing and diagnostic repairs with reviewed current source
Preserve the exact backup witness and both owning histories, including upstream opacity, trusted-peer help and portable demo fixtures. The existing full-state and guest-safety contracts remain intact. Change-source: pulse-maintainer
This commit is contained in:
@@ -145,7 +145,10 @@ class TransactionTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
root = Path(self.tmp.name)
|
||||
# Model an owned estate, not the OS alias leading to its temporary
|
||||
# directory (for example macOS /var -> /private/var). The production
|
||||
# history guard still rejects symlinks introduced inside the estate.
|
||||
root = Path(self.tmp.name).resolve()
|
||||
self.paths = {name: root / "estate" / name for name in engine.PATHS}
|
||||
for name, path in self.paths.items():
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -555,7 +558,7 @@ class TransactionTest(unittest.TestCase):
|
||||
def test_unhealthy_recovery_retains_original_generated_history(self):
|
||||
self.host.unhealthy = True
|
||||
code, receipt = self.run_transaction()
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(code, 0, receipt.get("failure"))
|
||||
self.assertFalse(receipt["healthy_baseline"])
|
||||
self.assertFalse((self.paths["data"] / "alerts/events.db").exists())
|
||||
self.assertEqual((self.attempt / "snapshot/data/alerts/events.db").read_bytes(), b"synthetic opaque persistent file")
|
||||
@@ -588,7 +591,7 @@ class TransactionTest(unittest.TestCase):
|
||||
def test_listener_readiness_does_not_spend_the_watch_window(self):
|
||||
self.host.ready_until = 30
|
||||
code, receipt = self.run_transaction()
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(code, 0, receipt.get("failure"))
|
||||
self.assertEqual(receipt["forward"]["elapsed_seconds"], 300)
|
||||
self.assertGreaterEqual(self.host.clock, 330)
|
||||
self.assertEqual(self.host.starts, 1)
|
||||
@@ -921,6 +924,29 @@ class RuntimeSelectionTest(unittest.TestCase):
|
||||
engine.runtime_members(self.archive(directory, entries), 'v6.4.6')
|
||||
|
||||
|
||||
class PortableTransactionFixtureTest(unittest.TestCase):
|
||||
def test_recovery_controls_accept_a_symlinked_temporary_parent(self):
|
||||
# macOS commonly exposes its temporary directory through /var ->
|
||||
# /private/var. Exercise both failing recovery paths with a real
|
||||
# equivalent alias, without changing the production symlink guard.
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory).resolve()
|
||||
real = root / "real"
|
||||
real.mkdir()
|
||||
alias = root / "alias"
|
||||
alias.symlink_to(real, target_is_directory=True)
|
||||
cases = unittest.TestSuite([
|
||||
TransactionTest("test_unhealthy_recovery_retains_original_generated_history"),
|
||||
TransactionTest("test_listener_readiness_does_not_spend_the_watch_window"),
|
||||
])
|
||||
result = unittest.TestResult()
|
||||
with patch.object(tempfile, "tempdir", str(alias)):
|
||||
cases.run(result)
|
||||
self.assertEqual(result.testsRun, 2)
|
||||
self.assertEqual(result.errors, [])
|
||||
self.assertEqual(result.failures, [])
|
||||
|
||||
|
||||
class NativeGateTest(unittest.TestCase):
|
||||
def row(self, run_id=10, **fields):
|
||||
return {'id': run_id, 'head_sha': 'a' * 40, 'run_attempt': 1,
|
||||
|
||||
@@ -307,7 +307,7 @@ Environment variables take precedence over `system.json`.
|
||||
| `DISCOVERY_HTTP_TIMEOUT_MS` | HTTP probe timeout (ms) | `2000` |
|
||||
| `PULSE_AUTH_HIDE_LOCAL_LOGIN` | Hide username/password form | `false` |
|
||||
| `DEMO_MODE` | Enable read-only demo mode | `false` |
|
||||
| `PULSE_TRUSTED_PROXY_CIDRS` | Comma-separated IPs/CIDRs trusted to supply `X-Forwarded-For`/`X-Real-IP` | *(unset)* |
|
||||
| `PULSE_TRUSTED_PROXY_CIDRS` | Comma-separated immediate proxy IPs/CIDRs trusted for forwarded client IP, scheme, host and port. Use the peer seen by Pulse; wildcard ranges are rejected. See [Reverse Proxy](REVERSE_PROXY.md#before-configuring-the-proxy). | *(unset)* |
|
||||
| `PULSE_TRUSTED_NETWORKS` | Comma-separated CIDRs treated as trusted local networks (does not bypass auth) | *(unset)* |
|
||||
| `ALLOW_UNPROTECTED_EXPORT` | Allow unauthenticated config export on public networks when no auth is configured (use with caution) | `false` |
|
||||
|
||||
|
||||
+94
-29
@@ -2,49 +2,118 @@
|
||||
|
||||
Pulse uses WebSockets for real-time updates. Your proxy **MUST** support WebSockets.
|
||||
|
||||
## Before configuring the proxy
|
||||
|
||||
Use HTTPS at the public proxy and keep Pulse's backend private. The examples
|
||||
below are configuration fragments, not complete TLS or authentication setups.
|
||||
Keep Pulse authentication enabled; TLS termination does not authenticate users.
|
||||
For identity supplied by an IdP/proxy, follow the separate
|
||||
[proxy authentication guide](PROXY_AUTH.md), including its Header Trust Boundary.
|
||||
|
||||
**Forwarded HTTPS headers are trusted only from a configured immediate peer.**
|
||||
Set `PULSE_TRUSTED_PROXY_CIDRS` in Pulse's deployment configuration to the proxy
|
||||
address **as seen by Pulse**, not the browser's address or the public hostname.
|
||||
For a proxy on the same host connecting to `127.0.0.1:7655`, use:
|
||||
|
||||
```dotenv
|
||||
PULSE_TRUSTED_PROXY_CIDRS=127.0.0.1/32
|
||||
```
|
||||
|
||||
Use `::1/128` instead if that connection actually uses IPv6 loopback. A Docker
|
||||
proxy normally reaches Pulse from its private container/network address, not
|
||||
loopback; trust only that proxy's fixed address or a narrowly isolated proxy
|
||||
network. Do not trust a whole LAN or all Docker networks to make a warning go
|
||||
away. Wildcard ranges `0.0.0.0/0` and `::/0` are rejected at startup.
|
||||
|
||||
At the public edge, replace client-supplied forwarded scheme, host and client-IP
|
||||
headers with values established by the proxy; do not append an untrusted value.
|
||||
With multiple proxy hops, configure the upstream trust boundary on each hop
|
||||
and trust only the final peer in Pulse. `PULSE_TRUSTED_NETWORKS` is not a
|
||||
substitute for proxy trust, and proxy trust is not an authentication bypass.
|
||||
See [configuration overrides](CONFIGURATION.md#common-overrides-environment-variables).
|
||||
|
||||
## ⚡ Quick Configs
|
||||
|
||||
### Nginx
|
||||
|
||||
Inside your HTTPS `server` block, for a single public-edge proxy:
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://localhost:7655;
|
||||
proxy_pass http://127.0.0.1:7655;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_set_header X-Forwarded-Scheme "";
|
||||
proxy_set_header Forwarded "";
|
||||
|
||||
# Critical for WebSockets
|
||||
proxy_read_timeout 86400; # 24h
|
||||
}
|
||||
```
|
||||
|
||||
### Caddy
|
||||
|
||||
Caddy handles the WebSocket upgrade and sets forwarded client-IP, host and
|
||||
scheme headers automatically. The removals below discard other client-supplied
|
||||
forwarding metadata. Pulse still needs the matching trusted-peer setting above:
|
||||
|
||||
```caddy
|
||||
pulse.example.com {
|
||||
reverse_proxy localhost:7655
|
||||
reverse_proxy 127.0.0.1:7655 {
|
||||
header_up -Forwarded
|
||||
header_up -X-Forwarded-Scheme
|
||||
header_up -X-Forwarded-Port
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Traefik (Docker Compose)
|
||||
|
||||
These labels assume an existing HTTPS entrypoint named `websecure` and a
|
||||
configured certificate. Keep Pulse on the private proxy network; do not publish
|
||||
its backend port publicly. Apply trusted-peer configuration to Pulse separately.
|
||||
Keep Traefik's `forwardedHeaders.insecure` disabled; upstream forwarded headers
|
||||
need their own narrow trusted-IP configuration, not blanket client trust.
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.pulse.rule=Host(`pulse.example.com`)"
|
||||
- "traefik.http.routers.pulse.entrypoints=websecure"
|
||||
- "traefik.http.routers.pulse.tls=true"
|
||||
- "traefik.http.routers.pulse.service=pulse"
|
||||
- "traefik.http.services.pulse.loadbalancer.server.port=7655"
|
||||
```
|
||||
|
||||
### Apache
|
||||
|
||||
Inside a TLS-enabled virtual host, with the required proxy, rewrite and headers
|
||||
modules enabled:
|
||||
|
||||
```apache
|
||||
ProxyPreserveHost On
|
||||
ProxyAddHeaders On
|
||||
RequestHeader unset X-Forwarded-For
|
||||
RequestHeader unset X-Forwarded-Host
|
||||
RequestHeader unset X-Forwarded-Port
|
||||
RequestHeader unset X-Real-IP
|
||||
RequestHeader set X-Forwarded-Proto "https"
|
||||
RequestHeader unset X-Forwarded-Scheme
|
||||
RequestHeader unset Forwarded
|
||||
RewriteEngine On
|
||||
RewriteCond %{HTTP:Upgrade} websocket [NC]
|
||||
RewriteCond %{HTTP:Connection} upgrade [NC]
|
||||
RewriteRule ^/?(.*) "ws://localhost:7655/$1" [P,L]
|
||||
RewriteRule ^/?(.*) "ws://127.0.0.1:7655/$1" [P,L]
|
||||
|
||||
ProxyPass / http://localhost:7655/
|
||||
ProxyPassReverse / http://localhost:7655/
|
||||
ProxyPass / http://127.0.0.1:7655/
|
||||
ProxyPassReverse / http://127.0.0.1:7655/
|
||||
```
|
||||
|
||||
---
|
||||
@@ -55,33 +124,29 @@ ProxyPassReverse / http://localhost:7655/
|
||||
|
||||
If your reverse proxy terminates SSL but Pulse shows "HTTPS: HTTP only" in Settings → Security:
|
||||
|
||||
**Cause**: Pulse detects HTTPS in two ways:
|
||||
1. Direct TLS connection (`req.TLS != nil`)
|
||||
2. The `X-Forwarded-Proto: https` header
|
||||
Pulse recognises direct TLS, or `X-Forwarded-Proto: https` from an immediate
|
||||
peer in `PULSE_TRUSTED_PROXY_CIDRS`. An untrusted peer's header is ignored even
|
||||
when the browser used HTTPS. Adding the header alone is not sufficient.
|
||||
|
||||
If your proxy terminates SSL but doesn't forward this header, Pulse sees plain HTTP.
|
||||
Check the proxy's actual backend connection address and the effective Pulse
|
||||
setting first, then check that the TLS proxy sends exactly `https`. For Nginx,
|
||||
keep `proxy_set_header X-Forwarded-Proto $scheme` inside the HTTPS server's
|
||||
location; do not label a plain-HTTP request as HTTPS. Caddy normally needs no
|
||||
header override. Any Caddy `header_up` override belongs **inside** its
|
||||
`reverse_proxy` block, not directly in the site block.
|
||||
|
||||
**Fix**: Add the `X-Forwarded-Proto` header in your proxy config:
|
||||
|
||||
```nginx
|
||||
# Nginx
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
```
|
||||
|
||||
```caddy
|
||||
# Caddy (automatic, but explicit override if needed)
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
```
|
||||
|
||||
```apache
|
||||
# Apache
|
||||
RequestHeader set X-Forwarded-Proto "https"
|
||||
```
|
||||
After applying configuration through your normal deployment path, sign in
|
||||
through the real HTTPS URL and recheck Security Posture and live dashboard
|
||||
updates. If using SSO, check its redirect/callback on that same URL. A successful
|
||||
`/api/health` request alone does not verify authentication, WebSockets or proxy
|
||||
trust. Do not disable authentication, certificate verification or origin checks,
|
||||
or expose the backend, to test this. Share only redacted configuration and
|
||||
HTTP/error status, not cookies, proxy secrets or a full network export.
|
||||
|
||||
### Other Issues
|
||||
|
||||
- **"Connection Lost"**: WebSocket upgrade failed. Check `Upgrade` and `Connection` headers.
|
||||
- **502 Bad Gateway**: Pulse is not running on port 7655.
|
||||
- **502 Bad Gateway**: Check Pulse's actual listener and the proxy's upstream address, port and network reachability; this does not by itself prove Pulse is stopped. Container `localhost` refers to that container, not another service.
|
||||
- **CORS Errors**: Do not add CORS headers in the proxy; Pulse handles them. Set **Settings → System → Network → Allowed Origins** or use `ALLOWED_ORIGINS` if needed.
|
||||
- **OIDC redirects fail**: Ensure `X-Forwarded-Proto` is set (see above).
|
||||
- **Wrong client IPs**: Set `PULSE_TRUSTED_PROXY_CIDRS` to your proxy IP/CIDR so `X-Forwarded-For` is trusted.
|
||||
- **Wrong client IPs**: Check the immediate peer and header replacement above. Do not broaden proxy trust or change authentication to repair attribution.
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// Real shipped Docs/router/CSS, with no backend, proxy or real credential.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { chromium, webkit } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const workspace = process.cwd();
|
||||
const root = path.join(workspace, 'frontend-modern');
|
||||
const output = path.join(workspace, 'tmp', 'reverse-proxy-help');
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
const binding = JSON.parse(fs.readFileSync(path.join(output, 'binding.json'), 'utf8'));
|
||||
assert.match(binding.sourceSha, /^[0-9a-f]{40}$/);
|
||||
for (const [file, digest] of Object.entries(binding.contentSha256)) {
|
||||
assert.equal(
|
||||
createHash('sha256').update(fs.readFileSync(path.join(workspace, file))).digest('hex'),
|
||||
digest,
|
||||
file,
|
||||
);
|
||||
}
|
||||
process.chdir(root);
|
||||
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
|
||||
const server = await createServer({
|
||||
root,
|
||||
configFile: path.join(root, 'vite.config.ts'),
|
||||
cacheDir: path.join(root, 'node_modules', '.vite-reverse-proxy-help'),
|
||||
server: { host: '127.0.0.1', port: 5268, strictPort: true },
|
||||
optimizeDeps: { noDiscovery: true, include: [] },
|
||||
});
|
||||
const observations = [];
|
||||
let browser;
|
||||
const inViewport = (id) => {
|
||||
const heading = document.getElementById(id);
|
||||
return heading && heading.getBoundingClientRect().top >= 0 &&
|
||||
heading.getBoundingClientRect().top < innerHeight;
|
||||
};
|
||||
try {
|
||||
await server.listen();
|
||||
for (const [engine, width, height, tone] of [
|
||||
['chromium', 1280, 1000, 'light'], ['webkit', 390, 844, 'dark'],
|
||||
]) {
|
||||
browser = await { chromium, webkit }[engine].launch(engine === 'chromium'
|
||||
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
|
||||
: { headless: true });
|
||||
const page = await browser.newPage({
|
||||
viewport: { width, height },
|
||||
...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}),
|
||||
});
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') errors.push(message.text());
|
||||
});
|
||||
await page.goto('http://127.0.0.1:5268/browser-tests/docs-fragment-navigation.html');
|
||||
if (tone === 'dark') await page.evaluate(() => document.documentElement.classList.add('dark'));
|
||||
await page.getByRole('link', { name: '← All documentation' }).click();
|
||||
const entry = page.locator('a[data-doc-link][href="/docs/REVERSE_PROXY"]').first();
|
||||
await entry.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
const heading = page.locator('#before-configuring-the-proxy');
|
||||
await heading.waitFor();
|
||||
await heading.scrollIntoViewIfNeeded();
|
||||
let text = await page.locator('article').innerText();
|
||||
for (const phrase of [
|
||||
'configured immediate peer', 'PULSE_TRUSTED_PROXY_CIDRS=127.0.0.1/32',
|
||||
'Wildcard ranges', 'rejected at startup', 'not an authentication bypass',
|
||||
'Adding the header alone is not sufficient',
|
||||
]) assert.ok(text.includes(phrase), phrase);
|
||||
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
|
||||
await page.screenshot({ path: path.join(output, `${engine}-trust.png`) });
|
||||
const nginx = page.locator('pre').filter({ hasText: 'proxy_set_header X-Forwarded-For $remote_addr;' });
|
||||
await nginx.scrollIntoViewIfNeeded();
|
||||
const copied = await nginx.innerText();
|
||||
assert.ok(copied.includes('proxy_set_header X-Forwarded-Proto $scheme;'));
|
||||
assert.ok(copied.includes('proxy_set_header Forwarded "";'));
|
||||
assert.ok(!copied.includes('$proxy_add_x_forwarded_for'));
|
||||
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
|
||||
await page.screenshot({ path: path.join(output, `${engine}-nginx.png`) });
|
||||
const configuration = page.getByRole('link', { name: 'configuration overrides', exact: true });
|
||||
await configuration.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(inViewport, 'common-overrides-environment-variables');
|
||||
text = await page.locator('article').innerText();
|
||||
assert.ok(text.includes('forwarded client IP, scheme, host and port'));
|
||||
const back = page.getByRole('link', { name: 'Reverse Proxy', exact: true });
|
||||
await back.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(inViewport, 'before-configuring-the-proxy');
|
||||
assert.ok(page.url().endsWith('/docs/REVERSE_PROXY#before-configuring-the-proxy'));
|
||||
const authentication = page.getByRole('link', { name: 'proxy authentication guide', exact: true });
|
||||
await authentication.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
const boundary = page.getByRole('heading', { name: '⚠️ Header Trust Boundary', exact: true });
|
||||
await boundary.waitFor();
|
||||
assert.ok(page.url().endsWith('/docs/PROXY_AUTH'));
|
||||
await boundary.scrollIntoViewIfNeeded();
|
||||
assert.ok((await page.locator('article').innerText()).includes('never append to them'));
|
||||
assert.deepEqual(errors, []);
|
||||
observations.push({
|
||||
engine, width, height, tone, browserVersion: browser.version(),
|
||||
trustWarningRendered: true, safeNginxBlockRendered: true,
|
||||
keyboardConfigurationRoundTrip: true, keyboardAuthenticationBoundary: true,
|
||||
noPageOverflow: true, errors,
|
||||
});
|
||||
await browser.close();
|
||||
browser = undefined;
|
||||
}
|
||||
const result = {
|
||||
result: 'passed', playwrightVersion: require('playwright/package.json').version,
|
||||
sourceSha: binding.sourceSha, verifiedAt: new Date().toISOString(),
|
||||
contentSha256: binding.contentSha256, dependencySnapshot: binding.dependencySnapshot,
|
||||
scope: 'Production Docs rendering/navigation; not native proxy configuration, TLS or SSO acceptance',
|
||||
observations,
|
||||
};
|
||||
fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2) + '\n');
|
||||
console.log(JSON.stringify(result));
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "beb6c3f6a6f3fad74a1b9f76745bacdadcab7834",
|
||||
"verified_at": "2026-10-03T18:30:37Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/AI/Chat/index.tsx",
|
||||
"frontend-modern/src/components/Settings/AIModelSelectionSection.tsx",
|
||||
"frontend-modern/src/components/Settings/DataHandlingPanel.tsx",
|
||||
"frontend-modern/src/components/Settings/RolesPanel.tsx",
|
||||
"frontend-modern/src/components/Settings/SettingsPageShell.tsx",
|
||||
"frontend-modern/src/components/Workloads/StackedDiskBar.tsx",
|
||||
"frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx",
|
||||
"frontend-modern/src/pages/Actions.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/AI/Chat/index.tsx": "27d3bd9fbdb86c7f6048b29a65d6bac41308f4650ebb7da1b7072a0fec5522e5",
|
||||
"frontend-modern/src/components/Settings/AIModelSelectionSection.tsx": "c94ce3d2b8c4c47bf8b07699bdbd98e9f009feb77bebc7bb81dc24f784248f87",
|
||||
"frontend-modern/src/components/Settings/DataHandlingPanel.tsx": "c0eff8e2c786a3baa45c22d0c7118ed55ad1d52b33f8e06692b7c3222431db96",
|
||||
"frontend-modern/src/components/Settings/RolesPanel.tsx": "f8b1cb3a8e47e2815c3978ef07239699cb60989a8ecacd93024178896f6211de",
|
||||
"frontend-modern/src/components/Settings/SettingsPageShell.tsx": "18a195c4c71a939ae17bba3136854f6419c5cbc6c8acb6e62d003d016b0dec74",
|
||||
"frontend-modern/src/components/Workloads/StackedDiskBar.tsx": "be53a9d0c349a54516b29de58bf8e6fb28af6ea9f8028965591aa3cb960a851d",
|
||||
"frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx": "3c4883dcd79af1ecea3c7e967de8a05797c5ffd360e873dbb8550f0632708438",
|
||||
"frontend-modern/src/pages/Actions.tsx": "9ce58a091378c5edc1469f3ad496194668d6d743a217aa43bed6d7ab9c508dcb"
|
||||
},
|
||||
"routes": [
|
||||
"/settings/security-data-handling",
|
||||
"/settings/security-roles",
|
||||
"/settings/system-ai",
|
||||
"/proxmox",
|
||||
"/proxmox/backups/coverage",
|
||||
"/actions",
|
||||
"/alerts/thresholds",
|
||||
"/patrol"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"light and dark color schemes on a private mock stack with a dev Pro licence; each restored class toggled off and on in one page load for before/after",
|
||||
"Resource Privacy at 390px scrolled under the sticky Settings header: body text no longer shows through (bg-surface/95)",
|
||||
"Resource Privacy Internal meter at 59% renders a slate-400 fill instead of an empty track",
|
||||
"Proxmox backups coverage filter chips: Unknown shows a grey status dot like its siblings",
|
||||
"Assistant session picker in dark mode: section header divider is border-border/60 instead of a bright gray-200 line",
|
||||
"Assistant model-route chips and Patrol model readiness card markup in dark mode: border-current borders replace bright gray-200",
|
||||
"Roles table at 390px with rbac capability and roles intercepted: custom-role rows 53px like built-in rows with 44px Edit and Delete targets; 28px buttons on desktop",
|
||||
"Alerts thresholds mobile card: Cancel threshold edits renders 44x44 like Save, Edit and Delay",
|
||||
"Patrol decision detail at 390px: Copy ID 44px tall in a row already 44px from Open resource"
|
||||
],
|
||||
"interactions": [
|
||||
"scrolled the app scroll shell under the Settings phone header",
|
||||
"hovered an Actions row and a node disk segment",
|
||||
"opened the Assistant, sent a message and opened the sessions picker",
|
||||
"opened a Patrol decision and a threshold edit on the phone layout"
|
||||
]
|
||||
}
|
||||
@@ -307,7 +307,7 @@ Environment variables take precedence over `system.json`.
|
||||
| `DISCOVERY_HTTP_TIMEOUT_MS` | HTTP probe timeout (ms) | `2000` |
|
||||
| `PULSE_AUTH_HIDE_LOCAL_LOGIN` | Hide username/password form | `false` |
|
||||
| `DEMO_MODE` | Enable read-only demo mode | `false` |
|
||||
| `PULSE_TRUSTED_PROXY_CIDRS` | Comma-separated IPs/CIDRs trusted to supply `X-Forwarded-For`/`X-Real-IP` | *(unset)* |
|
||||
| `PULSE_TRUSTED_PROXY_CIDRS` | Comma-separated immediate proxy IPs/CIDRs trusted for forwarded client IP, scheme, host and port. Use the peer seen by Pulse; wildcard ranges are rejected. See [Reverse Proxy](REVERSE_PROXY.md#before-configuring-the-proxy). | *(unset)* |
|
||||
| `PULSE_TRUSTED_NETWORKS` | Comma-separated CIDRs treated as trusted local networks (does not bypass auth) | *(unset)* |
|
||||
| `ALLOW_UNPROTECTED_EXPORT` | Allow unauthenticated config export on public networks when no auth is configured (use with caution) | `false` |
|
||||
|
||||
|
||||
@@ -2,49 +2,118 @@
|
||||
|
||||
Pulse uses WebSockets for real-time updates. Your proxy **MUST** support WebSockets.
|
||||
|
||||
## Before configuring the proxy
|
||||
|
||||
Use HTTPS at the public proxy and keep Pulse's backend private. The examples
|
||||
below are configuration fragments, not complete TLS or authentication setups.
|
||||
Keep Pulse authentication enabled; TLS termination does not authenticate users.
|
||||
For identity supplied by an IdP/proxy, follow the separate
|
||||
[proxy authentication guide](PROXY_AUTH.md), including its Header Trust Boundary.
|
||||
|
||||
**Forwarded HTTPS headers are trusted only from a configured immediate peer.**
|
||||
Set `PULSE_TRUSTED_PROXY_CIDRS` in Pulse's deployment configuration to the proxy
|
||||
address **as seen by Pulse**, not the browser's address or the public hostname.
|
||||
For a proxy on the same host connecting to `127.0.0.1:7655`, use:
|
||||
|
||||
```dotenv
|
||||
PULSE_TRUSTED_PROXY_CIDRS=127.0.0.1/32
|
||||
```
|
||||
|
||||
Use `::1/128` instead if that connection actually uses IPv6 loopback. A Docker
|
||||
proxy normally reaches Pulse from its private container/network address, not
|
||||
loopback; trust only that proxy's fixed address or a narrowly isolated proxy
|
||||
network. Do not trust a whole LAN or all Docker networks to make a warning go
|
||||
away. Wildcard ranges `0.0.0.0/0` and `::/0` are rejected at startup.
|
||||
|
||||
At the public edge, replace client-supplied forwarded scheme, host and client-IP
|
||||
headers with values established by the proxy; do not append an untrusted value.
|
||||
With multiple proxy hops, configure the upstream trust boundary on each hop
|
||||
and trust only the final peer in Pulse. `PULSE_TRUSTED_NETWORKS` is not a
|
||||
substitute for proxy trust, and proxy trust is not an authentication bypass.
|
||||
See [configuration overrides](CONFIGURATION.md#common-overrides-environment-variables).
|
||||
|
||||
## ⚡ Quick Configs
|
||||
|
||||
### Nginx
|
||||
|
||||
Inside your HTTPS `server` block, for a single public-edge proxy:
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://localhost:7655;
|
||||
proxy_pass http://127.0.0.1:7655;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_set_header X-Forwarded-Scheme "";
|
||||
proxy_set_header Forwarded "";
|
||||
|
||||
# Critical for WebSockets
|
||||
proxy_read_timeout 86400; # 24h
|
||||
}
|
||||
```
|
||||
|
||||
### Caddy
|
||||
|
||||
Caddy handles the WebSocket upgrade and sets forwarded client-IP, host and
|
||||
scheme headers automatically. The removals below discard other client-supplied
|
||||
forwarding metadata. Pulse still needs the matching trusted-peer setting above:
|
||||
|
||||
```caddy
|
||||
pulse.example.com {
|
||||
reverse_proxy localhost:7655
|
||||
reverse_proxy 127.0.0.1:7655 {
|
||||
header_up -Forwarded
|
||||
header_up -X-Forwarded-Scheme
|
||||
header_up -X-Forwarded-Port
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Traefik (Docker Compose)
|
||||
|
||||
These labels assume an existing HTTPS entrypoint named `websecure` and a
|
||||
configured certificate. Keep Pulse on the private proxy network; do not publish
|
||||
its backend port publicly. Apply trusted-peer configuration to Pulse separately.
|
||||
Keep Traefik's `forwardedHeaders.insecure` disabled; upstream forwarded headers
|
||||
need their own narrow trusted-IP configuration, not blanket client trust.
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.pulse.rule=Host(`pulse.example.com`)"
|
||||
- "traefik.http.routers.pulse.entrypoints=websecure"
|
||||
- "traefik.http.routers.pulse.tls=true"
|
||||
- "traefik.http.routers.pulse.service=pulse"
|
||||
- "traefik.http.services.pulse.loadbalancer.server.port=7655"
|
||||
```
|
||||
|
||||
### Apache
|
||||
|
||||
Inside a TLS-enabled virtual host, with the required proxy, rewrite and headers
|
||||
modules enabled:
|
||||
|
||||
```apache
|
||||
ProxyPreserveHost On
|
||||
ProxyAddHeaders On
|
||||
RequestHeader unset X-Forwarded-For
|
||||
RequestHeader unset X-Forwarded-Host
|
||||
RequestHeader unset X-Forwarded-Port
|
||||
RequestHeader unset X-Real-IP
|
||||
RequestHeader set X-Forwarded-Proto "https"
|
||||
RequestHeader unset X-Forwarded-Scheme
|
||||
RequestHeader unset Forwarded
|
||||
RewriteEngine On
|
||||
RewriteCond %{HTTP:Upgrade} websocket [NC]
|
||||
RewriteCond %{HTTP:Connection} upgrade [NC]
|
||||
RewriteRule ^/?(.*) "ws://localhost:7655/$1" [P,L]
|
||||
RewriteRule ^/?(.*) "ws://127.0.0.1:7655/$1" [P,L]
|
||||
|
||||
ProxyPass / http://localhost:7655/
|
||||
ProxyPassReverse / http://localhost:7655/
|
||||
ProxyPass / http://127.0.0.1:7655/
|
||||
ProxyPassReverse / http://127.0.0.1:7655/
|
||||
```
|
||||
|
||||
---
|
||||
@@ -55,33 +124,29 @@ ProxyPassReverse / http://localhost:7655/
|
||||
|
||||
If your reverse proxy terminates SSL but Pulse shows "HTTPS: HTTP only" in Settings → Security:
|
||||
|
||||
**Cause**: Pulse detects HTTPS in two ways:
|
||||
1. Direct TLS connection (`req.TLS != nil`)
|
||||
2. The `X-Forwarded-Proto: https` header
|
||||
Pulse recognises direct TLS, or `X-Forwarded-Proto: https` from an immediate
|
||||
peer in `PULSE_TRUSTED_PROXY_CIDRS`. An untrusted peer's header is ignored even
|
||||
when the browser used HTTPS. Adding the header alone is not sufficient.
|
||||
|
||||
If your proxy terminates SSL but doesn't forward this header, Pulse sees plain HTTP.
|
||||
Check the proxy's actual backend connection address and the effective Pulse
|
||||
setting first, then check that the TLS proxy sends exactly `https`. For Nginx,
|
||||
keep `proxy_set_header X-Forwarded-Proto $scheme` inside the HTTPS server's
|
||||
location; do not label a plain-HTTP request as HTTPS. Caddy normally needs no
|
||||
header override. Any Caddy `header_up` override belongs **inside** its
|
||||
`reverse_proxy` block, not directly in the site block.
|
||||
|
||||
**Fix**: Add the `X-Forwarded-Proto` header in your proxy config:
|
||||
|
||||
```nginx
|
||||
# Nginx
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
```
|
||||
|
||||
```caddy
|
||||
# Caddy (automatic, but explicit override if needed)
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
```
|
||||
|
||||
```apache
|
||||
# Apache
|
||||
RequestHeader set X-Forwarded-Proto "https"
|
||||
```
|
||||
After applying configuration through your normal deployment path, sign in
|
||||
through the real HTTPS URL and recheck Security Posture and live dashboard
|
||||
updates. If using SSO, check its redirect/callback on that same URL. A successful
|
||||
`/api/health` request alone does not verify authentication, WebSockets or proxy
|
||||
trust. Do not disable authentication, certificate verification or origin checks,
|
||||
or expose the backend, to test this. Share only redacted configuration and
|
||||
HTTP/error status, not cookies, proxy secrets or a full network export.
|
||||
|
||||
### Other Issues
|
||||
|
||||
- **"Connection Lost"**: WebSocket upgrade failed. Check `Upgrade` and `Connection` headers.
|
||||
- **502 Bad Gateway**: Pulse is not running on port 7655.
|
||||
- **502 Bad Gateway**: Check Pulse's actual listener and the proxy's upstream address, port and network reachability; this does not by itself prove Pulse is stopped. Container `localhost` refers to that container, not another service.
|
||||
- **CORS Errors**: Do not add CORS headers in the proxy; Pulse handles them. Set **Settings → System → Network → Allowed Origins** or use `ALLOWED_ORIGINS` if needed.
|
||||
- **OIDC redirects fail**: Ensure `X-Forwarded-Proto` is set (see above).
|
||||
- **Wrong client IPs**: Set `PULSE_TRUSTED_PROXY_CIDRS` to your proxy IP/CIDR so `X-Forwarded-For` is trusted.
|
||||
- **Wrong client IPs**: Check the immediate peer and header replacement above. Do not broaden proxy trust or change authentication to repair attribution.
|
||||
|
||||
@@ -4567,7 +4567,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
<For each={sessionPickerSections()}>
|
||||
{(section) => (
|
||||
<>
|
||||
<div class="sticky top-0 z-10 border-b bg-surface-alt px-3 py-1.5 text-[11px] font-semibold text-muted">
|
||||
<div class="sticky top-0 z-10 border-b border-border/60 bg-surface-alt px-3 py-1.5 text-[11px] font-semibold text-muted">
|
||||
{section.title}
|
||||
</div>
|
||||
<For each={section.sessions}>
|
||||
@@ -4879,7 +4879,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={switchToProviderReadinessAlternative}
|
||||
class="inline-flex max-w-44 items-center gap-1.5 rounded-md border bg-surface px-2 py-1 text-[10px] font-medium text-base-content hover:bg-surface-hover"
|
||||
class="inline-flex max-w-44 items-center gap-1.5 rounded-md border border-current/20 bg-surface px-2 py-1 text-[10px] font-medium text-base-content hover:bg-surface-hover"
|
||||
aria-label={providerReadinessAlternativeButtonLabel()}
|
||||
title={alternative().label}
|
||||
>
|
||||
@@ -4893,7 +4893,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
type="button"
|
||||
onClick={retrySelectedProviderReadiness}
|
||||
disabled={providerReadiness().status === 'checking'}
|
||||
class="inline-flex items-center gap-1.5 rounded-md border bg-surface px-2 py-1 text-[10px] font-medium text-base-content hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60"
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-current/20 bg-surface px-2 py-1 text-[10px] font-medium text-base-content hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60"
|
||||
aria-label="Retry route check"
|
||||
>
|
||||
<RefreshCwIcon class="h-3.5 w-3.5" />
|
||||
@@ -4901,7 +4901,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
</button>
|
||||
<a
|
||||
href={AI_CHAT_PROVIDER_READINESS_SETTINGS_HREF}
|
||||
class="inline-flex items-center gap-1.5 rounded-md border bg-surface px-2 py-1 text-[10px] font-medium text-base-content hover:bg-surface-hover"
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-current/20 bg-surface px-2 py-1 text-[10px] font-medium text-base-content hover:bg-surface-hover"
|
||||
>
|
||||
<SettingsIcon class="h-3.5 w-3.5" />
|
||||
<span>{AI_CHAT_PROVIDER_READINESS_SETTINGS_LABEL}</span>
|
||||
@@ -4910,7 +4910,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProviderReadinessVisible(false)}
|
||||
class="inline-flex items-center gap-1.5 rounded-md border bg-surface px-2 py-1 text-[10px] font-medium text-base-content hover:bg-surface-hover"
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-current/20 bg-surface px-2 py-1 text-[10px] font-medium text-base-content hover:bg-surface-hover"
|
||||
aria-label="Hide route status"
|
||||
>
|
||||
<XIcon class="h-3.5 w-3.5" />
|
||||
@@ -5114,7 +5114,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
<Show when={autonomousWarningVisible()}>
|
||||
<div
|
||||
class={`flex min-h-8 min-w-0 items-center gap-2 px-2.5 py-1.5 text-xs text-red-700 dark:text-red-200 ${
|
||||
currentStatus() ? 'border-t ' : ''
|
||||
currentStatus() ? 'border-t border-border/70' : ''
|
||||
}`}
|
||||
role="status"
|
||||
aria-label="Assistant chat actions warning"
|
||||
@@ -5149,7 +5149,9 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
<Show when={activityDockQueuedFollowUpCount() > 0}>
|
||||
<div
|
||||
class={`px-2.5 py-1.5 ${
|
||||
currentStatus() || autonomousWarningVisible() ? 'border-t ' : ''
|
||||
currentStatus() || autonomousWarningVisible()
|
||||
? 'border-t border-border/70'
|
||||
: ''
|
||||
}`}
|
||||
role="status"
|
||||
aria-label="Queued follow-up messages"
|
||||
|
||||
@@ -312,7 +312,7 @@ export const PatrolModelReadinessControl: Component<{ state: AISettingsState }>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div class="mt-2 border-t pt-2">
|
||||
<div class="mt-2 border-t border-current/15 pt-2">
|
||||
<p class="text-[11px] font-medium mb-1">Autonomy suitability</p>
|
||||
<div class="grid gap-1 sm:grid-cols-2">
|
||||
<For each={modeRows()}>
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from './dataHandlingPanelModel';
|
||||
|
||||
const meterClassByTone: Record<DataHandlingPostureItem['tone'], string> = {
|
||||
neutral: '',
|
||||
neutral: 'bg-slate-400',
|
||||
info: 'bg-sky-500 dark:bg-sky-400',
|
||||
success: 'bg-emerald-500 dark:bg-emerald-400',
|
||||
warning: 'bg-amber-500 dark:bg-amber-400',
|
||||
|
||||
@@ -112,7 +112,7 @@ export const RolesPanel: Component = () => {
|
||||
label="Edit role"
|
||||
tone="accent"
|
||||
size="sm"
|
||||
class="min-h-11 min-w-11 sm:min-h-0 sm:min-w-0"
|
||||
class="-my-2 min-h-11 min-w-11 sm:my-0 sm:min-h-0 sm:min-w-0"
|
||||
onClick={() => state.openEditRole(role)}
|
||||
>
|
||||
<Pencil class="w-4 h-4" />
|
||||
@@ -121,7 +121,7 @@ export const RolesPanel: Component = () => {
|
||||
label="Delete role"
|
||||
tone="danger"
|
||||
size="sm"
|
||||
class="min-h-11 min-w-11 sm:min-h-0 sm:min-w-0"
|
||||
class="-my-2 min-h-11 min-w-11 sm:my-0 sm:min-h-0 sm:min-w-0"
|
||||
onClick={() => state.handleDeleteRole(role)}
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
|
||||
@@ -263,7 +263,7 @@ export const SettingsPageShell: Component<SettingsPageShellProps> = (props) => {
|
||||
class={`min-w-0 flex-1 overflow-visible lg:overflow-hidden ${props.isMobileMenuOpen() ? 'hidden lg:block' : 'block'}`}
|
||||
>
|
||||
<Show when={props.flatTabs().length > 0}>
|
||||
<div class="sticky top-0 z-40 flex min-h-12 items-center border-b border-border-subtle px-2 backdrop-blur-sm lg:hidden">
|
||||
<div class="sticky top-0 z-40 flex min-h-12 items-center border-b border-border-subtle bg-surface/95 px-2 backdrop-blur-sm lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
||||
@@ -179,8 +179,19 @@ describe('RBAC paywall settings panels', () => {
|
||||
render(() => <RolesPanel />);
|
||||
|
||||
const edit = await screen.findByRole('button', { name: 'Edit role' });
|
||||
expect(edit).toHaveClass('min-h-11', 'min-w-11', 'sm:min-h-0', 'sm:min-w-0');
|
||||
// The negative margin keeps the 44px phone target inside the row's
|
||||
// existing height, so custom roles line up with built-in ones.
|
||||
expect(edit).toHaveClass(
|
||||
'-my-2',
|
||||
'min-h-11',
|
||||
'min-w-11',
|
||||
'sm:my-0',
|
||||
'sm:min-h-0',
|
||||
'sm:min-w-0',
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Delete role' })).toHaveClass(
|
||||
'-my-2',
|
||||
'sm:my-0',
|
||||
'min-h-11',
|
||||
'min-w-11',
|
||||
'sm:min-h-0',
|
||||
|
||||
@@ -212,7 +212,7 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
|
||||
<For each={presentation().verticalBars}>
|
||||
{(bar) => (
|
||||
<div
|
||||
class="group/disk relative h-full w-[5px] shrink-0 cursor-help overflow-hidden rounded-xs bg-surface-hover ring-1 ring-transparent transition-[transform,box-shadow,ring-color] duration-100 hover:scale-y-[1.08]"
|
||||
class="group/disk relative h-full w-[5px] shrink-0 cursor-help overflow-hidden rounded-xs bg-surface-hover ring-1 ring-transparent transition-[transform,box-shadow,ring-color] duration-100 hover:scale-y-[1.08] hover:ring-base-content/30"
|
||||
title={bar.title}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -131,7 +131,7 @@ export const COVERAGE_FILTERS: FilterOption<CoverageFilterValue>[] = [
|
||||
{
|
||||
value: 'unknown',
|
||||
label: 'Unknown',
|
||||
leading: filterChipStatusDot(''),
|
||||
leading: filterChipStatusDot('bg-base-content/40'),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -294,7 +294,7 @@ export function Actions() {
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Review ${title()} on ${action.request.resourceId}, ${state().label}`}
|
||||
class={`group w-full border-l-2 px-3 py-3 text-left transition-colors focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-blue-500 sm:px-4 ${state().accentClass}`}
|
||||
class={`group w-full border-l-2 px-3 py-3 text-left transition-colors hover:bg-surface-hover focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-blue-500 sm:px-4 ${state().accentClass}`}
|
||||
onClick={() => openAction(action)}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
@@ -131,6 +131,35 @@ describe('docsLinks', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps reverse-proxy help explicit about forwarded HTTPS and peer trust', () => {
|
||||
const proxy = readFileSync(path.join(repoRoot, 'docs', 'REVERSE_PROXY.md'), 'utf8');
|
||||
const configuration = readFileSync(path.join(repoRoot, 'docs', 'CONFIGURATION.md'), 'utf8');
|
||||
const setup = proxy
|
||||
.split('## Before configuring the proxy')[1]
|
||||
?.split('## ⚡ Quick Configs')[0];
|
||||
expect(setup).toBeDefined();
|
||||
expect(setup).toContain('configured immediate peer');
|
||||
expect(setup).toContain('PULSE_TRUSTED_PROXY_CIDRS=127.0.0.1/32');
|
||||
expect(setup).toContain('::1/128');
|
||||
expect(setup).toContain('Wildcard ranges `0.0.0.0/0` and `::/0` are rejected');
|
||||
expect(setup).toContain('not an authentication bypass');
|
||||
expect(setup).toContain('[proxy authentication guide](PROXY_AUTH.md)');
|
||||
expect(setup).toContain('including its Header Trust Boundary');
|
||||
const nginx = proxy.split('### Nginx')[1].split('### Caddy')[0];
|
||||
expect(nginx).toContain('HTTPS `server` block');
|
||||
expect(nginx).toContain('proxy_set_header X-Forwarded-Proto $scheme;');
|
||||
expect(nginx).toContain('proxy_set_header X-Forwarded-For $remote_addr;');
|
||||
expect(nginx).toContain('proxy_set_header X-Forwarded-Host $host;');
|
||||
expect(nginx).toContain('proxy_set_header Forwarded "";');
|
||||
expect(proxy).not.toContain('$proxy_add_x_forwarded_for');
|
||||
const troubleshooting = proxy.split('### "HTTPS: HTTP only"')[1].split('### Other Issues')[0];
|
||||
expect(troubleshooting).toContain('Adding the header alone is not sufficient');
|
||||
expect(troubleshooting).toContain('inside** its\n`reverse_proxy` block');
|
||||
expect(troubleshooting).toContain('does not verify authentication, WebSockets or proxy');
|
||||
expect(configuration).toContain('REVERSE_PROXY.md#before-configuring-the-proxy');
|
||||
expect(configuration).toContain('forwarded client IP, scheme, host and port');
|
||||
});
|
||||
|
||||
it('keeps configuration transfer separate from full-state recovery', () => {
|
||||
const migration = readFileSync(path.join(repoRoot, 'docs', 'MIGRATION.md'), 'utf8');
|
||||
const agent = readFileSync(path.join(repoRoot, 'docs', 'UNIFIED_AGENT.md'), 'utf8');
|
||||
|
||||
Reference in New Issue
Block a user