Skip to content

feat: add external_url config for reverse proxy deployments#1584

Open
ianbmacdonald wants to merge 5 commits intolemonade-sdk:mainfrom
ianbmacdonald:feat/external-url-config
Open

feat: add external_url config for reverse proxy deployments#1584
ianbmacdonald wants to merge 5 commits intolemonade-sdk:mainfrom
ianbmacdonald:feat/external-url-config

Conversation

@ianbmacdonald
Copy link
Copy Markdown
Collaborator

Summary

  • Adds an external_url server config key for specifying the public browser-facing URL when Lemonade runs behind a reverse proxy
  • When set, the web app derives all REST and WebSocket URLs from external_url instead of internal hostnames and ports
  • WebSocket clients route through the proxy origin (e.g. wss://example.com/realtime) rather than connecting directly to :9000
  • Direct-connect and Electron modes are unaffected — isExternalUrl() is scoped to web-app mode only
  • Includes ws:// vs wss:// protocol detection for direct-connect mode
  • Emits a startup warning when external_url is set but websocket_port is still "auto"
  • Documents the reverse proxy deployment contract with example nginx config

Full context in #472 (comment).

Changes

File What
defaults.json Adds external_url: ""
runtime_config.h / .cpp Getter, regex validation (http/https, no query/fragment), runtime-changeable
server.cpp Injects external_url via JSON-serialized <script> tag; proxy misconfiguration warning; side-effect handling
serverConfig.ts Resolution: external_url > window.location.origin > localhost; getWebSocketUrl() helper; isExternalUrl() scoped to web-app mode
websocketClient.ts / logWebSocketClient.ts Use centralized getWebSocketUrl()
configuration.md Documents external_url, reverse proxy section with nginx example
server_spec.md Updates WebSocket connection docs for proxied mode

Deployment

lemonade config set external_url=https://lemonade.example.com websocket_port=9000
location / {
    proxy_pass http://127.0.0.1:13305;
}
location /realtime {
    proxy_pass http://127.0.0.1:9000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}
location /logs/stream {
    proxy_pass http://127.0.0.1:9000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}

Test plan

  • No external_url set — verify local, Electron, and direct-connect modes work exactly as before
  • Set external_url=http://localhost:13305 — web app should use that as base URL, WS connects via origin
  • Set external_url=https://example.com behind HTTPS proxy — REST uses https://, WS uses wss://, no mixed-content errors
  • Set external_url=https://example.com/lemonade with path prefix — verify /lemonade/realtime and /lemonade/logs/stream paths
  • Set external_url with websocket_port=auto — verify startup warning is logged
  • lemonade config set external_url=... at runtime — verify side-effect log message and warning re-check
  • Electron app with --base-url — verify it still uses websocket_port from /health (no regression)

Fixes #472

🤖 Generated with Claude Code

@ianbmacdonald
Copy link
Copy Markdown
Collaborator Author

ianbmacdonald commented Apr 8, 2026

@Geramy here you go. @superm1 it looks like there is the proper guard to prevent regressing Electron users. YMMV; Test plan not executed

@Geramy Geramy self-requested a review April 8, 2026 18:20
@ianbmacdonald
Copy link
Copy Markdown
Collaborator Author

add @sofiageo's comment

Copy link
Copy Markdown
Member

@Geramy Geramy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like it generally. Please see changes.

Comment thread src/app/src/renderer/utils/serverConfig.ts Outdated
Comment thread src/cpp/resources/defaults.json
Comment thread src/cpp/server/server.cpp Outdated
@ianbmacdonald
Copy link
Copy Markdown
Collaborator Author

ianbmacdonald commented Apr 8, 2026

Okay, I was able to verify this works with caddy
image

Then remove settings, and reverse proxy, and default path with the webapp still works
image

@ianbmacdonald
Copy link
Copy Markdown
Collaborator Author

Testing this branch

Prerequisites

  • A reverse proxy with TLS (nginx, Caddy, etc.)
  • Lemonade built from this branch

Quick test with Caddy

1. Configure lemonade:

lemonade config set \
  external_url=https://<YOUR_IP>:8443 \
  host=127.0.0.1 \
  websocket_port=9000
  • external_url — the public HTTPS URL browsers will use
  • host=127.0.0.1 — bind lemonade to loopback so only the proxy serves external traffic
  • websocket_port=9000 — pin the WebSocket port so the proxy target is stable

2. Install and configure Caddy:

sudo apt install caddy

/etc/caddy/Caddyfile:

https://<YOUR_IP>:8443 {
	tls internal

	reverse_proxy /realtime http://127.0.0.1:9000 {
		header_up Connection {http.request.header.Connection}
		header_up Upgrade {http.request.header.Upgrade}
	}

	reverse_proxy /logs/stream http://127.0.0.1:9000 {
		header_up Connection {http.request.header.Connection}
		header_up Upgrade {http.request.header.Upgrade}
	}

	reverse_proxy http://127.0.0.1:13305
}
sudo systemctl restart caddy

3. Verify:

Open https://<YOUR_IP>:8443/ in a browser (accept the self-signed cert warning), then:

  • Open DevTools → Console: should show Using explicit server base URL: https://<YOUR_IP>:8443
  • Open the logs panel — should connect and stream
  • DevTools → Network → filter WS: connection URL should be wss://<YOUR_IP>:8443/logs/stream (routed through the proxy, no separate port 9000 exposed)
  • No mixed-content errors in the console

4. Cleanup (to restore default behavior):

lemonade config set external_url= host=0.0.0.0 websocket_port=auto
sudo systemctl stop caddy

What to look for

Check Expected
REST API calls in Network tab https://<YOUR_IP>:8443/api/v1/...
WebSocket URL wss://<YOUR_IP>:8443/logs/stream
Mixed-content errors None
Log streaming Connected, entries flowing
Without external_url set Behavior identical to current main branch

ianbmacdonald and others added 5 commits April 8, 2026 17:33
Adds an `external_url` server config key that lets users specify the
public-facing URL when Lemonade runs behind a reverse proxy. When set,
the web app and WebSocket clients derive all browser-facing URLs from
it instead of using internal hostnames and ports.

- external_url config key with http/https validation in runtime_config
- Server injects external_url into web app mock API for frontend use
- Frontend prefers external_url > window.location.origin > localhost
- WebSocket clients use base URL origin (no separate port) when
  external_url is set, fixing mixed-content and port exposure issues
- Direct-connect mode gains ws:// vs wss:// protocol detection

Fixes lemonade-sdk#472

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The hasExternalUrl flag was set for any explicit base URL, including
Electron's --base-url. This caused Electron/remote-server setups to
stop using the advertised websocket_port and assume /realtime and
/logs/stream exist on the HTTP origin — a regression for users
without a same-origin WS proxy.

Now hasExternalUrl is only true in web-app mode (server-injected
external_url), where the reverse proxy is expected to forward WS
paths. Electron --base-url continues using the separate websocket_port.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Escape external_url via JSON serialization instead of raw string
  concatenation in the injected <script> tag (server.cpp)
- Validate external_url with a regex rejecting query/fragment (runtime_config.cpp)
- Extract getWebSocketUrl() helper in serverConfig.ts so both WS
  clients share a single URL-construction path that handles path
  prefixes correctly (e.g. https://example.com/lemonade/realtime)
- Add log_external_url_proxy_warning() emitted at startup and on
  config change when external_url is set but websocket_port is auto
- Handle external_url as a runtime-changeable config key in
  apply_config_side_effects
- Document reverse proxy deployment contract in configuration.md
  and update WebSocket connection docs in server_spec.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove standalone getWebSocketProtocol export — the protocol logic
is only used inside getWebSocketUrl, so keep it there.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The serve_web_app_html lambda only needs RuntimeConfig, not the
entire Server object. Capture config_.get() directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@ianbmacdonald ianbmacdonald force-pushed the feat/external-url-config branch from 8a1891f to 0abf920 Compare April 8, 2026 21:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Frontend does not follow url that loaded the page

2 participants