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:
pulse-triage[bot]
2026-10-03 20:49:37 +01:00
17 changed files with 462 additions and 79 deletions
@@ -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,
+1 -1
View File
@@ -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
View File
@@ -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"
]
}
+1 -1
View File
@@ -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
View File
@@ -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'),
},
];
+1 -1
View File
@@ -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');