From 42df128f62aeeacdc3ae2313da17114ced392264 Mon Sep 17 00:00:00 2001 From: yuanyuanxiang <962914132@qq.com> Date: Fri, 11 Sep 2026 12:48:21 +0200 Subject: [PATCH] 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 --- server/2015Remote/WebService.cpp | 14 +++++ server/go/cmd/main.go | 18 +++++++ server/go/deploy/nginx-yama.conf.example | 68 ++++++++++++++++++++++++ server/go/deploy/yama-server.env.example | 64 ++++++++++++++++++++++ server/go/deploy/yama.service | 35 ++++++++++++ server/go/web/server.go | 57 +++++++++++++++++--- server/web/index.html | 10 ++-- 7 files changed, 254 insertions(+), 12 deletions(-) create mode 100644 server/go/deploy/nginx-yama.conf.example create mode 100644 server/go/deploy/yama-server.env.example create mode 100644 server/go/deploy/yama.service diff --git a/server/2015Remote/WebService.cpp b/server/2015Remote/WebService.cpp index bf2777c..b8f3599 100644 --- a/server/2015Remote/WebService.cpp +++ b/server/2015Remote/WebService.cpp @@ -35,6 +35,16 @@ static std::string LoadBinaryResourceAsString(int resourceId) { return std::string(static_cast(p), size); } +// Replace all occurrences of `from` with `to` in-place. +static void ReplaceAll(std::string& s, const std::string& from, const std::string& to) { + if (from.empty()) return; + size_t pos = 0; + while ((pos = s.find(from, pos)) != std::string::npos) { + s.replace(pos, from.length(), to); + pos += to.length(); + } +} + // Challenge-response nonce storage (prevents replay attacks) static std::map s_ClientNonces; static std::mutex s_NonceMutex; @@ -231,6 +241,10 @@ void CWebService::ServerThread(int port) { // Serve static HTML page and file downloads static std::string cachedHtml = GetWebPageHTML(); + // server/web/index.html carries a __BASE_PATH__ placeholder for the Go + // server's subpath deployment. The MFC server serves from the domain root, + // so strip it (→ "") — otherwise the WebSocket/CSS/JS URLs break. + ReplaceAll(cachedHtml, "__BASE_PATH__", ""); // Log loaded size; zero bytes means the RC BINARY resource is missing or // server/web/index.html was not embedded — check 2015Remote.rc and rebuild. Mprintf("[WebService] index.html loaded: %zu bytes\n", cachedHtml.size()); diff --git a/server/go/cmd/main.go b/server/go/cmd/main.go index 85ef26a..4f31d8f 100644 --- a/server/go/cmd/main.go +++ b/server/go/cmd/main.go @@ -661,6 +661,19 @@ func splitCSV(s string) []string { return out } +// normalizeBasePath turns a YAMA_WEB_BASE_PATH value into a leading-slash, +// no-trailing-slash prefix ("" for root). "" (or "/") means the web UI is +// served at the domain root (方案A); "/remoter" means it is served under that +// prefix (方案B), with a reverse proxy routing the subpath to this process. +func normalizeBasePath(s string) string { + s = strings.TrimSpace(s) + s = strings.Trim(s, "/") + if s == "" { + return "" + } + return "/" + s +} + // runIssueToken handles the "issue-token" subcommand. It mints a customer JWT // signed with the operator's RSA private key and prints it to stdout. // @@ -918,11 +931,16 @@ func main() { if trustProxy { log.Info("Trusting X-Forwarded-For for client IP — make sure a reverse proxy is in front") } + basePath := normalizeBasePath(os.Getenv("YAMA_WEB_BASE_PATH")) + if basePath != "" { + log.Info("Serving web UI under base path %q (subpath deployment)", basePath) + } webCfg := web.Config{ AllowedOrigins: allowedOrigins, LoginIPLimit: wsauth.NewRateLimiter(10, time.Minute), LoginUserLimit: wsauth.NewRateLimiter(5, 15*time.Minute), TrustForwardedFor: trustProxy, + BasePath: basePath, } // Start HTTP server for web UI. Hub gives it read-only access to the diff --git a/server/go/deploy/nginx-yama.conf.example b/server/go/deploy/nginx-yama.conf.example new file mode 100644 index 0000000..8e5795d --- /dev/null +++ b/server/go/deploy/nginx-yama.conf.example @@ -0,0 +1,68 @@ +# ============================================================ +# YAMA 网页端 nginx 反代示例(方案B:子路径部署) +# 作为站点配置放入 /etc/nginx/sites-available/yama 后软链到 sites-enabled +# 核心原则:nginx 只做 TLS 终止 + 完整路径转发,由 Go 端 +# YAMA_WEB_BASE_PATH=/remoter 负责 strip +# ============================================================ + +server { + listen 443 ssl http2; + server_name 你的域名.com; + + # TLS 证书(路径换成你自己的,如 certbot/letsencrypt 生成的位置) + ssl_certificate /etc/letsencrypt/live/你的域名.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/你的域名.com/privkey.pem; + + # 裸 /remoter(没写结尾斜杠)→ 301 到 /remoter/ + location = /remoter { + return 301 /remoter/; + } + + # 子路径:完整路径原样转发,Go 端按 YAMA_WEB_BASE_PATH 再 strip + location /remoter/ { + # 结尾没有 "/" → /remoter/... 原样转发(不要写成 http://127.0.0.1:8080/) + # Go 服务端若在另一台机器,换成 http://192.168.1.50:8080; + proxy_pass http://127.0.0.1:8080; + + proxy_http_version 1.1; + + # WebSocket 升级(远程桌面走 /remoter/ws,不配这两行连不上) + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 远程桌面连接长时间空闲,避免默认 60s 被 nginx 掐断 + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + + # 大文件/流式传输时避免缓冲占内存(可选) + # proxy_buffering off; + } +} + +# ============================================================ +# 方案A(根路径部署):不设 YAMA_WEB_BASE_PATH,用下面这段替代上面 +# ============================================================ +# server { +# listen 443 ssl http2; +# server_name 你的域名.com; +# ssl_certificate /etc/letsencrypt/live/你的域名.com/fullchain.pem; +# ssl_certificate_key /etc/letsencrypt/live/你的域名.com/privkey.pem; +# +# location / { +# proxy_pass http://127.0.0.1:8080; +# 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-Real-IP $remote_addr; +# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +# proxy_set_header X-Forwarded-Proto $scheme; +# proxy_read_timeout 3600s; +# proxy_send_timeout 3600s; +# } +# } diff --git a/server/go/deploy/yama-server.env.example b/server/go/deploy/yama-server.env.example new file mode 100644 index 0000000..3d77cd9 --- /dev/null +++ b/server/go/deploy/yama-server.env.example @@ -0,0 +1,64 @@ +# ============================================================ +# YAMA 服务端环境变量(完整版) +# systemd 通过 EnvironmentFile=-/etc/yama/server.env 读取 +# 行首 "#" = 未启用(保持默认);去掉 "#" 即生效 +# ============================================================ + +# ---------- 账号 / 密码 ---------- + +# Web 管理员账号 "admin" 的初始密码。回退链: +# YAMA_WEB_ADMIN_PASS → YAMA_PWD → 硬编码 "admin" +# 生产环境务必设置,否则会以 admin/admin 裸奔(启动日志会红色告警) +YAMA_WEB_ADMIN_PASS=ChangeMe_Strong + +# 超级密码(明文)。TCP 授权用,也是上面 Web 管理员密码的回退来源 +#YAMA_PWD= + +# TCP 授权的密码哈希(SHA-256 十六进制)。与 YAMA_PWD 二选一; +# 未设置时用内置占位默认值(生产应覆盖) +#YAMA_PWDHASH= + +# 持久化 Web 用户文件路径(默认相对工作目录的 users.json) +#YAMA_USERS_FILE=/opt/yama/users.json + +# ---------- 网页端 / 反向代理 ---------- + +# 子路径部署前缀(方案B)。空 = 根路径(方案A);有值须与 nginx location 一致 +YAMA_WEB_BASE_PATH=/remoter + +# "1" = 信任反向代理的 X-Forwarded-For(nginx 在 Go 前面时务必开启, +# 否则登录限流会把所有客户端当成 nginx 同一个 IP) +YAMA_WEB_TRUST_PROXY=1 + +# WebSocket Origin 白名单(逗号分隔)。空 = 仅允许同源 +#YAMA_WEB_ALLOWED_ORIGINS=https://你的域名 + +# ---------- 授权 / 许可(signer)---------- +# 优先级:YAMA_SIGN_PASSWORD > YAMA_LICENSE_DISABLED=1 > YAMA_LICENSE_TOKEN > 匿名试用 + +# 本地签名主密钥(HMAC)。设置即进入 LocalSigner 模式(运营方自部署) +#YAMA_SIGN_PASSWORD= + +# "1" = 关闭授权校验(NoOpSigner,离线/开发用;客户端将拒绝屏幕/文件功能) +#YAMA_LICENSE_DISABLED=1 + +# 许可服务器 URL(RemoteSigner 付费客户 / 试用模式的默认地址) +#YAMA_LICENSE_SERVER= + +# 客户 JWT(付费客户解锁设备配额) +#YAMA_LICENSE_TOKEN= + +# 缓存 TTL(小时,RemoteSigner 离线缓存,默认 24) +#YAMA_LICENSE_OFFLINE_HRS=24 + +# ---------- 兼做许可服务器(可选,LocalSigner-as-LS)---------- + +# issue-token 子命令用:RSA 私钥 PEM 路径(与公钥配对) +#YAMA_LICENSE_PRIVATE_KEY= + +# 对外签发许可用:RSA 公钥 PEM 路径 + 监听地址(如 ":8443") +#YAMA_LICENSE_PUBLIC_KEY= +#YAMA_LICENSE_HTTP_ADDR=:8443 + +# 配额状态持久化文件路径 +#YAMA_LICENSE_STATE_PATH=/opt/yama/license_state.json diff --git a/server/go/deploy/yama.service b/server/go/deploy/yama.service new file mode 100644 index 0000000..aaed607 --- /dev/null +++ b/server/go/deploy/yama.service @@ -0,0 +1,35 @@ +[Unit] +Description=YAMA Remote Administration Server +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=yama +Group=yama +WorkingDirectory=/opt/yama + +# 密钥 / 部署参数集中放这里(权限 600)。前面的 "-" 表示文件不存在也不报错。 +EnvironmentFile=-/etc/yama/server.env + +# 6543 = 客户端(agent)回连端口;8080 = 网页端 HTTP(由 nginx 反向代理) +# arm64 机器请把二进制名换成 server_linux_arm64 +ExecStart=/opt/yama/server_linux_amd64 -port 6543 -http-port 8080 -no-console + +Restart=on-failure +RestartSec=5 + +# --- 安全加固(可选,不需要可删掉整段) --- +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +# 若服务需要写 /opt/yama 之外的目录,再放开这一行: +# ReadWritePaths=/opt/yama/data + +# 日志交给 journald +StandardOutput=journal +StandardError=journal +SyslogIdentifier=yama + +[Install] +WantedBy=multi-user.target diff --git a/server/go/web/server.go b/server/go/web/server.go index 62d9972..8b695a9 100644 --- a/server/go/web/server.go +++ b/server/go/web/server.go @@ -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 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 diff --git a/server/web/index.html b/server/web/index.html index a121bf5..47750d2 100644 --- a/server/web/index.html +++ b/server/web/index.html @@ -10,9 +10,9 @@ - + - +