Feature: Support subpath deployment for Go web UI

Add optional YAMA_WEB_BASE_PATH to mount the whole web UI (index, manifest,
static assets, /health, /api/devices, /ws) under a URL prefix such as
/remoter behind a reverse proxy. index.html now uses a __BASE_PATH__
placeholder substituted at serve time, the PWA manifest start_url is rooted
at the base path, and a mount wrapper redirects the bare prefix to prefix/
and strips it before routing. Include nginx, systemd, and env-file deployment
examples for the subpath setup.

The shared placeholder is also handled by the MFC server, which serves from
the domain root and strips __BASE_PATH__ (to "") so its web remote desktop
URLs stay rooted.

Co-Authored-By: deepseek-v4-pro
This commit is contained in:
yuanyuanxianganddeepseek-v4-pro committed 2026-09-11 12:48:21 +02:00
1 parent 32cc7af371
commit 42df128f62
7 files changed
+254 -12

No files matched your search

+50 -7
View File
@@ -1,6 +1,7 @@
package web
import (
"bytes"
"context"
"encoding/json"
"errors"
@@ -30,6 +31,7 @@ type Server struct {
loginIPLimit *wsauth.RateLimiter
loginUserLimit *wsauth.RateLimiter
trustForwardedFor bool // honor X-Forwarded-For (behind trusted proxy only)
basePath string // web UI mount prefix (e.g. "/remoter"); "" = domain root
}
// Config tunes the server's exposed-on-public-HTTPS hardening knobs.
@@ -52,6 +54,12 @@ type Config struct {
// exposure the header is client-controlled and would let attackers
// evade per-IP rate limits.
TrustForwardedFor bool
// BasePath mounts the whole web UI (index, manifest, static assets,
// /health, /api/devices, /ws) under a URL prefix, so a reverse proxy
// can serve it from a subpath of an existing domain (e.g.
// https://example.com/remoter). Must be empty or a leading-slash,
// no-trailing-slash path; "" (default) serves from the domain root.
BasePath string
}
// New creates an HTTP server bound to the given port. port=0 disables the server.
@@ -68,6 +76,7 @@ func (s *Server) WithConfig(cfg Config) *Server {
s.loginIPLimit = cfg.LoginIPLimit
s.loginUserLimit = cfg.LoginUserLimit
s.trustForwardedFor = cfg.TrustForwardedFor
s.basePath = cfg.BasePath
return s
}
@@ -96,7 +105,7 @@ func (s *Server) Start() error {
s.srv = &http.Server{
Addr: ":" + strconv.Itoa(s.port),
Handler: mux,
Handler: s.mount(mux),
ReadHeaderTimeout: 10 * time.Second,
}
@@ -129,14 +138,41 @@ func (s *Server) Stop() {
_ = s.srv.Shutdown(ctx)
}
// basePathPlaceholder is the token in index.html substituted with the
// configured base path at serve time. Empty base path → "" → root URLs.
const basePathPlaceholder = "__BASE_PATH__"
// mount wraps the root-path mux with the configured base path. When basePath
// is empty the mux is returned unchanged (domain-root serving, 方案A). Otherwise
// the bare prefix redirects to prefix+"/", paths outside the prefix 404, and
// matching paths are stripped before reaching the mux (方案B, subpath serving).
func (s *Server) mount(h http.Handler) http.Handler {
if s.basePath == "" {
return h
}
base := s.basePath // normalized by cmd: leading "/", no trailing "/"
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == base {
http.Redirect(w, r, base+"/", http.StatusMovedPermanently)
return
}
if !strings.HasPrefix(r.URL.Path, base+"/") {
http.NotFound(w, r)
return
}
http.StripPrefix(base, h).ServeHTTP(w, r)
})
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" && r.URL.Path != "/index.html" {
http.NotFound(w, r)
return
}
body := bytes.ReplaceAll(IndexHTML, []byte(basePathPlaceholder), []byte(s.basePath))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(IndexHTML)
_, _ = w.Write(body)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
@@ -190,21 +226,28 @@ func (s *Server) unauthorized(w http.ResponseWriter) {
}
// PWA manifest. Referenced by <link rel="manifest"> in index.html.
// Static JSON, no template needed.
const manifestJSON = `{
// start_url is rooted at the configured base path so an installed PWA
// launches into the right location; "/" when served from the domain root.
func (s *Server) manifestJSON() []byte {
start := "/"
if s.basePath != "" {
start = s.basePath + "/"
}
return []byte(fmt.Sprintf(`{
"name": "SimpleRemoter",
"short_name": "Remoter",
"start_url": "/",
"start_url": "%s",
"display": "standalone",
"orientation": "any",
"background_color": "#1a1a2e",
"theme_color": "#1a1a2e"
}`
}`, start))
}
func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/manifest+json")
w.Header().Set("Cache-Control", "public, max-age=86400")
_, _ = w.Write([]byte(manifestJSON))
_, _ = w.Write(s.manifestJSON())
}
// staticHandler returns an http.HandlerFunc that serves a fixed byte slice