Skip to content

Commit e97ee96

Browse files
committed
Add web (Chromium) session support
Introduce a new 'web' session type that runs Chromium in an isolated Xvfb display and exposes it via a loopback x11vnc bridge. Adds internal/web package implementing process management, framebuffer/input bridging, and session lifecycle. Extend config to include URL and browser_no_sandbox, add web resolution modes and validation (only http/https, manual/current/terminal rules). Integrate server handling and geometry selection for web sessions. Update Dockerfile, README, and config template to document and include Chromium and fonts. Add unit tests for config, server geometry, and web helper argument generation.
1 parent 407df72 commit e97ee96

9 files changed

Lines changed: 578 additions & 11 deletions

File tree

Dockerfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/sunrayd ./cmd/sunr
1212
FROM debian:bookworm-slim
1313
RUN apt-get update && apt-get install -y --no-install-recommends \
1414
ca-certificates \
15+
chromium \
16+
chromium-sandbox \
17+
fonts-noto-cjk \
18+
fonts-noto-color-emoji \
1519
freerdp2-x11 \
1620
x11vnc \
1721
xauth \

README.md

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
SunRay2Server is an experimental Go server for Sun Ray 2 terminals. It accepts
44
the Sun Ray authentication/display protocol and routes each terminal to a
5-
local test page or a remote SSH, VNC, or RDP session.
5+
local test page, a full-screen web dashboard, or a remote SSH, VNC, or RDP
6+
session.
67

78
The current card reader integration reliably distinguishes `no-card` and
89
`card-present`. Some firmware reports `card_id=0`, so exact card routing is
@@ -12,13 +13,14 @@ available only when the terminal provides a real ID.
1213

1314
- Sun Ray authentication, display, keyboard, pointer, and wheel input.
1415
- `card-test` and interactive `geometry-test` sessions.
16+
- Full-screen Chromium web sessions with live page updates and input.
1517
- SSH with a PTY and framebuffer terminal.
1618
- VNC with per-session credentials, scaling, and CopyRect updates.
1719
- RDP through FreeRDP + Xvfb + x11vnc.
1820
- Optional passive smart-card/ATR observation on TCP 4120.
1921
- No audio forwarding yet. Sun Ray and VNC transports are not encrypted;
2022
use them only on a trusted network or through a secure tunnel.
21-
- A physical monitor is not required. RDP uses an in-container Xvfb display.
23+
- A physical monitor is not required. Web and RDP sessions use Xvfb.
2224

2325
## Build and test
2426

@@ -57,7 +59,7 @@ sections:
5759

5860
- `server`: TCP listener, fallback/canvas resolution, packet pacing, logging,
5961
and optional smart-card probing.
60-
- `sessions`: named `card-test`, `geometry-test`, `vnc`, `ssh`, or `rdp`
62+
- `sessions`: named `card-test`, `geometry-test`, `web`, `vnc`, `ssh`, or `rdp`
6163
sessions.
6264
- `routing`: maps `no_card`, `card_present`, exact card IDs, or terminal serials
6365
to session names. Terminal-specific routes take precedence over defaults.
@@ -77,6 +79,10 @@ server:
7779
sessions:
7880
card-test:
7981
type: card-test
82+
dashboard:
83+
type: web
84+
url: https://example.com/
85+
resolution_mode: current
8086
office-vnc:
8187
type: vnc
8288
address: 192.168.30.10:5900
@@ -124,14 +130,40 @@ all four colored edges fit without panning, then copy the displayed value to
124130
`server.display_width` and `server.display_height`. A tested Sun Ray 2 with a
125131
1920x1080 monitor used `1400x1050`; measure each installation.
126132

127-
VNC and RDP resolution modes:
133+
VNC, RDP, and web resolution modes:
128134

129135
- `current`: the configured/current Sun Ray canvas (default).
130136
- `terminal`: the original `startRes` reported by the terminal.
131137
- `manual`: session-level `display_width` and `display_height`.
132138
- VNC also supports `vnc`, which keeps the remote framebuffer at native size;
133139
a larger desktop may pan on the Sun Ray.
134140

141+
### Web dashboards
142+
143+
A `web` session opens its `url` as a full-screen Chromium application inside a
144+
private Xvfb display. The page remains loaded: JavaScript timers, WebSocket,
145+
SSE, and page-managed updates continue normally, with no periodic reload.
146+
Framebuffer changes are forwarded through the local VNC bridge.
147+
148+
```yaml
149+
sessions:
150+
dashboard:
151+
type: web
152+
url: https://example.com/
153+
resolution_mode: current
154+
browser_no_sandbox: true # commonly required by Docker/LXC
155+
```
156+
157+
Mouse, wheel, and keyboard input are forwarded to Chromium. `Ctrl+R` or `F5`
158+
reloads, `Alt+Left` goes back, and `Alt+Right` goes forward. Web sessions support
159+
the same `current`, `terminal`, and `manual` resolution modes as RDP.
160+
161+
Chromium's sandbox is enabled by default in the application. Docker and nested
162+
LXC environments commonly block the required namespaces, so the supplied YAML
163+
template explicitly sets `browser_no_sandbox: true`. This weakens browser
164+
isolation: use trusted URLs and set it back to `false` when the host supports
165+
Chromium's sandbox.
166+
135167
### SSH
136168

137169
SSH requires exactly one host-key policy: `known_hosts_file`,
@@ -146,8 +178,9 @@ RDP sessions require `hostname`, `username`, and a password/password file. The
146178
LAN test with a self-signed server. Supported policies are `deny`, `ignore`,
147179
`tofu`, `name:<certificate-name>`, and `fingerprint:<hash>`.
148180

149-
The supplied Docker image includes `xfreerdp`, `Xvfb`, and `x11vnc`. A native
150-
installation must provide those programs in `PATH`.
181+
The supplied Docker image includes Chromium, Japanese/emoji fonts, `xfreerdp`,
182+
`Xvfb`, and `x11vnc`. A native installation must provide the relevant programs
183+
in `PATH`.
151184

152185
## Docker Compose
153186

@@ -231,9 +264,9 @@ On compact keyboards, `Pause/Break` may require `Fn`.
231264
- **The display is slow or resends repeat:** keep `log_input_events: false`,
232265
reduce the logical resolution, and try a larger `packet_delay` such as
233266
`300us` or `1ms`.
234-
- **RDP stops before showing a desktop:** verify that `xfreerdp`, `Xvfb`, and
235-
`x11vnc` are present; in a customized container also verify `/tmp/.X11-unix`
236-
permissions.
267+
- **Web or RDP stops before showing a desktop:** verify that Chromium or
268+
`xfreerdp`, plus `Xvfb` and `x11vnc`, are present; in a customized container
269+
also verify `/tmp/.X11-unix` permissions.
237270
- **The interactive Compose menu warns about terminfo:** set
238271
`COMPOSE_MENU=false`; this is a Compose CLI warning, not a Sun Ray error.
239272

config.yaml.template

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# SunRay2Server configuration template.
2-
# Copy this file to config.yaml and adjust the VNC address before deployment.
2+
# Copy this file to config.yaml and adjust the desired session routes.
33
version: 1
44

55
server:
@@ -37,6 +37,22 @@ sessions:
3737
geometry-test:
3838
type: geometry-test
3939

40+
# Full-screen Chromium dashboard. The page stays loaded, so JavaScript,
41+
# timers, SSE and WebSocket updates continue without periodic reloads.
42+
# Keyboard/mouse input is forwarded through the same local VNC bridge used
43+
# by RDP. Ctrl+R/F5 reload; Alt+Left/Alt+Right navigate history.
44+
example-web:
45+
type: web
46+
url: https://example.com/
47+
# current, terminal, or manual.
48+
resolution_mode: current
49+
# display_width: 1400
50+
# display_height: 1050
51+
# Docker and nested LXC commonly block Chromium's sandbox namespaces, so
52+
# the Compose-ready example enables this compatibility mode. Set it to
53+
# false when the host supports Chromium's sandbox, and use trusted URLs.
54+
browser_no_sandbox: true
55+
4056
example-vnc:
4157
type: vnc
4258
address: 192.168.30.10:5900
@@ -104,6 +120,8 @@ routing:
104120
card_present: example-vnc
105121
# For calibration, temporarily use:
106122
# no_card: geometry-test
123+
# To show the web page without a card, use:
124+
# no_card: example-web
107125

108126
# Exact IDs override card_present when the terminal can report a real ID.
109127
# cards:
@@ -115,5 +133,6 @@ routing:
115133
"00144fd19044":
116134
no_card: card-test
117135
card_present: example-vnc
136+
# no_card: example-web
118137
# cards:
119138
# "0x0123456789abcdef": example-vnc

internal/config/config.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"io"
99
"net"
10+
"net/url"
1011
"os"
1112
"path/filepath"
1213
"strings"
@@ -25,6 +26,9 @@ const (
2526
RDPResolutionCurrent = "current"
2627
RDPResolutionTerminal = "terminal"
2728
RDPResolutionManual = "manual"
29+
WebResolutionCurrent = "current"
30+
WebResolutionTerminal = "terminal"
31+
WebResolutionManual = "manual"
2832
)
2933

3034
type Config struct {
@@ -66,6 +70,8 @@ type Session struct {
6670
DisplayWidth int `yaml:"display_width,omitempty"`
6771
DisplayHeight int `yaml:"display_height,omitempty"`
6872
Certificate string `yaml:"certificate,omitempty"`
73+
URL string `yaml:"url,omitempty"`
74+
BrowserNoSandbox bool `yaml:"browser_no_sandbox,omitempty"`
6975
}
7076

7177
type Routing struct {
@@ -155,6 +161,9 @@ func (c *Config) applyDefaults() {
155161
if session.Type == "rdp" && strings.TrimSpace(session.Certificate) == "" {
156162
session.Certificate = "deny"
157163
}
164+
if session.Type == "web" && session.ResolutionMode == "" {
165+
session.ResolutionMode = WebResolutionCurrent
166+
}
158167
c.Sessions[name] = session
159168
}
160169
}
@@ -234,6 +243,23 @@ func (c *Config) Validate() error {
234243
default:
235244
return fmt.Errorf("session %q has invalid VNC resolution_mode %q", name, resolutionMode)
236245
}
246+
case "web":
247+
pageURL, err := url.ParseRequestURI(strings.TrimSpace(session.URL))
248+
if err != nil || pageURL.Host == "" || (pageURL.Scheme != "http" && pageURL.Scheme != "https") {
249+
return fmt.Errorf("session %q has invalid web URL %q (only http and https are supported)", name, session.URL)
250+
}
251+
switch session.ResolutionMode {
252+
case WebResolutionCurrent, WebResolutionTerminal:
253+
if session.DisplayWidth != 0 || session.DisplayHeight != 0 {
254+
return fmt.Errorf("session %q may set display_width and display_height only with resolution_mode manual", name)
255+
}
256+
case WebResolutionManual:
257+
if session.DisplayWidth < 1 || session.DisplayWidth > 8192 || session.DisplayHeight < 1 || session.DisplayHeight > 8192 {
258+
return fmt.Errorf("session %q has invalid manual display resolution %dx%d", name, session.DisplayWidth, session.DisplayHeight)
259+
}
260+
default:
261+
return fmt.Errorf("session %q has invalid web resolution_mode %q", name, session.ResolutionMode)
262+
}
237263
case "ssh", "rdp":
238264
if strings.TrimSpace(session.Hostname) == "" {
239265
return fmt.Errorf("session %q requires hostname", name)

internal/config/config_test.go

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,34 @@ func TestRDPDefaultsAndValidation(t *testing.T) {
153153
}
154154
}
155155

156+
func TestWebDefaultsAndValidation(t *testing.T) {
157+
cfg := Default()
158+
cfg.Sessions["web"] = Session{Type: "web", URL: "https://example.test/dashboard"}
159+
cfg.applyDefaults()
160+
web := cfg.Sessions["web"]
161+
if web.ResolutionMode != WebResolutionCurrent {
162+
t.Fatalf("unexpected web defaults: %#v", web)
163+
}
164+
if err := cfg.Validate(); err != nil {
165+
t.Fatal(err)
166+
}
167+
168+
web.ResolutionMode = WebResolutionManual
169+
web.DisplayWidth, web.DisplayHeight = 1400, 1050
170+
cfg.Sessions["web"] = web
171+
if err := cfg.Validate(); err != nil {
172+
t.Fatal(err)
173+
}
174+
175+
for _, invalidURL := range []string{"", "example.test", "file:///tmp/dashboard.html", "javascript:alert(1)"} {
176+
web.URL = invalidURL
177+
cfg.Sessions["web"] = web
178+
if err := cfg.Validate(); err == nil {
179+
t.Errorf("expected web URL %q to fail", invalidURL)
180+
}
181+
}
182+
}
183+
156184
func TestVNCPasswordSourcesArePerSessionAndMutuallyExclusive(t *testing.T) {
157185
cfg := Default()
158186
cfg.Sessions["vnc-one"] = Session{Type: "vnc", Address: "server-one:5900", Password: "one"}
@@ -260,7 +288,7 @@ func TestProjectTemplateLoads(t *testing.T) {
260288
if err != nil {
261289
t.Fatal(err)
262290
}
263-
if cfg.Sessions["card-test"].Type != "card-test" || cfg.Sessions["geometry-test"].Type != "geometry-test" || cfg.Sessions["example-vnc"].Type != "vnc" {
291+
if cfg.Sessions["card-test"].Type != "card-test" || cfg.Sessions["geometry-test"].Type != "geometry-test" || cfg.Sessions["example-vnc"].Type != "vnc" || cfg.Sessions["example-web"].Type != "web" {
264292
t.Fatalf("unexpected template sessions: %#v", cfg.Sessions)
265293
}
266294
}

internal/server/server.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"sunray2server/internal/rdp"
2626
"sunray2server/internal/sshclient"
2727
"sunray2server/internal/vnc"
28+
"sunray2server/internal/web"
2829
)
2930

3031
type Config struct {
@@ -392,6 +393,14 @@ func (s *Server) runSession(ctx context.Context, key string, active activeDispla
392393
}
393394
}
394395
s.startRDP(ctx, key, active, generation, definition, password, logger)
396+
case "web":
397+
connecting := display.CardStatusImage(s.config.Image, "WEB", definition.URL, "CONNECT")
398+
if s.isCurrentSession(key, active.client, generation) {
399+
if err := active.client.ShowImage(active.width, active.height, connecting); err != nil && !errors.Is(err, net.ErrClosed) {
400+
logger.Warn("web connecting screen failed", "error", err)
401+
}
402+
}
403+
s.startWeb(ctx, key, active, generation, definition, logger)
395404
}
396405
}
397406

@@ -522,6 +531,38 @@ func (s *Server) startRDP(ctx context.Context, key string, active activeDisplay,
522531
}
523532
}
524533

534+
func (s *Server) startWeb(ctx context.Context, key string, active activeDisplay, generation uint64, definition appconfig.Session, logger *slog.Logger) {
535+
mode, screenWidth, screenHeight := webDisplayGeometry(active, definition)
536+
logger.Info("web display geometry selected", "mode", mode, "resolution", resolutionDescription(screenWidth, screenHeight))
537+
firstFrame := true
538+
session := web.NewSession(web.Config{
539+
URL: definition.URL, ScreenWidth: screenWidth, ScreenHeight: screenHeight,
540+
BrowserNoSandbox: definition.BrowserNoSandbox, Logger: logger,
541+
OnFrame: func(frame *image.RGBA, changed []display.RegionUpdate, resized bool) error {
542+
if !s.isCurrentSession(key, active.client, generation) {
543+
return context.Canceled
544+
}
545+
if firstFrame || resized {
546+
firstFrame = false
547+
if err := active.client.ShowImage(screenWidth, screenHeight, frame); err != nil {
548+
return err
549+
}
550+
return active.client.Send(display.LocalCursor())
551+
}
552+
return active.client.ShowImageRegions(screenWidth, screenHeight, frame, changed)
553+
},
554+
})
555+
active.client.SetResyncHandler(session.RequestFullFrame)
556+
active.client.SetInputHandler(session.HandleInput)
557+
if err := session.Run(ctx); err != nil && ctx.Err() == nil {
558+
logger.Warn("web session stopped", "url", definition.URL, "error", err)
559+
if s.isCurrentSession(key, active.client, generation) {
560+
failure := display.CardStatusImage(s.config.Image, "WEB ERROR", definition.URL, "CHECK LOG")
561+
_ = active.client.ShowImage(active.width, active.height, failure)
562+
}
563+
}
564+
}
565+
525566
func vncDisplayGeometry(active activeDisplay, definition appconfig.Session) (string, int, int) {
526567
switch definition.ResolutionMode {
527568
case appconfig.VNCResolutionTerminal:
@@ -546,6 +587,17 @@ func rdpDisplayGeometry(active activeDisplay, definition appconfig.Session) (str
546587
}
547588
}
548589

590+
func webDisplayGeometry(active activeDisplay, definition appconfig.Session) (string, int, int) {
591+
switch definition.ResolutionMode {
592+
case appconfig.WebResolutionTerminal:
593+
return definition.ResolutionMode, active.reportedWidth, active.reportedHeight
594+
case appconfig.WebResolutionManual:
595+
return definition.ResolutionMode, definition.DisplayWidth, definition.DisplayHeight
596+
default:
597+
return appconfig.WebResolutionCurrent, active.width, active.height
598+
}
599+
}
600+
549601
func resolutionDescription(width, height int) string {
550602
if width == 0 && height == 0 {
551603
return "VNC framebuffer"

internal/server/server_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,26 @@ func TestRDPDisplayGeometryModes(t *testing.T) {
8888
}
8989
}
9090

91+
func TestWebDisplayGeometryModes(t *testing.T) {
92+
active := activeDisplay{width: 1400, height: 1050, reportedWidth: 1920, reportedHeight: 1080}
93+
tests := []struct {
94+
definition appconfig.Session
95+
mode string
96+
width int
97+
height int
98+
}{
99+
{appconfig.Session{}, appconfig.WebResolutionCurrent, 1400, 1050},
100+
{appconfig.Session{ResolutionMode: appconfig.WebResolutionTerminal}, appconfig.WebResolutionTerminal, 1920, 1080},
101+
{appconfig.Session{ResolutionMode: appconfig.WebResolutionManual, DisplayWidth: 1280, DisplayHeight: 720}, appconfig.WebResolutionManual, 1280, 720},
102+
}
103+
for _, test := range tests {
104+
mode, width, height := webDisplayGeometry(active, test.definition)
105+
if mode != test.mode || width != test.width || height != test.height {
106+
t.Errorf("mode %q = %q %dx%d, want %q %dx%d", test.definition.ResolutionMode, mode, width, height, test.mode, test.width, test.height)
107+
}
108+
}
109+
}
110+
91111
func TestCardSessionSlotUsesDefinitiveInsertEvents(t *testing.T) {
92112
tests := []struct {
93113
cardType string

0 commit comments

Comments
 (0)