1 Commits

Author SHA1 Message Date
GL.iNet-Yongping.Xie 6c357f2842 feat: add domain-based access restrictions for security
- Add support for restricting access to the platform Web UI by allowed domain
- Validate that the device access domain matches the target device ID
- Redirect requests with mismatched or invalid domains to an invalid access page

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-01-07 23:10:27 -08:00
8 changed files with 340 additions and 67 deletions
+13 -3
View File
@@ -56,11 +56,21 @@ func (srv *RttyServer) ListenAPI() error {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(func(c *gin.Context) {
hi := getHostInfoFromRequest(c.Request)
host := hi.Host
allowedHost := cfg.WebUIHost
// If WebUIHost is configured, enforce host validation
if allowedHost != "" && !isIPHost(host) {
if !domainAllowed(host, allowedHost) {
html := generateErrorHTML("invalid")
c.Data(http.StatusBadRequest, "text/html; charset=utf-8", []byte(html))
c.Abort()
return
}
}
c.Next()
log.Debug().Msgf("%s - \"%s %s %s %d\"", c.ClientIP(),
c.Request.Method, c.Request.URL.Path, c.Request.Proto, c.Writer.Status())
})
if cfg.AllowOrigins {
+21
View File
@@ -91,6 +91,10 @@ type Config struct {
// Host[:port] used to generate device remote access address:
// <deviceId>.<DEVICE_ENDPOINT_HOST>
DeviceEndpointHost string
// Platform access domain restriction.
// When set, only requests with a matching domain are allowed to access the platform.
WebUIHost string
}
// docker mode fixed path for reading certificate
@@ -293,6 +297,23 @@ func parseYamlCfg(cfg *Config, conf string) error {
cfg.DeviceEndpointHost = cleaned
}
if v := strings.TrimSpace(os.Getenv("WEB_UI_HOST")); v != "" {
cleaned := v
// 1. Remove scheme if present (http:// or https://)
if idx := strings.Index(cleaned, "://"); idx != -1 {
cleaned = cleaned[idx+3:]
}
// 2. Remove path/query/fragment if present
if idx := strings.IndexAny(cleaned, "/?#"); idx != -1 {
cleaned = cleaned[:idx]
}
// 3. Final trim
cleaned = strings.TrimSpace(cleaned)
cfg.WebUIHost = cleaned
}
return nil
}
+19
View File
@@ -45,6 +45,25 @@ REVERSE_PROXY_ENABLED=false
# Leave empty to derive the host/port from X-Forwarded-* headers (auto-detect).
DEVICE_ENDPOINT_HOST=
# =====================================================
# Platform Access Domain Restriction
# =====================================================
# Restrict the domain used to access the GLKVM Cloud platform.
#
# When set, only requests with a matching domain are allowed to access
# the Web UI and API. Requests using other domains will be rejected
# as invalid access.
#
# Examples:
# WEB_UI_HOST=www.example.com
#
# Notes:
# - Do NOT include scheme (http:// or https://)
# - Do NOT include path (/xxx)
# - Leave empty to disable domain restriction (allow access via any domain)
WEB_UI_HOST=
# GLKVM access IP seen by devices/users.
# Leave empty to auto-detect at container start.
GLKVM_ACCESS_IP=
+18
View File
@@ -45,6 +45,24 @@ REVERSE_PROXY_ENABLED=false
# Leave empty to derive the host/port from X-Forwarded-* headers (auto-detect).
DEVICE_ENDPOINT_HOST=
# =====================================================
# Platform Access Domain Restriction
# =====================================================
# Restrict the domain used to access the GLKVM Cloud platform.
#
# When set, only requests with a matching domain are allowed to access
# the Web UI and API. Requests using other domains will be rejected
# as invalid access.
#
# Examples:
# WEB_UI_HOST=www.example.com
#
# Notes:
# - Do NOT include scheme (http:// or https://)
# - Do NOT include path (/xxx)
# - Leave empty to disable domain restriction (allow access via any domain)
WEB_UI_HOST=
GLKVM access IP seen by devices/users.
# Leave empty to auto-detect at container start.
GLKVM_ACCESS_IP=
+2
View File
@@ -56,6 +56,8 @@ services:
# ---- Device Endpoint Host ----
DEVICE_ENDPOINT_HOST: ${DEVICE_ENDPOINT_HOST:-}
# ---- Web UI Host ----
WEB_UI_HOST: ${WEB_UI_HOST:-}
volumes:
- ./templates/rttys.conf.template:/tpl/rttys.conf.tmpl:ro
- ./scripts/docker-entrypoint.sh:/docker-entrypoint.sh:ro
+34 -63
View File
@@ -144,6 +144,17 @@ func doHttpProxy(srv *RttyServer, c net.Conn) {
if err != nil {
return
}
domain, port, proto := getRequestHostInfo(req)
log.Debug().Msgf("http proxy incoming host=%s port=%s proto=%s uri=%s",
domain, port, proto, req.URL.String())
devID, ok := extractDeviceIDFromHost(domain)
if ok {
log.Debug().Msgf("parsed deviceId from host: %s", devID)
} else {
log.Debug().Msgf("host is IP or invalid, skip deviceId parsing")
}
// 获取 URL 查询参数
queryParams := req.URL.Query()
name := queryParams.Get("sid")
@@ -179,6 +190,29 @@ func doHttpProxy(srv *RttyServer, c net.Conn) {
return
}
// 3) match hostDevID vs session devid, and optionally lookup by hostDevID
if devID != "" {
match := devID == ses.devid
log.Debug().Msgf(
"http proxy devid check: hostDevID=%s sessionDevid=%s match=%v hostDevFound=%v sid=%s group=%s",
devID, ses.devid, match, domain, sid, ses.group,
)
// If you want, you can also log when mismatch happens
if !match {
log.Info().Msgf(
"http proxy devid mismatch: hostDevID=%s sessionDevid=%s sid=%s group=%s host=%s uri=%s",
devID, ses.devid, sid, ses.group, domain, req.URL.String(),
)
sendHTTPErrorResponse(c, "invalid")
}
} else {
log.Debug().Msgf(
"http proxy devid check skipped: no hostDevID (host=%s) sid=%s group=%s sessionDevid=%s",
domain, sid, ses.group, ses.devid,
)
}
hostHeaderRewrite := ses.destaddr
destAddr := genDestAddr(hostHeaderRewrite)
@@ -703,66 +737,3 @@ func Write302WithCookie(conn net.Conn, location, cookieName, cookieValue string)
)
_, _ = conn.Write([]byte(response))
}
// buildRedirectHost removes the first label of the hostname and prepends devid.
// Rules:
// - "www.example.com" -> "devid.example.com"
// - "www.l1.example.com" -> "devid.l1.example.com"
// - "www.l1.l2.example.com" -> "devid.l1.l2.example.com"
// - Two-level domain "example.com" -> "devid.example.com"
// - Single label / abnormal cases -> "devid." + hostname (fallback)
//
// The input hostname must be a pure hostname without port.
func buildRedirectHost(hostname, devid string) string {
// Allow FQDN with trailing dot like "example.com."
hostname = strings.TrimSuffix(hostname, ".")
// Split into labels
labels := strings.Split(hostname, ".")
// Remove empty labels (in case of consecutive dots)
compact := make([]string, 0, len(labels))
for _, l := range labels {
if l != "" {
compact = append(compact, l)
}
}
labels = compact
switch len(labels) {
case 0:
return devid // extreme case: just return devid
case 1:
// Single label (e.g., "localhost") — keep original as suffix
return devid + "." + labels[0]
default:
// >=2: drop the leftmost label
suffix := strings.Join(labels[1:], ".")
return devid + "." + suffix
}
}
func joinHostPortIfNeeded(host, scheme, port string) string {
if port == "" {
return host
}
// avoid adding default ports
if (scheme == "https" && port == "443") || (scheme == "http" && port == "80") {
return host
}
return net.JoinHostPort(host, port)
}
func buildRedirectLocation(scheme, hostPort, path, sid string) string {
if path == "" {
path = "/"
}
u := &url.URL{
Scheme: scheme,
Host: hostPort,
Path: path,
}
q := u.Query()
q.Set("sid", sid)
u.RawQuery = q.Encode()
return u.String()
}
+1 -1
View File
@@ -41,7 +41,7 @@ import (
)
const RttysVersion = "5.2.0"
const KVMCloudVersion = "v1.7.0"
const KVMCloudVersion = "v1.8.0"
var (
GitCommit = ""
Executable
+232
View File
@@ -0,0 +1,232 @@
package main
import (
"net"
"net/http"
"net/url"
"strings"
)
type HostInfo struct {
Host string // pure host without port
Port string // external port if known
Scheme string // http/https
RawHost string // req.Host (may include port)
XFHost string // X-Forwarded-Host (raw)
XFProto string // X-Forwarded-Proto (raw)
XFPort string // X-Forwarded-Port (raw)
}
func getHostInfoFromRequest(req *http.Request) HostInfo {
hi := HostInfo{
RawHost: req.Host,
XFHost: req.Header.Get("X-Forwarded-Host"),
XFProto: req.Header.Get("X-Forwarded-Proto"),
XFPort: req.Header.Get("X-Forwarded-Port"),
}
// host: prefer X-Forwarded-Host
host := strings.TrimSpace(hi.XFHost)
if host != "" {
host = strings.TrimSpace(strings.Split(host, ",")[0])
} else {
host = strings.TrimSpace(req.Host)
}
// split port if host contains it
if h, p, err := net.SplitHostPort(host); err == nil {
hi.Host = h
hi.Port = p
} else {
hi.Host = strings.TrimSuffix(host, ".")
}
// scheme
proto := strings.TrimSpace(hi.XFProto)
if proto != "" {
proto = strings.ToLower(strings.TrimSpace(strings.Split(proto, ",")[0]))
hi.Scheme = proto
} else if req.TLS != nil {
hi.Scheme = "https"
} else {
hi.Scheme = "http"
}
// forwarded port overrides
fp := strings.TrimSpace(hi.XFPort)
if fp != "" {
hi.Port = strings.TrimSpace(strings.Split(fp, ",")[0])
}
return hi
}
// isIPHost checks whether host is an IP address.
func isIPHost(host string) bool {
ip := net.ParseIP(strings.TrimSpace(host))
return ip != nil
}
// domainAllowed checks whether host is allowed.
// Allow:
// - exact match: base
// - subdomain: *.base
func domainAllowed(host, base string) bool {
host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
base = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(base), "."))
if host == "" || base == "" {
return false
}
if host == base {
return true
}
return strings.HasSuffix(host, "."+base)
}
// buildRedirectHost removes the first label of the hostname and prepends devid.
// Rules:
// - "www.example.com" -> "devid.example.com"
// - "www.l1.example.com" -> "devid.l1.example.com"
// - "www.l1.l2.example.com" -> "devid.l1.l2.example.com"
// - Two-level domain "example.com" -> "devid.example.com"
// - Single label / abnormal cases -> "devid." + hostname (fallback)
//
// The input hostname must be a pure hostname without port.
func buildRedirectHost(hostname, devid string) string {
// Allow FQDN with trailing dot like "example.com."
hostname = strings.TrimSuffix(hostname, ".")
// Split into labels
labels := strings.Split(hostname, ".")
// Remove empty labels (in case of consecutive dots)
compact := make([]string, 0, len(labels))
for _, l := range labels {
if l != "" {
compact = append(compact, l)
}
}
labels = compact
switch len(labels) {
case 0:
return devid // extreme case: just return devid
case 1:
// Single label (e.g., "localhost") — keep original as suffix
return devid + "." + labels[0]
default:
// >=2: drop the leftmost label
suffix := strings.Join(labels[1:], ".")
return devid + "." + suffix
}
}
func joinHostPortIfNeeded(host, scheme, port string) string {
if port == "" {
return host
}
// avoid adding default ports
if (scheme == "https" && port == "443") || (scheme == "http" && port == "80") {
return host
}
return net.JoinHostPort(host, port)
}
func buildRedirectLocation(scheme, hostPort, path, sid string) string {
if path == "" {
path = "/"
}
u := &url.URL{
Scheme: scheme,
Host: hostPort,
Path: path,
}
q := u.Query()
q.Set("sid", sid)
u.RawQuery = q.Encode()
return u.String()
}
// getRequestHostInfo extracts domain(host), port and scheme(proto) from request headers.
// Priority:
// 1) X-Forwarded-Host / X-Forwarded-Proto / X-Forwarded-Port (reverse proxy)
// 2) Host header / TLS info
func getRequestHostInfo(req *http.Request) (host string, port string, proto string) {
// 1) Reverse-proxy headers
xfh := strings.TrimSpace(req.Header.Get("X-Forwarded-Host"))
xfp := strings.TrimSpace(req.Header.Get("X-Forwarded-Proto"))
xfport := strings.TrimSpace(req.Header.Get("X-Forwarded-Port"))
// X-Forwarded-Host may contain a comma-separated list. Take the first one.
if xfh != "" {
if i := strings.IndexByte(xfh, ','); i >= 0 {
xfh = strings.TrimSpace(xfh[:i])
}
host = xfh
}
// 2) Fallback to Host header
if host == "" {
host = strings.TrimSpace(req.Host)
}
// Split host:port if present
if h, p, err := net.SplitHostPort(host); err == nil {
host = h
port = p
} else {
// no explicit port in Host header
port = ""
}
// scheme/proto
if xfp != "" {
if i := strings.IndexByte(xfp, ','); i >= 0 {
xfp = strings.TrimSpace(xfp[:i])
}
proto = xfp
} else if req.TLS != nil {
proto = "https"
} else {
proto = "http"
}
// forwarded port overrides parsed port if present
if xfport != "" {
if i := strings.IndexByte(xfport, ','); i >= 0 {
xfport = strings.TrimSpace(xfport[:i])
}
port = xfport
}
return host, port, proto
}
// extractDeviceIDFromHost extracts deviceId from hostname.
// Rules:
// - IP address -> ("", false)
// - lv99862.example.com -> ("lv99862", true)
// - lv99862.l1.example.com -> ("lv99862", true)
// - localhost / single label -> ("localhost", true)
func extractDeviceIDFromHost(host string) (string, bool) {
host = strings.TrimSpace(host)
if host == "" {
return "", false
}
// remove trailing dot
host = strings.TrimSuffix(host, ".")
// If host is IP, skip
if ip := net.ParseIP(host); ip != nil {
return "", false
}
labels := strings.Split(host, ".")
for _, l := range labels {
if l != "" {
return l, true
}
}
return "", false
}