mirror of
https://github.com/gl-inet/glkvm-cloud.git
synced 2026-10-04 04:32:22 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fdba252fa8 | |||
| 9faeac6ff9 | |||
| 6c357f2842 | |||
| 8703f91ebf | |||
| 1c473458cf | |||
| c6c09dbae1 | |||
| 9258aa5f43 | |||
| 8994cccb25 | |||
| 60994b9513 | |||
| c99b96ca01 | |||
| 27792291ed | |||
| 4adb10f577 | |||
| cd231e996b | |||
| 739aa235b3 | |||
| 329468bf61 |
+4
-1
@@ -1,4 +1,7 @@
|
||||
FROM alpine:latest
|
||||
WORKDIR /home
|
||||
COPY ./rttys /usr/bin/rttys
|
||||
|
||||
ARG TARGETARCH
|
||||
COPY ./dist/rttys-linux-${TARGETARCH} /usr/bin/rttys
|
||||
|
||||
ENTRYPOINT ["/usr/bin/rttys"]
|
||||
|
||||
@@ -1,29 +1,54 @@
|
||||
# Makefile
|
||||
|
||||
# Go binary name
|
||||
BINARY_NAME = rttys
|
||||
# ---------------- Project ----------------
|
||||
BINARY_NAME ?= rttys
|
||||
UI_DIR ?= ui
|
||||
CONF_FILE ?= ./rttys.conf
|
||||
|
||||
# Go build flags
|
||||
BUILD_FLAGS := -ldflags "-s -w"
|
||||
BUILD_FLAGS ?= -ldflags "-s -w"
|
||||
|
||||
# Go build command
|
||||
# Output dir for cross builds
|
||||
DIST_DIR ?= dist
|
||||
|
||||
# Image name
|
||||
IMAGE_NAME ?= glkvm-cloud
|
||||
IMAGE_TAG ?= build
|
||||
|
||||
UNAME_S := $(shell uname -s)
|
||||
UNAME_M := $(shell uname -m)
|
||||
|
||||
GOOS ?= $(shell go env GOOS)
|
||||
GOARCH ?= $(shell go env GOARCH)
|
||||
|
||||
# Map uname -m -> goarch
|
||||
ifeq ($(UNAME_M),x86_64)
|
||||
HOST_GOARCH := amd64
|
||||
else ifeq ($(UNAME_M),aarch64)
|
||||
HOST_GOARCH := arm64
|
||||
else ifeq ($(UNAME_M),arm64)
|
||||
HOST_GOARCH := arm64
|
||||
else
|
||||
HOST_GOARCH := $(GOARCH)
|
||||
endif
|
||||
|
||||
# ---------------- Commands ----------------
|
||||
GO_BUILD_CMD = go build $(BUILD_FLAGS) -o $(BINARY_NAME)
|
||||
|
||||
# Paths
|
||||
UI_DIR = ui
|
||||
CONF_FILE = ./rttys.conf
|
||||
.PHONY: all ui build run build-all build-run full-run \
|
||||
build-linux-amd64 build-linux-arm64 build-linux-all \
|
||||
docker-build docker-fullbuild docker-buildx docker-buildx-full
|
||||
|
||||
.PHONY: all ui build run build-run full-run
|
||||
all: build
|
||||
|
||||
# Build frontend files only
|
||||
ui:
|
||||
cd $(UI_DIR) && npm install && npm run build
|
||||
|
||||
# Build Go binary only
|
||||
# Build for current env (native)
|
||||
build:
|
||||
CGO_ENABLED=0 $(GO_BUILD_CMD)
|
||||
CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) $(GO_BUILD_CMD)
|
||||
|
||||
# Run Go program only
|
||||
# Run Go program only (native binary)
|
||||
run:
|
||||
./$(BINARY_NAME) -c $(CONF_FILE)
|
||||
|
||||
@@ -36,10 +61,52 @@ build-run: build run
|
||||
# Build frontend, build Go binary, and run
|
||||
full-run: ui build run
|
||||
|
||||
# Build Docker image without updating ui
|
||||
docker-build: build
|
||||
docker build -t glkvm-cloud:build .
|
||||
# ---------------- Cross compile (Linux) ----------------
|
||||
# Produce: dist/rttys-linux-amd64 , dist/rttys-linux-arm64
|
||||
build-linux-amd64:
|
||||
@mkdir -p $(DIST_DIR)
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||
go build $(BUILD_FLAGS) -o $(DIST_DIR)/$(BINARY_NAME)-linux-amd64
|
||||
|
||||
# Full Build Docker image
|
||||
build-linux-arm64:
|
||||
@mkdir -p $(DIST_DIR)
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \
|
||||
go build $(BUILD_FLAGS) -o $(DIST_DIR)/$(BINARY_NAME)-linux-arm64
|
||||
|
||||
build-linux-all: build-linux-amd64 build-linux-arm64
|
||||
|
||||
# ---------------- Docker (single-arch) ----------------
|
||||
# Build Docker image using current host arch
|
||||
docker-build: build
|
||||
docker build -t $(IMAGE_NAME):$(IMAGE_TAG) .
|
||||
|
||||
# Full build Docker image
|
||||
docker-fullbuild: ui build
|
||||
docker build -t glkvm-cloud:build .
|
||||
docker build -t $(IMAGE_NAME):$(IMAGE_TAG) .
|
||||
|
||||
# ---------------- Docker Buildx ----------------
|
||||
# Multi-arch build
|
||||
# Usage:
|
||||
# make docker-buildx GOARCH=amd64 IMAGE_TAG=build-amd64
|
||||
# make docker-buildx GOARCH=arm64 IMAGE_TAG=build-arm64
|
||||
PLATFORMS ?= linux/amd64,linux/arm64
|
||||
REGISTRY ?=
|
||||
|
||||
# If REGISTRY is set, tag becomes: REGISTRY/IMAGE_NAME:IMAGE_TAG
|
||||
ifdef REGISTRY
|
||||
IMAGE_REF := $(REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG)
|
||||
else
|
||||
IMAGE_REF := $(IMAGE_NAME):$(IMAGE_TAG)
|
||||
endif
|
||||
|
||||
docker-buildx:
|
||||
@docker buildx version >/dev/null 2>&1 || (echo "docker buildx not available" && exit 1)
|
||||
@echo "==> buildx (load local image): $(IMAGE_REF) [linux/$(GOARCH)]"
|
||||
docker buildx build \
|
||||
--platform linux/$(GOARCH) \
|
||||
-t $(IMAGE_REF) \
|
||||
--load .
|
||||
|
||||
|
||||
docker-buildx-full: ui
|
||||
@$(MAKE) docker-buildx
|
||||
|
||||
@@ -17,6 +17,9 @@ Self-Deployed Lightweight Cloud is a lightweight KVM remote cloud platform tailo
|
||||
- **Lightweight Design** - Optimized for small businesses and individual users
|
||||
- **Enterprise Authentication** - Supports both **LDAP** and **OIDC** login methods for enterprise users.
|
||||
|
||||
- **Deployment** - Supports both **internal network** and **public internet** deployments
|
||||
- **Platform Compatibility** - Supports both **x86_64** and **arm64** platforms
|
||||
|
||||
## Self-Hosting Guide
|
||||
|
||||
The following mainstream operating systems have been tested and verified
|
||||
@@ -61,9 +64,11 @@ If your server provider uses a **cloud security group** (e.g., AWS, Aliyun, etc.
|
||||
|
||||
We provide **two** ways to install GLKVM Cloud:
|
||||
|
||||
#### A) One-line installer (recommended)
|
||||
#### A) One-line installer (recommended, x86_64/amd64)
|
||||
|
||||
> **Note:** The one-line installer is **Docker-based**. It automates Docker/Compose setup, pulls images, renders configs from templates, and starts services for you.
|
||||
>
|
||||
> **Platform:** currently supports **x86_64 (amd64)** only.
|
||||
|
||||
Run **as root**:
|
||||
|
||||
@@ -74,6 +79,8 @@ Run **as root**:
|
||||
#### B) Docker manual install
|
||||
|
||||
> Full reference: see [`docker-compose/README.md`](https://github.com/gl-inet/glkvm-cloud/blob/main/docker-compose/README.md)
|
||||
>
|
||||
> **Platform:** supports both **x86_64 (amd64)** and **arm64 (AArch64)**.
|
||||
|
||||
### 🌐 Platform Access
|
||||
|
||||
|
||||
+8
-1
@@ -18,6 +18,9 @@
|
||||
* **轻量设计** - 专为小型企业和个人优化
|
||||
* **企业级认证** - 同时支持 **LDAP** 和 **OIDC** 登录方式,适用于企业用户。
|
||||
|
||||
- **部署方式** - 同时支持 **内网部署** 和 **公网部署**
|
||||
- **平台兼容性** - 同时支持 **x86_64** 和 **arm64** 平台
|
||||
|
||||
## 自部署指南
|
||||
|
||||
以下主流操作系统已通过测试验证:
|
||||
@@ -61,9 +64,11 @@
|
||||
|
||||
我们提供 **两种** 安装 GLKVM Cloud 的方式:
|
||||
|
||||
#### A) 一键安装脚本(推荐)
|
||||
#### A) 一键安装脚本(推荐,仅支持 x86_64 / amd64)
|
||||
|
||||
> **注意:** 一键安装脚本基于 **Docker**。它会自动完成 Docker / Docker Compose 的安装、拉取镜像、根据模板渲染配置文件,并启动所有服务。
|
||||
>
|
||||
> **平台支持:** 当前仅支持 **x86_64(amd64)** 平台。
|
||||
|
||||
使用 **root 权限** 运行以下命令安装 GLKVM 轻量云:
|
||||
|
||||
@@ -74,6 +79,8 @@
|
||||
#### B) 使用 Docker 手动安装
|
||||
|
||||
> 完整参考文档请查看:[`docker-compose/README-CN.md`](https://github.com/gl-inet/glkvm-cloud/blob/main/docker-compose/README-CN.md)
|
||||
>
|
||||
> 平台支持: 同时支持 x86_64(amd64) 与 arm64(AArch64) 平台。
|
||||
|
||||
|
||||
### 🌐 平台访问
|
||||
|
||||
@@ -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 {
|
||||
@@ -432,9 +442,10 @@ func (srv *RttyServer) ListenAPI() error {
|
||||
|
||||
r.GET("/auth-config", func(c *gin.Context) {
|
||||
authConfig := gin.H{
|
||||
"ldapEnabled": cfg.LdapEnabled,
|
||||
"legacyPassword": cfg.Password != "",
|
||||
"oidcEnabled": cfg.OIDCEnabled,
|
||||
"ldapEnabled": cfg.LdapEnabled,
|
||||
"legacyPassword": cfg.Password != "",
|
||||
"oidcEnabled": cfg.OIDCEnabled,
|
||||
"kvmCloudVersion": KVMCloudVersion,
|
||||
}
|
||||
c.JSON(http.StatusOK, authConfig)
|
||||
})
|
||||
@@ -498,13 +509,20 @@ func (srv *RttyServer) ListenAPI() error {
|
||||
hostname = host // Use host directly if no port
|
||||
}
|
||||
|
||||
// Choose value by priority:
|
||||
// 1) If request host is a domain (not an IP), keep it.
|
||||
// 2) Else if it's an IP and cfg.WebrtcIP is set, use cfg.WebrtcIP.
|
||||
// 3) Else keep the request IP.
|
||||
chosen := hostname
|
||||
if isIP(hostname) && cfg.WebrtcIP != "" {
|
||||
chosen = cfg.WebrtcIP
|
||||
// -------- Reverse proxy mode: force IP ----------
|
||||
if cfg.ReverseProxyEnabled {
|
||||
// Reverse proxy mode: always use configured WebRTC IP
|
||||
if strings.TrimSpace(cfg.WebrtcIP) != "" {
|
||||
chosen = strings.TrimSpace(cfg.WebrtcIP)
|
||||
}
|
||||
} else {
|
||||
// -------- 3) Original behavior (unchanged) ----------
|
||||
// 1) If hostname is domain, keep it
|
||||
// 2) If hostname is IP and cfg.WebrtcIP is set, use cfg.WebrtcIP
|
||||
if isIP(hostname) && cfg.WebrtcIP != "" {
|
||||
chosen = cfg.WebrtcIP
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -524,7 +542,10 @@ func (srv *RttyServer) ListenAPI() error {
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
if cfg.SslCert != "" && cfg.SslKey != "" {
|
||||
// If we're behind a reverse proxy (TLS terminated by nginx), never enable TLS here.
|
||||
enableTLS := !cfg.ReverseProxyEnabled && cfg.SslCert != "" && cfg.SslKey != ""
|
||||
|
||||
if enableTLS {
|
||||
crt, err := tls.LoadX509KeyPair(cfg.SslCert, cfg.SslKey)
|
||||
if err != nil {
|
||||
log.Fatal().Msg(err.Error())
|
||||
|
||||
@@ -78,6 +78,23 @@ type Config struct {
|
||||
OIDCGenericAllowedSubs []string
|
||||
OIDCGenericAllowedUsernames []string
|
||||
OIDCGenericAllowedGroups []string
|
||||
|
||||
// =====================================================
|
||||
// Reverse Proxy / Proxy Mode
|
||||
// =====================================================
|
||||
// Enable proxy mode (app is behind Nginx/Traefik/Caddy/Cloudflare)
|
||||
ReverseProxyEnabled bool
|
||||
|
||||
// =====================================================
|
||||
// Device Remote Access
|
||||
// =====================================================
|
||||
// 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
|
||||
@@ -251,6 +268,52 @@ func parseYamlCfg(cfg *Config, conf string) error {
|
||||
cfg.OIDCGenericAllowedGroups = splitScopes(s)
|
||||
}
|
||||
|
||||
// Reverse proxy mode is always read from environment variable
|
||||
// to avoid config drift when running behind different proxies per deployment.
|
||||
if v := strings.TrimSpace(os.Getenv("REVERSE_PROXY_ENABLED")); v != "" {
|
||||
// Accept common truthy values: "true/false", "1/0", "yes/no", "on/off"
|
||||
if b, err := strconv.ParseBool(v); err == nil {
|
||||
cfg.ReverseProxyEnabled = b
|
||||
} else {
|
||||
return fmt.Errorf("invalid REVERSE_PROXY_ENABLED value %q, expected boolean (true/false/1/0)", v)
|
||||
}
|
||||
}
|
||||
|
||||
if v := strings.TrimSpace(os.Getenv("DEVICE_ENDPOINT_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
|
||||
// Keep only host[:port]
|
||||
if idx := strings.IndexAny(cleaned, "/?#"); idx != -1 {
|
||||
cleaned = cleaned[:idx]
|
||||
}
|
||||
|
||||
// 3. Final trim
|
||||
cleaned = strings.TrimSpace(cleaned)
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
# Images
|
||||
GLKVM_IMAGE=glzhitong/glkvm-cloud:latest-arm64
|
||||
COTURN_IMAGE=coturn/coturn:edge-alpine-arm64v8
|
||||
|
||||
# Enable reverse proxy mode (e.g. Nginx in front of GLKVM Cloud).
|
||||
# When enabled, TLS is handled by the proxy and GLKVM Cloud runs in plain HTTP.
|
||||
#
|
||||
# Note:
|
||||
# In reverse-proxy mode, remote device access depends on the correct forwarded headers
|
||||
# from the front-end proxy. If these headers are missing or incorrect, GLKVM Cloud may
|
||||
# generate redirect URLs with the internal port (e.g. :10443).
|
||||
#
|
||||
# Please make sure your Nginx config includes:
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header X-Forwarded-Host $host;
|
||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# proxy_set_header X-Forwarded-Port $server_port;
|
||||
# proxy_set_header X-Real-IP $remote_addr;
|
||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
#
|
||||
# Reference (verified working example):
|
||||
# https://github.com/gl-inet/glkvm-cloud/blob/main/docker-compose/nginx-reverse-proxy-example.conf
|
||||
REVERSE_PROXY_ENABLED=false
|
||||
|
||||
# =====================================================
|
||||
# Device Remote Access Domain (Reverse Proxy Mode Only)
|
||||
# =====================================================
|
||||
# This option is used to generate the Remote Control URL for devices when
|
||||
# running behind a reverse proxy.
|
||||
#
|
||||
# Effective ONLY when:
|
||||
# REVERSE_PROXY_ENABLED=true
|
||||
#
|
||||
# When set, GLKVM Cloud will generate device access addresses as:
|
||||
# https://<deviceId>.<DEVICE_ENDPOINT_HOST>/... (scheme is taken from X-Forwarded-Proto)
|
||||
#
|
||||
# Examples:
|
||||
# DEVICE_ENDPOINT_HOST=kvm.example.com
|
||||
# DEVICE_ENDPOINT_HOST=kvm.example.com:443
|
||||
#
|
||||
# Notes:
|
||||
# - Do NOT include scheme (http:// or https://)
|
||||
# - Do NOT include path (/xxx)
|
||||
#
|
||||
# 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=
|
||||
|
||||
# rttys
|
||||
RTTYS_TOKEN=DeviceTokenYouCanChangeMe
|
||||
RTTYS_PASS=StrongP@ssw0rd
|
||||
RTTYS_DEVICE_PORT=5912
|
||||
RTTYS_WEBUI_PORT=443
|
||||
RTTYS_HTTP_PROXY_PORT=10443
|
||||
|
||||
# TURN
|
||||
TURN_PORT=3478
|
||||
TURN_USER=glkvmcloudwebrtcuser
|
||||
TURN_PASS=AnotherS3cret
|
||||
|
||||
# LDAP Authentication (Optional)
|
||||
LDAP_ENABLED=false
|
||||
LDAP_SERVER=your-ldap-server.com
|
||||
LDAP_PORT=389
|
||||
LDAP_USE_TLS=false
|
||||
LDAP_BIND_DN=cn=service-account,ou=users,dc=company,dc=com
|
||||
LDAP_BIND_PASSWORD=service-password
|
||||
LDAP_BASE_DN=ou=users,dc=company,dc=com
|
||||
|
||||
# User filter examples for different LDAP implementations:
|
||||
# Active Directory: (&(objectClass=person)(sAMAccountName=%s))
|
||||
# OpenLDAP: (&(objectClass=inetOrgPerson)(uid=%s))
|
||||
# FreeIPA: (&(objectClass=person)(uid=%s))
|
||||
# Generic LDAP: (uid=%s)
|
||||
LDAP_USER_FILTER=(uid=%s)
|
||||
|
||||
LDAP_ALLOWED_GROUPS=admins,operators
|
||||
LDAP_ALLOWED_USERS=user1,user2
|
||||
|
||||
# OIDC Authentication (Optional, generic OIDC provider)
|
||||
OIDC_ENABLED=false
|
||||
OIDC_ISSUER=
|
||||
OIDC_CLIENT_ID=
|
||||
OIDC_CLIENT_SECRET=
|
||||
OIDC_AUTH_URL=
|
||||
OIDC_TOKEN_URL=
|
||||
|
||||
# Redirect URL registered in your OIDC provider.
|
||||
# The path part (/auth/oidc/callback) is fixed by GLKVM Cloud and must not be changed.
|
||||
# Example:
|
||||
# OIDC_REDIRECT_URL=https://your-domain.example.com/auth/oidc/callback
|
||||
OIDC_REDIRECT_URL=
|
||||
|
||||
OIDC_SCOPES="openid profile email"
|
||||
|
||||
# Email-based whitelist (exact email or domain like @example.com)
|
||||
OIDC_ALLOWED_USERS=
|
||||
# Subject (sub) whitelist (stable user IDs)
|
||||
OIDC_ALLOWED_SUBS=
|
||||
# Username whitelist (preferred_username or name)
|
||||
OIDC_ALLOWED_USERNAMES=
|
||||
# Groups whitelist (e.g. admin, devops)
|
||||
OIDC_ALLOWED_GROUPS=
|
||||
@@ -2,7 +2,68 @@
|
||||
GLKVM_IMAGE=glzhitong/glkvm-cloud:latest
|
||||
COTURN_IMAGE=coturn/coturn:edge-alpine
|
||||
|
||||
# GLKVM access IP seen by devices/users.
|
||||
# Enable reverse proxy mode (e.g. Nginx in front of GLKVM Cloud).
|
||||
# When enabled, TLS is handled by the proxy and GLKVM Cloud runs in plain HTTP.
|
||||
#
|
||||
# Note:
|
||||
# In reverse-proxy mode, remote device access depends on the correct forwarded headers
|
||||
# from the front-end proxy. If these headers are missing or incorrect, GLKVM Cloud may
|
||||
# generate redirect URLs with the internal port (e.g. :10443).
|
||||
#
|
||||
# Please make sure your Nginx config includes:
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header X-Forwarded-Host $host;
|
||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# proxy_set_header X-Forwarded-Port $server_port;
|
||||
# proxy_set_header X-Real-IP $remote_addr;
|
||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
#
|
||||
# Reference (verified working example):
|
||||
# https://github.com/gl-inet/glkvm-cloud/blob/main/docker-compose/nginx-reverse-proxy-example.conf
|
||||
REVERSE_PROXY_ENABLED=false
|
||||
|
||||
# =====================================================
|
||||
# Device Remote Access Domain (Reverse Proxy Mode Only)
|
||||
# =====================================================
|
||||
# This option is used to generate the Remote Control URL for devices when
|
||||
# running behind a reverse proxy.
|
||||
#
|
||||
# Effective ONLY when:
|
||||
# REVERSE_PROXY_ENABLED=true
|
||||
#
|
||||
# When set, GLKVM Cloud will generate device access addresses as:
|
||||
# https://<deviceId>.<DEVICE_ENDPOINT_HOST>/... (scheme is taken from X-Forwarded-Proto)
|
||||
#
|
||||
# Examples:
|
||||
# DEVICE_ENDPOINT_HOST=kvm.example.com
|
||||
# DEVICE_ENDPOINT_HOST=kvm.example.com:443
|
||||
#
|
||||
# Notes:
|
||||
# - Do NOT include scheme (http:// or https://)
|
||||
# - Do NOT include path (/xxx)
|
||||
#
|
||||
# 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=
|
||||
|
||||
|
||||
@@ -7,9 +7,19 @@
|
||||
```bash
|
||||
git clone https://github.com/gl-inet/glkvm-cloud.git
|
||||
cd glkvm-cloud/docker-compose/
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
* **x86_64(amd64)平台**:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
* **arm64(AArch64)平台**:
|
||||
|
||||
```bash
|
||||
cp .env.arm64.example .env
|
||||
```
|
||||
|
||||
|
||||
### 2. **配置环境变量**
|
||||
|
||||
编辑 `.env` 文件,并根据需求更新关键参数:
|
||||
@@ -53,6 +63,61 @@ cp .env.example .env
|
||||
- `OIDC_ALLOWED_USERNAMES`:允许的用户名列表(可选)
|
||||
- `OIDC_ALLOWED_GROUPS`:允许的用户组列表(可选)
|
||||
|
||||
#### 反向代理模式(可选)
|
||||
|
||||
```env
|
||||
REVERSE_PROXY_ENABLED=false
|
||||
```
|
||||
|
||||
启用后(`REVERSE_PROXY_ENABLED=true`):
|
||||
|
||||
- GLKVM Cloud 运行在反向代理(如 Nginx)之后
|
||||
- TLS 由反向代理终止,GLKVM Cloud 内部使用 HTTP
|
||||
- Web UI 与设备远程访问可共用同一个 HTTPS 端口(通常为 443)
|
||||
|
||||
|
||||
##### 必需的反向代理请求头
|
||||
|
||||
反向代理必须转发以下请求头,否则可能生成包含内部端口(如 `:10443`)的访问地址:
|
||||
|
||||
```nginx
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
```
|
||||
|
||||
##### 设备远程访问域名(可选)
|
||||
|
||||
```env
|
||||
DEVICE_ENDPOINT_HOST=
|
||||
```
|
||||
|
||||
- **仅在** `REVERSE_PROXY_ENABLED=true` 时生效
|
||||
- 用于指定设备远程访问使用的域名
|
||||
- 生成的设备访问地址格式为:
|
||||
|
||||
```text
|
||||
https://<deviceId>.<DEVICE_ENDPOINT_HOST>/
|
||||
```
|
||||
|
||||
**说明:**
|
||||
|
||||
- 不需要包含 `http(s)://` 或路径
|
||||
- 可与 Web UI 域名不同
|
||||
- 留空时,将从 `X-Forwarded-*` 请求头自动推导
|
||||
|
||||
**示例:**
|
||||
|
||||
```text
|
||||
https://www.example.com → Web UI
|
||||
https://<deviceId>.kvm.example.com → 设备远程访问
|
||||
DEVICE_ENDPOINT_HOST=kvm.example.com
|
||||
```
|
||||
|
||||
|
||||
⚠️ **注意:所有配置均需在 `.env` 中完成,不需要修改 `docker-compose.yml`、模板或脚本。**
|
||||
|
||||
### 3. **启动服务**
|
||||
|
||||
@@ -7,8 +7,16 @@
|
||||
```bash
|
||||
git clone https://github.com/gl-inet/glkvm-cloud.git
|
||||
cd glkvm-cloud/docker-compose/
|
||||
cp .env.example .env
|
||||
```
|
||||
* For **x86_64 (amd64)**:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
* For **arm64 (AArch64)**:
|
||||
```bash
|
||||
cp .env.arm64.example .env
|
||||
```
|
||||
|
||||
2. **Configure environment variables**
|
||||
|
||||
@@ -54,9 +62,70 @@
|
||||
- `OIDC_ALLOWED_USERNAMES`: comma-separated list of allowed usernames (`preferred_username` or `name`) (optional)
|
||||
- `OIDC_ALLOWED_GROUPS`: comma-separated list of allowed OIDC groups (optional)
|
||||
|
||||
|
||||
#### Reverse Proxy Mode (Optional)
|
||||
|
||||
```env
|
||||
REVERSE_PROXY_ENABLED=false
|
||||
```
|
||||
|
||||
When enabled (`REVERSE_PROXY_ENABLED=true`):
|
||||
|
||||
- GLKVM Cloud runs behind a reverse proxy (e.g. Nginx)
|
||||
- TLS is terminated at the reverse proxy; GLKVM Cloud uses plain HTTP internally
|
||||
- The Web UI and remote device access can share the same HTTPS port (usually 443)
|
||||
|
||||
|
||||
##### Required Reverse Proxy Headers
|
||||
|
||||
The reverse proxy **must** forward the following headers; otherwise, GLKVM Cloud may generate URLs containing internal ports (e.g. `:10443`):
|
||||
|
||||
```nginx
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
```
|
||||
|
||||
|
||||
##### Device Remote Access Domain (Optional)
|
||||
|
||||
```env
|
||||
DEVICE_ENDPOINT_HOST=
|
||||
```
|
||||
|
||||
- **Effective only when** `REVERSE_PROXY_ENABLED=true`
|
||||
- Used to specify the domain for device remote access
|
||||
- Device access URLs are generated as:
|
||||
|
||||
```text
|
||||
https://<deviceId>.<DEVICE_ENDPOINT_HOST>/
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
|
||||
- Do not include the scheme (`http://` or `https://`)
|
||||
- Do not include any path
|
||||
- The domain may differ from the Web UI domain
|
||||
- If left empty, the host/port will be derived from `X-Forwarded-*` headers
|
||||
|
||||
**Example:**
|
||||
|
||||
```text
|
||||
https://www.example.com → Web UI
|
||||
https://<deviceId>.kvm.example.com → Device remote access
|
||||
DEVICE_ENDPOINT_HOST=kvm.example.com
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
⚠️ **Note:** All configuration should be done in the `.env` file.
|
||||
You don’t need to modify `docker-compose.yml`, templates, or scripts directly.
|
||||
|
||||
|
||||
3. **Start the services**
|
||||
|
||||
```bash
|
||||
|
||||
@@ -49,6 +49,14 @@ services:
|
||||
OIDC_ALLOWED_SUBS: ${OIDC_ALLOWED_SUBS:-}
|
||||
OIDC_ALLOWED_USERNAMES: ${OIDC_ALLOWED_USERNAMES:-}
|
||||
OIDC_ALLOWED_GROUPS: ${OIDC_ALLOWED_GROUPS:-}
|
||||
|
||||
# ---- Reverse Proxy ----
|
||||
REVERSE_PROXY_ENABLED: ${REVERSE_PROXY_ENABLED:-false}
|
||||
|
||||
# ---- 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
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
# =========================================================
|
||||
# GLKVM Cloud - Reverse Proxy Mode (Nginx Example)
|
||||
#
|
||||
# This configuration shows how to run GLKVM Cloud behind
|
||||
# Nginx in reverse proxy mode.
|
||||
#
|
||||
# - TLS is terminated by Nginx
|
||||
# - GLKVM Cloud listens on plain HTTP internally
|
||||
# - Web UI and remote device access share the same HTTPS port
|
||||
# - Routing is based on the requested domain name
|
||||
# =========================================================
|
||||
|
||||
# WebSocket connection helper
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
# --- Web UI: https://www.example.com ---
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name www.example.com;
|
||||
|
||||
ssl_certificate /path/to/fullchain.pem;
|
||||
ssl_certificate_key /path/to/privkey.pem;
|
||||
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
location / {
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# Required forwarded headers for reverse proxy mode
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
# WebSocket support
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
# GLKVM Cloud web service (HTTP)
|
||||
proxy_pass http://127.0.0.1:1443;
|
||||
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
}
|
||||
|
||||
# --- Device Access: https://<device_id>.example.com ---
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name *.example.com;
|
||||
|
||||
ssl_certificate /path/to/fullchain.pem;
|
||||
ssl_certificate_key /path/to/privkey.pem;
|
||||
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
location / {
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# Required forwarded headers for reverse proxy mode
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
# WebSocket support
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
# GLKVM Cloud device access service (HTTP)
|
||||
proxy_pass http://127.0.0.1:10443;
|
||||
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
}
|
||||
@@ -25,36 +25,36 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"rttys/utils"
|
||||
"rttys/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/valyala/bytebufferpool"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/valyala/bytebufferpool"
|
||||
)
|
||||
|
||||
type HttpProxySession struct {
|
||||
expire atomic.Int64
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
devid string
|
||||
group string
|
||||
destaddr string
|
||||
https bool
|
||||
expire atomic.Int64
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
devid string
|
||||
group string
|
||||
destaddr string
|
||||
https bool
|
||||
}
|
||||
|
||||
var httpProxySessions = sync.Map{}
|
||||
@@ -62,382 +62,500 @@ var httpProxySessions = sync.Map{}
|
||||
const httpProxySessionsExpire = 15 * time.Minute
|
||||
|
||||
func (ses *HttpProxySession) Expire() {
|
||||
ses.expire.Store(time.Now().Add(httpProxySessionsExpire).Unix())
|
||||
ses.expire.Store(time.Now().Add(httpProxySessionsExpire).Unix())
|
||||
}
|
||||
|
||||
func (ses *HttpProxySession) String() string {
|
||||
return fmt.Sprintf("{devid: %s, group: %s, destaddr: %s, https: %v}",
|
||||
ses.devid, ses.group, ses.destaddr, ses.https)
|
||||
return fmt.Sprintf("{devid: %s, group: %s, destaddr: %s, https: %v}",
|
||||
ses.devid, ses.group, ses.destaddr, ses.https)
|
||||
}
|
||||
|
||||
func (srv *RttyServer) ListenHttpProxy() {
|
||||
cfg := &srv.cfg
|
||||
cfg := &srv.cfg
|
||||
|
||||
if cfg.AddrHttpProxy != "" {
|
||||
addr, err := net.ResolveTCPAddr("tcp", cfg.AddrHttpProxy)
|
||||
if err != nil {
|
||||
log.Warn().Msg("invalid http proxy addr: " + err.Error())
|
||||
} else {
|
||||
srv.httpProxyPort = addr.Port
|
||||
}
|
||||
}
|
||||
if cfg.AddrHttpProxy != "" {
|
||||
addr, err := net.ResolveTCPAddr("tcp", cfg.AddrHttpProxy)
|
||||
if err != nil {
|
||||
log.Warn().Msg("invalid http proxy addr: " + err.Error())
|
||||
} else {
|
||||
srv.httpProxyPort = addr.Port
|
||||
}
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", cfg.AddrHttpProxy)
|
||||
if err != nil {
|
||||
log.Fatal().Msg(err.Error())
|
||||
}
|
||||
defer ln.Close()
|
||||
ln, err := net.Listen("tcp", cfg.AddrHttpProxy)
|
||||
if err != nil {
|
||||
log.Fatal().Msg(err.Error())
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
if cfg.SslCert != "" && cfg.SslKey != "" {
|
||||
crt, err := tls.LoadX509KeyPair(cfg.SslCert, cfg.SslKey)
|
||||
if err != nil {
|
||||
log.Fatal().Msg(err.Error())
|
||||
}
|
||||
// In reverse proxy mode (TLS terminated by nginx), never enable TLS here.
|
||||
enableTLS := !cfg.ReverseProxyEnabled && cfg.SslCert != "" && cfg.SslKey != ""
|
||||
if enableTLS {
|
||||
crt, err := tls.LoadX509KeyPair(cfg.SslCert, cfg.SslKey)
|
||||
if err != nil {
|
||||
log.Fatal().Msg(err.Error())
|
||||
}
|
||||
|
||||
tlsConfig := &tls.Config{Certificates: []tls.Certificate{crt}}
|
||||
tlsConfig := &tls.Config{Certificates: []tls.Certificate{crt}}
|
||||
|
||||
ln = tls.NewListener(ln, tlsConfig)
|
||||
}
|
||||
ln = tls.NewListener(ln, tlsConfig)
|
||||
}
|
||||
|
||||
srv.httpProxyPort = ln.Addr().(*net.TCPAddr).Port
|
||||
srv.httpProxyPort = ln.Addr().(*net.TCPAddr).Port
|
||||
|
||||
log.Info().Msgf("Listen http proxy on: %s", ln.Addr().(*net.TCPAddr))
|
||||
log.Info().Msgf("Listen http proxy on: %s", ln.Addr().(*net.TCPAddr))
|
||||
|
||||
go httpProxySessionsClean()
|
||||
go httpProxySessionsClean()
|
||||
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
log.Error().Msg(err.Error())
|
||||
continue
|
||||
}
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
log.Error().Msg(err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
go doHttpProxy(srv, c)
|
||||
}
|
||||
go doHttpProxy(srv, c)
|
||||
}
|
||||
}
|
||||
|
||||
func httpProxySessionsClean() {
|
||||
for {
|
||||
time.Sleep(time.Second * 30)
|
||||
for {
|
||||
time.Sleep(time.Second * 30)
|
||||
|
||||
httpProxySessions.Range(func(key, value any) bool {
|
||||
ses := value.(*HttpProxySession)
|
||||
if time.Now().Unix() > ses.expire.Load() {
|
||||
log.Debug().Msgf("Http proxy session '%s' expired", key)
|
||||
ses.cancel()
|
||||
httpProxySessions.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
httpProxySessions.Range(func(key, value any) bool {
|
||||
ses := value.(*HttpProxySession)
|
||||
if time.Now().Unix() > ses.expire.Load() {
|
||||
log.Debug().Msgf("Http proxy session '%s' expired", key)
|
||||
ses.cancel()
|
||||
httpProxySessions.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func doHttpProxy(srv *RttyServer, c net.Conn) {
|
||||
defer logPanic()
|
||||
defer c.Close()
|
||||
defer logPanic()
|
||||
defer c.Close()
|
||||
|
||||
br := bufio.NewReader(c)
|
||||
br := bufio.NewReader(c)
|
||||
|
||||
req, err := http.ReadRequest(br)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// 获取 URL 查询参数
|
||||
queryParams := req.URL.Query()
|
||||
name := queryParams.Get("sid")
|
||||
if name != "" {
|
||||
location := "/"
|
||||
location += fmt.Sprintf("?_=%d", time.Now().Unix())
|
||||
req, err := http.ReadRequest(br)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
Write302WithCookie(c, location, "rtty-http-sid", name)
|
||||
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")
|
||||
}
|
||||
|
||||
cookie, err := req.Cookie("rtty-http-sid")
|
||||
if err != nil {
|
||||
log.Debug().Msgf(`not found cookie "rtty-http-sid"`)
|
||||
sendHTTPErrorResponse(c, "invalid")
|
||||
return
|
||||
}
|
||||
sid := cookie.Value
|
||||
// 获取 URL 查询参数
|
||||
queryParams := req.URL.Query()
|
||||
name := queryParams.Get("sid")
|
||||
if name != "" {
|
||||
location := "/"
|
||||
|
||||
sesVal, ok := httpProxySessions.Load(sid)
|
||||
if !ok {
|
||||
log.Debug().Msgf(`not found httpProxySession "%s"`, sid)
|
||||
sendHTTPErrorResponse(c, "unauthorized")
|
||||
return
|
||||
}
|
||||
Write302WithCookie(c, location, "rtty-http-sid", name)
|
||||
return
|
||||
}
|
||||
|
||||
ses := sesVal.(*HttpProxySession)
|
||||
cookie, err := req.Cookie("rtty-http-sid")
|
||||
if err != nil {
|
||||
log.Debug().Msgf(`not found cookie "rtty-http-sid"`)
|
||||
sendHTTPErrorResponse(c, "invalid")
|
||||
return
|
||||
}
|
||||
sid := cookie.Value
|
||||
|
||||
dev := srv.GetDevice(ses.group, ses.devid)
|
||||
if dev == nil {
|
||||
log.Debug().Msgf(`device "%s" group "%s" offline`, ses.devid, ses.group)
|
||||
sendHTTPErrorResponse(c, "offline")
|
||||
return
|
||||
}
|
||||
sesVal, ok := httpProxySessions.Load(sid)
|
||||
if !ok {
|
||||
log.Debug().Msgf(`not found httpProxySession "%s"`, sid)
|
||||
sendHTTPErrorResponse(c, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
hostHeaderRewrite := ses.destaddr
|
||||
ses := sesVal.(*HttpProxySession)
|
||||
|
||||
destAddr := genDestAddr(hostHeaderRewrite)
|
||||
srcAddr := tcpAddr2Bytes(c.RemoteAddr().(*net.TCPAddr))
|
||||
dev := srv.GetDevice(ses.group, ses.devid)
|
||||
if dev == nil {
|
||||
log.Debug().Msgf(`device "%s" group "%s" offline`, ses.devid, ses.group)
|
||||
sendHTTPErrorResponse(c, "offline")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ses.ctx)
|
||||
defer cancel()
|
||||
// 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,
|
||||
)
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
c.Close()
|
||||
log.Debug().Msgf("http proxy conn closed: %s", ses)
|
||||
dev.https.Delete(string(srcAddr))
|
||||
sendHttpReq(dev, ses.https, srcAddr[:], destAddr, nil)
|
||||
}()
|
||||
// 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,
|
||||
)
|
||||
}
|
||||
|
||||
log.Debug().Msgf("new http proxy conn: %s", ses)
|
||||
hostHeaderRewrite := ses.destaddr
|
||||
|
||||
dev.https.Store(string(srcAddr), c)
|
||||
destAddr := genDestAddr(hostHeaderRewrite)
|
||||
srcAddr := tcpAddr2Bytes(c.RemoteAddr().(*net.TCPAddr))
|
||||
|
||||
hpw := &HttpProxyWriter{destAddr, srcAddr, hostHeaderRewrite, dev, ses.https}
|
||||
ctx, cancel := context.WithCancel(ses.ctx)
|
||||
defer cancel()
|
||||
|
||||
req.Host = hostHeaderRewrite
|
||||
hpw.WriteRequest(req)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
c.Close()
|
||||
log.Debug().Msgf("http proxy conn closed: %s", ses)
|
||||
dev.https.Delete(string(srcAddr))
|
||||
sendHttpReq(dev, ses.https, srcAddr[:], destAddr, nil)
|
||||
}()
|
||||
|
||||
if req.Header.Get("Upgrade") == "websocket" {
|
||||
b := make([]byte, 4096)
|
||||
log.Debug().Msgf("new http proxy conn: %s", ses)
|
||||
|
||||
for {
|
||||
n, err := c.Read(b)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
sendHttpReq(dev, ses.https, srcAddr, destAddr, b[:n])
|
||||
ses.Expire()
|
||||
}
|
||||
} else {
|
||||
for {
|
||||
req, err := http.ReadRequest(br)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
hpw.WriteRequest(req)
|
||||
ses.Expire()
|
||||
}
|
||||
}
|
||||
dev.https.Store(string(srcAddr), c)
|
||||
|
||||
hpw := &HttpProxyWriter{destAddr, srcAddr, hostHeaderRewrite, dev, ses.https}
|
||||
|
||||
req.Host = hostHeaderRewrite
|
||||
hpw.WriteRequest(req)
|
||||
|
||||
if req.Header.Get("Upgrade") == "websocket" {
|
||||
b := make([]byte, 4096)
|
||||
|
||||
for {
|
||||
n, err := c.Read(b)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
sendHttpReq(dev, ses.https, srcAddr, destAddr, b[:n])
|
||||
ses.Expire()
|
||||
}
|
||||
} else {
|
||||
for {
|
||||
req, err := http.ReadRequest(br)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
hpw.WriteRequest(req)
|
||||
ses.Expire()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func httpProxyRedirect(srv *RttyServer, c *gin.Context, group string) {
|
||||
cfg := &srv.cfg
|
||||
devid := c.Param("devid")
|
||||
proto := c.Param("proto")
|
||||
addr := c.Param("addr")
|
||||
rawPath := c.Param("path")
|
||||
log.Info().Msgf("httpProxyRedirect devid: %s, proto: %s, addr: %s, path: %s", devid, proto, addr, rawPath)
|
||||
cfg := &srv.cfg
|
||||
devid := c.Param("devid")
|
||||
proto := c.Param("proto")
|
||||
addr := c.Param("addr")
|
||||
rawPath := c.Param("path")
|
||||
log.Info().Msgf("httpProxyRedirect devid: %s, proto: %s, addr: %s, path: %s", devid, proto, addr, rawPath)
|
||||
|
||||
if !callUserHookUrl(cfg, c) {
|
||||
c.Status(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if !callUserHookUrl(cfg, c) {
|
||||
c.Status(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().Msgf("httpProxyRedirect devid: %s, proto: %s, addr: %s, path: %s", devid, proto, addr, rawPath)
|
||||
log.Debug().Msgf("httpProxyRedirect devid: %s, proto: %s, addr: %s, path: %s", devid, proto, addr, rawPath)
|
||||
|
||||
_, _, err := httpProxyVaildAddr(addr)
|
||||
if err != nil {
|
||||
log.Debug().Msgf("invalid addr: %s", addr)
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_, _, err := httpProxyVaildAddr(addr)
|
||||
if err != nil {
|
||||
log.Debug().Msgf("invalid addr: %s", addr)
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
path, err := url.Parse(rawPath)
|
||||
if err != nil {
|
||||
log.Debug().Msgf("invalid path: %s", rawPath)
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
path, err := url.Parse(rawPath)
|
||||
if err != nil {
|
||||
log.Debug().Msgf("invalid path: %s", rawPath)
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
dev := srv.GetDevice(group, devid)
|
||||
if dev == nil {
|
||||
c.Redirect(http.StatusFound, "/error/offline")
|
||||
return
|
||||
}
|
||||
dev := srv.GetDevice(group, devid)
|
||||
if dev == nil {
|
||||
c.Redirect(http.StatusFound, "/error/offline")
|
||||
return
|
||||
}
|
||||
|
||||
location := c.Request.Header.Get("HttpProxyRedir")
|
||||
log.Info().Msgf("HttpProxyRedir location: %s, devid: %s", location, devid)
|
||||
if location == "" {
|
||||
location = cfg.HttpProxyRedirURL
|
||||
if location != "" {
|
||||
log.Debug().Msgf("use HttpProxyRedirURL from config: %s, devid: %s", location, devid)
|
||||
}
|
||||
} else {
|
||||
log.Debug().Msgf("use HttpProxyRedir from HTTP header: %s, devid: %s", location, devid)
|
||||
}
|
||||
location := c.Request.Header.Get("HttpProxyRedir")
|
||||
log.Info().Msgf("HttpProxyRedir location: %s, devid: %s", location, devid)
|
||||
if location == "" {
|
||||
location = cfg.HttpProxyRedirURL
|
||||
if location != "" {
|
||||
log.Debug().Msgf("use HttpProxyRedirURL from config: %s, devid: %s", location, devid)
|
||||
}
|
||||
} else {
|
||||
log.Debug().Msgf("use HttpProxyRedir from HTTP header: %s, devid: %s", location, devid)
|
||||
}
|
||||
|
||||
if location == "" {
|
||||
host, _, err := net.SplitHostPort(c.Request.Host)
|
||||
if err != nil {
|
||||
host = c.Request.Host
|
||||
}
|
||||
if location == "" {
|
||||
host, _, err := net.SplitHostPort(c.Request.Host)
|
||||
if err != nil {
|
||||
host = c.Request.Host
|
||||
}
|
||||
|
||||
location = "http://" + host
|
||||
location = "http://" + host
|
||||
|
||||
if srv.httpProxyPort != 80 {
|
||||
location += fmt.Sprintf(":%d", srv.httpProxyPort)
|
||||
}
|
||||
}
|
||||
if srv.httpProxyPort != 80 {
|
||||
location += fmt.Sprintf(":%d", srv.httpProxyPort)
|
||||
}
|
||||
}
|
||||
|
||||
location += path.Path
|
||||
location += path.Path
|
||||
|
||||
location += fmt.Sprintf("?_=%d", time.Now().Unix())
|
||||
if path.RawQuery != "" {
|
||||
location += "&" + path.RawQuery
|
||||
}
|
||||
|
||||
if path.RawQuery != "" {
|
||||
location += "&" + path.RawQuery
|
||||
}
|
||||
sid, err := c.Cookie("rtty-http-sid")
|
||||
log.Info().Msgf("rtty-http-sid: %s", sid)
|
||||
if err == nil {
|
||||
if v, loaded := httpProxySessions.LoadAndDelete(sid); loaded {
|
||||
s := v.(*HttpProxySession)
|
||||
s.cancel()
|
||||
log.Debug().Msgf(`del old httpProxySession "%s" for device "%s"`, sid, devid)
|
||||
}
|
||||
}
|
||||
|
||||
sid, err := c.Cookie("rtty-http-sid")
|
||||
log.Info().Msgf("rtty-http-sid: %s", sid)
|
||||
if err == nil {
|
||||
if v, loaded := httpProxySessions.LoadAndDelete(sid); loaded {
|
||||
s := v.(*HttpProxySession)
|
||||
s.cancel()
|
||||
log.Debug().Msgf(`del old httpProxySession "%s" for device "%s"`, sid, devid)
|
||||
}
|
||||
}
|
||||
sid = utils.GenUniqueID()
|
||||
log.Info().Msgf("rtty-http-sid: %s", sid)
|
||||
ctx, cancel := context.WithCancel(dev.ctx)
|
||||
|
||||
sid = utils.GenUniqueID()
|
||||
log.Info().Msgf("rtty-http-sid: %s", sid)
|
||||
ctx, cancel := context.WithCancel(dev.ctx)
|
||||
ses := &HttpProxySession{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
devid: devid,
|
||||
group: group,
|
||||
destaddr: addr,
|
||||
https: proto == "https",
|
||||
}
|
||||
ses.Expire()
|
||||
httpProxySessions.Store(sid, ses)
|
||||
|
||||
ses := &HttpProxySession{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
devid: devid,
|
||||
group: group,
|
||||
destaddr: addr,
|
||||
https: proto == "https",
|
||||
}
|
||||
ses.Expire()
|
||||
httpProxySessions.Store(sid, ses)
|
||||
log.Debug().Msgf(`new httpProxySession "%s" for device "%s"`, sid, devid)
|
||||
|
||||
log.Debug().Msgf(`new httpProxySession "%s" for device "%s"`, sid, devid)
|
||||
domain := c.Request.Header.Get("HttpProxyRedirDomain")
|
||||
if domain == "" {
|
||||
domain = cfg.HttpProxyRedirDomain
|
||||
if domain != "" {
|
||||
log.Debug().Msgf("set cookie domain from config: %s, devid: %s", domain, devid)
|
||||
}
|
||||
} else {
|
||||
log.Debug().Msgf("set cookie domain from HTTP header: %s, devid: %s", domain, devid)
|
||||
}
|
||||
|
||||
domain := c.Request.Header.Get("HttpProxyRedirDomain")
|
||||
if domain == "" {
|
||||
domain = cfg.HttpProxyRedirDomain
|
||||
if domain != "" {
|
||||
log.Debug().Msgf("set cookie domain from config: %s, devid: %s", domain, devid)
|
||||
}
|
||||
} else {
|
||||
log.Debug().Msgf("set cookie domain from HTTP header: %s, devid: %s", domain, devid)
|
||||
}
|
||||
// Get domain info
|
||||
host := c.Request.Host
|
||||
hostname, _, err := net.SplitHostPort(host)
|
||||
if err != nil {
|
||||
hostname = host
|
||||
}
|
||||
log.Info().Msgf("hostname: %s", hostname)
|
||||
|
||||
// Get domain info
|
||||
host := c.Request.Host
|
||||
hostname, _, err := net.SplitHostPort(host)
|
||||
if err != nil {
|
||||
hostname = host
|
||||
}
|
||||
log.Info().Msgf("hostname: %s", hostname)
|
||||
ip := net.ParseIP(hostname)
|
||||
isIP := ip != nil
|
||||
if isIP {
|
||||
location = fmt.Sprintf("https://%s%s?sid=%s", hostname, cfg.AddrHttpProxy, sid)
|
||||
log.Info().Msgf("Using IP redirect: %s", location)
|
||||
} else {
|
||||
redirHost := buildRedirectHost(hostname, devid)
|
||||
// Keep original behavior when NOT in reverse proxy mode
|
||||
if !cfg.ReverseProxyEnabled {
|
||||
location = fmt.Sprintf("https://%s%s?sid=%s", redirHost, cfg.AddrHttpProxy, sid)
|
||||
log.Info().Msgf("Using domain redirect: %s", location)
|
||||
} else {
|
||||
// ---- verify forwarded headers from reverse proxy ----
|
||||
rawHost := c.GetHeader("Host")
|
||||
xfHost := c.GetHeader("X-Forwarded-Host")
|
||||
xfProto := c.GetHeader("X-Forwarded-Proto")
|
||||
xfPort := c.GetHeader("X-Forwarded-Port")
|
||||
xRealIP := c.GetHeader("X-Real-IP")
|
||||
xFF := c.GetHeader("X-Forwarded-For")
|
||||
|
||||
ip := net.ParseIP(hostname)
|
||||
isIP := ip != nil
|
||||
if isIP {
|
||||
location = fmt.Sprintf("https://%s%s?sid=%s", hostname, cfg.AddrHttpProxy, sid)
|
||||
log.Info().Msgf("Using IP redirect: %s", location)
|
||||
} else {
|
||||
redirHost := buildRedirectHost(hostname, devid)
|
||||
location = fmt.Sprintf("https://%s%s?sid=%s", redirHost, cfg.AddrHttpProxy, sid)
|
||||
log.Info().Msgf("Using domain redirect: %s", location)
|
||||
}
|
||||
log.Info().Msgf(
|
||||
"reverse-proxy info: method=%s uri=%s host=%q tls=%v remoteIP=%q",
|
||||
c.Request.Method,
|
||||
c.Request.URL.String(),
|
||||
rawHost,
|
||||
c.Request.TLS != nil,
|
||||
c.ClientIP(),
|
||||
)
|
||||
log.Info().Msgf(
|
||||
"reverse-proxy headers: Host=%q X-Forwarded-Host=%q X-Forwarded-Proto=%q X-Forwarded-Port=%q X-Real-IP=%q X-Forwarded-For=%q",
|
||||
rawHost, xfHost, xfProto, xfPort, xRealIP, xFF,
|
||||
)
|
||||
|
||||
log.Info().Msgf("Final redirect location: %s", location)
|
||||
c.Redirect(http.StatusFound, location)
|
||||
// -------------------------------------------------
|
||||
// Proxy mode:
|
||||
// 1) If DEVICE_ENDPOINT_HOST is configured, use it directly
|
||||
// 2) Otherwise, fallback to forwarded-header logic
|
||||
// -------------------------------------------------
|
||||
|
||||
// 0) scheme: follow reverse proxy
|
||||
scheme := ""
|
||||
if v := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")); v != "" {
|
||||
scheme = strings.ToLower(strings.Split(v, ",")[0])
|
||||
} else if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
} else {
|
||||
scheme = "http"
|
||||
}
|
||||
|
||||
// [A] Prefer explicit DEVICE_ENDPOINT_HOST if set
|
||||
if v := strings.TrimSpace(cfg.DeviceEndpointHost); v != "" {
|
||||
endpoint := v // already normalized when reading env: host[:port] only
|
||||
|
||||
baseHost := endpoint
|
||||
port := ""
|
||||
if h, p, err := net.SplitHostPort(endpoint); err == nil {
|
||||
baseHost = h
|
||||
port = p
|
||||
}
|
||||
|
||||
// Build device host: <deviceId>.<baseHost>
|
||||
// NOTE: DEVICE_ENDPOINT_HOST is a base domain (host[:port]) for device access,
|
||||
baseHost = strings.TrimSuffix(strings.TrimSpace(baseHost), ".")
|
||||
deviceHost := devid
|
||||
if baseHost != "" {
|
||||
deviceHost = devid + "." + baseHost
|
||||
}
|
||||
|
||||
hostPort := joinHostPortIfNeeded(deviceHost, scheme, port)
|
||||
|
||||
redirectPath := c.Request.URL.Path
|
||||
location = buildRedirectLocation(scheme, hostPort, redirectPath, sid)
|
||||
log.Info().Msgf("Using domain redirect (proxy mode, DEVICE_ENDPOINT_HOST): %s", location)
|
||||
} else {
|
||||
// 1) external port: prefer the one user actually accessed
|
||||
port := ""
|
||||
if fp := strings.TrimSpace(c.GetHeader("X-Forwarded-Port")); fp != "" {
|
||||
port = strings.TrimSpace(strings.Split(fp, ",")[0])
|
||||
} else if fh := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); fh != "" {
|
||||
fh = strings.TrimSpace(strings.Split(fh, ",")[0])
|
||||
if _, p, err := net.SplitHostPort(fh); err == nil && p != "" {
|
||||
port = p
|
||||
}
|
||||
}
|
||||
log.Info().Msgf("port: %s", port)
|
||||
|
||||
// 3) Build host: in proxy mode redirect domain to be redirHost
|
||||
hostPort := joinHostPortIfNeeded(redirHost, scheme, port)
|
||||
|
||||
redirectPath := c.Request.URL.Path
|
||||
location = buildRedirectLocation(scheme, hostPort, redirectPath, sid)
|
||||
log.Info().Msgf("Using domain redirect (proxy mode): %s", location)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Info().Msgf("Final redirect location: %s", location)
|
||||
c.Redirect(http.StatusFound, location)
|
||||
}
|
||||
|
||||
func sendHttpReq(dev *Device, https bool, srcAddr []byte, destAddr []byte, data []byte) {
|
||||
bb := bytebufferpool.Get()
|
||||
defer bytebufferpool.Put(bb)
|
||||
bb := bytebufferpool.Get()
|
||||
defer bytebufferpool.Put(bb)
|
||||
|
||||
if dev.proto > 3 {
|
||||
if https {
|
||||
bb.WriteByte(1)
|
||||
} else {
|
||||
bb.WriteByte(0)
|
||||
}
|
||||
}
|
||||
if dev.proto > 3 {
|
||||
if https {
|
||||
bb.WriteByte(1)
|
||||
} else {
|
||||
bb.WriteByte(0)
|
||||
}
|
||||
}
|
||||
|
||||
bb.Write(srcAddr)
|
||||
bb.Write(destAddr)
|
||||
bb.Write(data)
|
||||
bb.Write(srcAddr)
|
||||
bb.Write(destAddr)
|
||||
bb.Write(data)
|
||||
|
||||
dev.WriteMsg(msgTypeHttp, "", bb.Bytes())
|
||||
dev.WriteMsg(msgTypeHttp, "", bb.Bytes())
|
||||
}
|
||||
|
||||
func genDestAddr(addr string) []byte {
|
||||
destIP, destPort, err := httpProxyVaildAddr(addr)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
destIP, destPort, err := httpProxyVaildAddr(addr)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
b := make([]byte, 6)
|
||||
copy(b, destIP)
|
||||
b := make([]byte, 6)
|
||||
copy(b, destIP)
|
||||
|
||||
binary.BigEndian.PutUint16(b[4:], destPort)
|
||||
binary.BigEndian.PutUint16(b[4:], destPort)
|
||||
|
||||
return b
|
||||
return b
|
||||
}
|
||||
|
||||
func tcpAddr2Bytes(addr *net.TCPAddr) []byte {
|
||||
b := make([]byte, 18)
|
||||
b := make([]byte, 18)
|
||||
|
||||
binary.BigEndian.PutUint16(b[:2], uint16(addr.Port))
|
||||
binary.BigEndian.PutUint16(b[:2], uint16(addr.Port))
|
||||
|
||||
copy(b[2:], addr.IP)
|
||||
copy(b[2:], addr.IP)
|
||||
|
||||
return b
|
||||
return b
|
||||
}
|
||||
|
||||
func httpProxyVaildAddr(addr string) (net.IP, uint16, error) {
|
||||
ips, ports, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
ips = addr
|
||||
ports = "80"
|
||||
}
|
||||
ips, ports, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
ips = addr
|
||||
ports = "80"
|
||||
}
|
||||
|
||||
ip := net.ParseIP(ips)
|
||||
if ip == nil {
|
||||
return nil, 0, errors.New("invalid IPv4 Addr")
|
||||
}
|
||||
ip := net.ParseIP(ips)
|
||||
if ip == nil {
|
||||
return nil, 0, errors.New("invalid IPv4 Addr")
|
||||
}
|
||||
|
||||
ip = ip.To4()
|
||||
if ip == nil {
|
||||
return nil, 0, errors.New("invalid IPv4 Addr")
|
||||
}
|
||||
ip = ip.To4()
|
||||
if ip == nil {
|
||||
return nil, 0, errors.New("invalid IPv4 Addr")
|
||||
}
|
||||
|
||||
port, _ := strconv.Atoi(ports)
|
||||
port, _ := strconv.Atoi(ports)
|
||||
|
||||
return ip, uint16(port), nil
|
||||
return ip, uint16(port), nil
|
||||
}
|
||||
|
||||
type HttpProxyWriter struct {
|
||||
destAddr []byte
|
||||
srcAddr []byte
|
||||
hostHeaderRewrite string
|
||||
dev *Device
|
||||
https bool
|
||||
destAddr []byte
|
||||
srcAddr []byte
|
||||
hostHeaderRewrite string
|
||||
dev *Device
|
||||
https bool
|
||||
}
|
||||
|
||||
func (rw *HttpProxyWriter) Write(p []byte) (n int, err error) {
|
||||
sendHttpReq(rw.dev, rw.https, rw.srcAddr, rw.destAddr, p)
|
||||
return len(p), nil
|
||||
sendHttpReq(rw.dev, rw.https, rw.srcAddr, rw.destAddr, p)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (rw *HttpProxyWriter) WriteRequest(req *http.Request) {
|
||||
req.Host = rw.hostHeaderRewrite
|
||||
req.Write(rw)
|
||||
req.Host = rw.hostHeaderRewrite
|
||||
req.Write(rw)
|
||||
}
|
||||
|
||||
func generateErrorHTML(errorType string) string {
|
||||
return fmt.Sprintf(
|
||||
`<!DOCTYPE html>
|
||||
return fmt.Sprintf(
|
||||
`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
@@ -591,65 +709,28 @@ func generateErrorHTML(errorType string) string {
|
||||
}
|
||||
|
||||
func sendHTTPErrorResponse(conn net.Conn, errorType string) {
|
||||
htmlContent := generateErrorHTML(errorType)
|
||||
htmlContent := generateErrorHTML(errorType)
|
||||
|
||||
response := "HTTP/1.1 200 OK\r\n"
|
||||
response += "Content-Type: text/html; charset=utf-8\r\n"
|
||||
response += fmt.Sprintf("Content-Length: %d\r\n", len(htmlContent))
|
||||
response += "Connection: close\r\n"
|
||||
response += "\r\n"
|
||||
response += htmlContent
|
||||
response := "HTTP/1.1 200 OK\r\n"
|
||||
response += "Content-Type: text/html; charset=utf-8\r\n"
|
||||
response += fmt.Sprintf("Content-Length: %d\r\n", len(htmlContent))
|
||||
response += "Connection: close\r\n"
|
||||
response += "\r\n"
|
||||
response += htmlContent
|
||||
|
||||
conn.Write([]byte(response))
|
||||
conn.Write([]byte(response))
|
||||
}
|
||||
|
||||
func Write302WithCookie(conn net.Conn, location, cookieName, cookieValue string) {
|
||||
cookie := fmt.Sprintf("%s=%s; Path=/; HttpOnly", cookieName, cookieValue)
|
||||
response := fmt.Sprintf(
|
||||
"HTTP/1.1 302 Found\r\n"+
|
||||
"Location: %s\r\n"+
|
||||
"Set-Cookie: %s\r\n"+
|
||||
"Content-Length: 0\r\n"+
|
||||
"Connection: close\r\n"+
|
||||
"\r\n",
|
||||
location, cookie,
|
||||
)
|
||||
_, _ = 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
|
||||
}
|
||||
cookie := fmt.Sprintf("%s=%s; Path=/; HttpOnly", cookieName, cookieValue)
|
||||
response := fmt.Sprintf(
|
||||
"HTTP/1.1 302 Found\r\n"+
|
||||
"Location: %s\r\n"+
|
||||
"Set-Cookie: %s\r\n"+
|
||||
"Content-Length: 0\r\n"+
|
||||
"Connection: close\r\n"+
|
||||
"\r\n",
|
||||
location, cookie,
|
||||
)
|
||||
_, _ = conn.Write([]byte(response))
|
||||
}
|
||||
|
||||
@@ -25,283 +25,284 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
_ "net/http/pprof"
|
||||
"os"
|
||||
"rttys/db"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"context"
|
||||
"encoding/json"
|
||||
_ "net/http/pprof"
|
||||
"os"
|
||||
"rttys/db"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
|
||||
xlog "rttys/log"
|
||||
xlog "rttys/log"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/urfave/cli/v3"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
const RttysVersion = "5.2.0"
|
||||
const KVMCloudVersion = "v1.9.0"
|
||||
|
||||
var (
|
||||
GitCommit = ""
|
||||
BuildTime = ""
|
||||
GitCommit = ""
|
||||
BuildTime = ""
|
||||
)
|
||||
|
||||
func main() {
|
||||
defaultLogPath := "/var/log/rttys.log"
|
||||
if runtime.GOOS == "windows" {
|
||||
defaultLogPath = "rttys.log"
|
||||
}
|
||||
defaultLogPath := "/var/log/rttys.log"
|
||||
if runtime.GOOS == "windows" {
|
||||
defaultLogPath = "rttys.log"
|
||||
}
|
||||
|
||||
cmd := &cli.Command{
|
||||
Name: "rttys",
|
||||
Usage: "The server side for rtty",
|
||||
Version: RttysVersion,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "log",
|
||||
Value: defaultLogPath,
|
||||
Usage: "log file path",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "log-level",
|
||||
Value: "info",
|
||||
Usage: "log level(debug, info, warn, error)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "conf",
|
||||
Aliases: []string{"c"},
|
||||
Usage: "config file to load",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "addr-dev",
|
||||
Value: ":5912",
|
||||
Usage: "address to listen device",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "addr-user",
|
||||
Value: ":5913",
|
||||
Usage: "address to listen user",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "addr-http-proxy",
|
||||
Usage: "address to listen for HTTP proxy (default auto)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "http-proxy-redir-url",
|
||||
Usage: "url to redirect for HTTP proxy",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "http-proxy-redir-domain",
|
||||
Usage: "domain for HTTP proxy set cookie",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "token",
|
||||
Aliases: []string{"t"},
|
||||
Usage: "token to use",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "dev-hook-url",
|
||||
Usage: "called when the device is connected",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "user-hook-url",
|
||||
Usage: "called when user accesses /connect/:devid, /cmd/:devid, /web/, or /web2/ APIs",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "local-auth",
|
||||
Value: true,
|
||||
Usage: "need auth for local",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "password",
|
||||
Usage: "web management password",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "allow-origins",
|
||||
Usage: "allow all origins for cross-domain request",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "ldap-enabled",
|
||||
Usage: "enable LDAP authentication",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "ldap-server",
|
||||
Usage: "LDAP server hostname or IP",
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "ldap-port",
|
||||
Value: 389,
|
||||
Usage: "LDAP server port",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "ldap-use-tls",
|
||||
Usage: "use TLS/SSL for LDAP connection",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "ldap-bind-dn",
|
||||
Usage: "LDAP bind DN for service account",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "ldap-bind-password",
|
||||
Usage: "LDAP bind password for service account",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "ldap-base-dn",
|
||||
Usage: "LDAP base DN for user searches",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "ldap-user-filter",
|
||||
Value: "(uid=%s)",
|
||||
Usage: "LDAP user filter",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "ldap-allowed-groups",
|
||||
Usage: "comma-separated list of allowed LDAP groups",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "ldap-allowed-users",
|
||||
Usage: "comma-separated list of allowed LDAP users",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "pprof",
|
||||
Usage: "enable pprof and listen on specified address (e.g. localhost:6060)",
|
||||
},
|
||||
cmd := &cli.Command{
|
||||
Name: "rttys",
|
||||
Usage: "The server side for rtty",
|
||||
Version: RttysVersion,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "log",
|
||||
Value: defaultLogPath,
|
||||
Usage: "log file path",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "log-level",
|
||||
Value: "info",
|
||||
Usage: "log level(debug, info, warn, error)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "conf",
|
||||
Aliases: []string{"c"},
|
||||
Usage: "config file to load",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "addr-dev",
|
||||
Value: ":5912",
|
||||
Usage: "address to listen device",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "addr-user",
|
||||
Value: ":5913",
|
||||
Usage: "address to listen user",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "addr-http-proxy",
|
||||
Usage: "address to listen for HTTP proxy (default auto)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "http-proxy-redir-url",
|
||||
Usage: "url to redirect for HTTP proxy",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "http-proxy-redir-domain",
|
||||
Usage: "domain for HTTP proxy set cookie",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "token",
|
||||
Aliases: []string{"t"},
|
||||
Usage: "token to use",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "dev-hook-url",
|
||||
Usage: "called when the device is connected",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "user-hook-url",
|
||||
Usage: "called when user accesses /connect/:devid, /cmd/:devid, /web/, or /web2/ APIs",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "local-auth",
|
||||
Value: true,
|
||||
Usage: "need auth for local",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "password",
|
||||
Usage: "web management password",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "allow-origins",
|
||||
Usage: "allow all origins for cross-domain request",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "ldap-enabled",
|
||||
Usage: "enable LDAP authentication",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "ldap-server",
|
||||
Usage: "LDAP server hostname or IP",
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "ldap-port",
|
||||
Value: 389,
|
||||
Usage: "LDAP server port",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "ldap-use-tls",
|
||||
Usage: "use TLS/SSL for LDAP connection",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "ldap-bind-dn",
|
||||
Usage: "LDAP bind DN for service account",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "ldap-bind-password",
|
||||
Usage: "LDAP bind password for service account",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "ldap-base-dn",
|
||||
Usage: "LDAP base DN for user searches",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "ldap-user-filter",
|
||||
Value: "(uid=%s)",
|
||||
Usage: "LDAP user filter",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "ldap-allowed-groups",
|
||||
Usage: "comma-separated list of allowed LDAP groups",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "ldap-allowed-users",
|
||||
Usage: "comma-separated list of allowed LDAP users",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "pprof",
|
||||
Usage: "enable pprof and listen on specified address (e.g. localhost:6060)",
|
||||
},
|
||||
|
||||
// ---- OIDC Authentication (generic OIDC provider) ----
|
||||
&cli.BoolFlag{
|
||||
Name: "oidc-enabled",
|
||||
Usage: "enable OIDC authentication (OpenID Connect)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "oidc-generic-client-id",
|
||||
Usage: "OIDC client ID (issued by the identity provider)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "oidc-generic-client-secret",
|
||||
Usage: "OIDC client secret (read from OIDC_GENERIC_CLIENT_SECRET env by default)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "oidc-generic-auth-url",
|
||||
Usage: "OIDC authorization endpoint URL",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "oidc-generic-token-url",
|
||||
Usage: "OIDC token endpoint URL",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "oidc-generic-redirect-url",
|
||||
Usage: "OIDC redirect/callback URL (must match one registered in IdP)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "oidc-generic-scopes",
|
||||
Value: "openid profile email",
|
||||
Usage: "space-separated list of OIDC scopes",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "oidc-generic-allowed-users",
|
||||
Usage: "optional email whitelist for OIDC logins (exact emails or @domain, space/comma-separated)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "oidc-generic-allowed-subs",
|
||||
Usage: "optional subject (sub) whitelist for OIDC logins (space/comma-separated)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "oidc-generic-allowed-usernames",
|
||||
Usage: "optional username whitelist for OIDC logins (preferred_username/name, space/comma-separated)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "oidc-generic-allowed-groups",
|
||||
Usage: "optional groups whitelist for OIDC logins (space/comma-separated)",
|
||||
},
|
||||
// ---- OIDC Authentication (generic OIDC provider) ----
|
||||
&cli.BoolFlag{
|
||||
Name: "oidc-enabled",
|
||||
Usage: "enable OIDC authentication (OpenID Connect)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "oidc-generic-client-id",
|
||||
Usage: "OIDC client ID (issued by the identity provider)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "oidc-generic-client-secret",
|
||||
Usage: "OIDC client secret (read from OIDC_GENERIC_CLIENT_SECRET env by default)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "oidc-generic-auth-url",
|
||||
Usage: "OIDC authorization endpoint URL",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "oidc-generic-token-url",
|
||||
Usage: "OIDC token endpoint URL",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "oidc-generic-redirect-url",
|
||||
Usage: "OIDC redirect/callback URL (must match one registered in IdP)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "oidc-generic-scopes",
|
||||
Value: "openid profile email",
|
||||
Usage: "space-separated list of OIDC scopes",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "oidc-generic-allowed-users",
|
||||
Usage: "optional email whitelist for OIDC logins (exact emails or @domain, space/comma-separated)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "oidc-generic-allowed-subs",
|
||||
Usage: "optional subject (sub) whitelist for OIDC logins (space/comma-separated)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "oidc-generic-allowed-usernames",
|
||||
Usage: "optional username whitelist for OIDC logins (preferred_username/name, space/comma-separated)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "oidc-generic-allowed-groups",
|
||||
Usage: "optional groups whitelist for OIDC logins (space/comma-separated)",
|
||||
},
|
||||
|
||||
&cli.BoolFlag{
|
||||
Name: "verbose",
|
||||
Aliases: []string{"V"},
|
||||
Usage: "more detailed output",
|
||||
},
|
||||
},
|
||||
Action: cmdAction,
|
||||
}
|
||||
&cli.BoolFlag{
|
||||
Name: "verbose",
|
||||
Aliases: []string{"V"},
|
||||
Usage: "more detailed output",
|
||||
},
|
||||
},
|
||||
Action: cmdAction,
|
||||
}
|
||||
|
||||
err := cmd.Run(context.Background(), os.Args)
|
||||
if err != nil {
|
||||
log.Fatal().Msg(err.Error())
|
||||
}
|
||||
err := cmd.Run(context.Background(), os.Args)
|
||||
if err != nil {
|
||||
log.Fatal().Msg(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func cmdAction(c context.Context, cmd *cli.Command) error {
|
||||
defer logPanic()
|
||||
defer logPanic()
|
||||
|
||||
xlog.SetPath(cmd.String("log"))
|
||||
xlog.SetPath(cmd.String("log"))
|
||||
|
||||
switch cmd.String("log-level") {
|
||||
case "debug":
|
||||
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
case "warn":
|
||||
zerolog.SetGlobalLevel(zerolog.WarnLevel)
|
||||
case "error":
|
||||
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
|
||||
default:
|
||||
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
||||
}
|
||||
switch cmd.String("log-level") {
|
||||
case "debug":
|
||||
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
case "warn":
|
||||
zerolog.SetGlobalLevel(zerolog.WarnLevel)
|
||||
case "error":
|
||||
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
|
||||
default:
|
||||
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
||||
}
|
||||
|
||||
if cmd.Bool("verbose") {
|
||||
xlog.Verbose()
|
||||
}
|
||||
if cmd.Bool("verbose") {
|
||||
xlog.Verbose()
|
||||
}
|
||||
|
||||
log.Info().Msg("Go Version: " + runtime.Version())
|
||||
log.Info().Msgf("Go OS/Arch: %s/%s", runtime.GOOS, runtime.GOARCH)
|
||||
log.Info().Msg("Go Version: " + runtime.Version())
|
||||
log.Info().Msgf("Go OS/Arch: %s/%s", runtime.GOOS, runtime.GOARCH)
|
||||
|
||||
log.Info().Msg("Rttys Version: " + RttysVersion)
|
||||
log.Info().Msg("Rttys Version: " + RttysVersion)
|
||||
|
||||
if GitCommit != "" {
|
||||
log.Info().Msg("Git Commit: " + GitCommit)
|
||||
}
|
||||
if GitCommit != "" {
|
||||
log.Info().Msg("Git Commit: " + GitCommit)
|
||||
}
|
||||
|
||||
if BuildTime != "" {
|
||||
log.Info().Msg("Build Time: " + BuildTime)
|
||||
}
|
||||
if BuildTime != "" {
|
||||
log.Info().Msg("Build Time: " + BuildTime)
|
||||
}
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
go signalHandle()
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
go signalHandle()
|
||||
}
|
||||
|
||||
cfg := Config{
|
||||
AddrDev: ":5912",
|
||||
AddrUser: ":5913",
|
||||
LocalAuth: true,
|
||||
}
|
||||
cfg := Config{
|
||||
AddrDev: ":5912",
|
||||
AddrUser: ":5913",
|
||||
LocalAuth: true,
|
||||
}
|
||||
|
||||
err := cfg.Parse(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err := cfg.Parse(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// ===== 打印完整配置(验证配置是否加载正确) =====
|
||||
{
|
||||
importJSON, _ := json.MarshalIndent(cfg, "", " ")
|
||||
log.Info().Msg("==== Loaded Configuration ====")
|
||||
log.Info().Msg(string(importJSON))
|
||||
log.Info().Msg("==============================")
|
||||
}
|
||||
// ===== 打印完整配置(验证配置是否加载正确) =====
|
||||
{
|
||||
importJSON, _ := json.MarshalIndent(cfg, "", " ")
|
||||
log.Info().Msg("==== Loaded Configuration ====")
|
||||
log.Info().Msg(string(importJSON))
|
||||
log.Info().Msg("==============================")
|
||||
}
|
||||
|
||||
// Initialize the SQLite database connection
|
||||
db.Init()
|
||||
// Initialize the SQLite database connection
|
||||
db.Init()
|
||||
|
||||
srv := &RttyServer{cfg: cfg}
|
||||
srv := &RttyServer{cfg: cfg}
|
||||
|
||||
return srv.Run()
|
||||
return srv.Run()
|
||||
}
|
||||
|
||||
func logPanic() {
|
||||
if r := recover(); r != nil {
|
||||
saveCrashLog(r, debug.Stack())
|
||||
os.Exit(2)
|
||||
}
|
||||
if r := recover(); r != nil {
|
||||
saveCrashLog(r, debug.Stack())
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func saveCrashLog(p any, stack []byte) {
|
||||
log.Error().Msgf("%v", p)
|
||||
log.Error().Msg(string(stack))
|
||||
log.Error().Msgf("%v", p)
|
||||
log.Error().Msg(string(stack))
|
||||
}
|
||||
|
||||
Executable
+232
@@ -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
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
* @Author: LPY
|
||||
* @Date: 2025-06-09 09:29:48
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2025-07-21 10:04:25
|
||||
* @FilePath: /kvm-cloud-frontend/src/components/base/baseWhitePage.vue
|
||||
* @LastEditTime: 2026-01-05 14:30:56
|
||||
* @FilePath: \glkvm-cloud\ui\src\components\base\baseWhitePage.vue
|
||||
* @Description: 基础白名单页。
|
||||
-->
|
||||
<template>
|
||||
@@ -13,6 +13,7 @@
|
||||
<img src="@/assets/svg/logo.svg" height="20">
|
||||
</div>
|
||||
<div class="base-white-page-header-right">
|
||||
<BaseText style="margin-right: 24px;">{{ appStore.state.version || '--' }}</BaseText>
|
||||
<BaseDropdownSelect :value="currentLang" :options="languageOptions" @update:value="changeLang">
|
||||
<div class="language-box flex">
|
||||
<BaseSvg name="gl-icon-language-regular" style="margin-right: 8px;font-size: 16px;"></BaseSvg>
|
||||
@@ -25,13 +26,13 @@
|
||||
|
||||
<div class="base-white-page-footer">
|
||||
<!-- 步骤条 -->
|
||||
<div v-if="route.query.bindToken" class="base-white-page-step">
|
||||
<!-- <div v-if="route.query.bindToken" class="base-white-page-step">
|
||||
<BaseStep
|
||||
v-model:value="useUserStore().bindingStep"
|
||||
:items="[{title: $t('login.accountSetup')}, {title: $t('login.deviceSetup')}]"
|
||||
titlePosition="bottom"
|
||||
/>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<div class="base-white-page-content">
|
||||
<slot></slot>
|
||||
@@ -54,19 +55,17 @@
|
||||
<script setup lang="ts">
|
||||
import useLanguage from '@/hooks/useLanguage'
|
||||
import { languageOptions, Languages } from 'gl-web-main'
|
||||
import BaseStep from './baseStep.vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
import { BaseDropdownSelect } from 'gl-web-main/components'
|
||||
import { isForeignEnv } from '@/utils'
|
||||
|
||||
const route = useRoute()
|
||||
import { useAppStore } from '@/stores/modules/app'
|
||||
|
||||
const { currentLang, currentLangLabel } = useLanguage()
|
||||
|
||||
const changeLang = (key: Languages) => {
|
||||
useLanguage().setLanguage(key)
|
||||
}
|
||||
|
||||
const appStore = useAppStore()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -85,6 +84,8 @@ const changeLang = (key: Languages) => {
|
||||
border-bottom: 1px solid var(--gl-color-line-divider1);
|
||||
|
||||
.base-white-page-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.language-box {
|
||||
color: var(--gl-color-text-level2);
|
||||
user-select: none;
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* @Author: LPY
|
||||
* @Date: 2025-05-30 10:18:18
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2025-06-25 10:03:27
|
||||
* @FilePath: /kvm-cloud-frontend/src/hooks/useLocalStorage.ts
|
||||
* @LastEditTime: 2026-01-05 14:22:12
|
||||
* @FilePath: \glkvm-cloud\ui\src\hooks\useLocalStorage.ts
|
||||
* @Description: 存储hook
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
@@ -18,6 +18,8 @@ export enum LocalStorageKeys {
|
||||
TWO_FACTOR_INFO_KEY = 'two-factor-info',
|
||||
/** 侧边栏手动控制展开收缩状态 */
|
||||
SIDEBAR_MANUAL_CONTROL_KEY = 'sidebar-manual-control',
|
||||
/** 版本号 */
|
||||
VERSION = 'version',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* @Author: LPY
|
||||
* @Date: 2025-05-30 09:37:06
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2025-06-13 15:55:51
|
||||
* @FilePath: /kvm-cloud-frontend/src/stores/modules/app.ts
|
||||
* @LastEditTime: 2026-01-05 14:35:02
|
||||
* @FilePath: \glkvm-cloud\ui\src\stores\modules\app.ts
|
||||
* @Description: app相关状态存储
|
||||
*/
|
||||
import { LocalStorageKeys, useLocalStorage } from '@/hooks/useLocalStorage'
|
||||
@@ -24,6 +24,8 @@ export const useAppStore = defineStore('appGlobal', () => {
|
||||
const state = reactive({
|
||||
/** 当前的主题模式 */
|
||||
themeMode: getThemeFromStorage(),
|
||||
/** 版本号 */
|
||||
version: (useLocalStorage(LocalStorageKeys.VERSION).getValue() as string)?.toUpperCase() || 'V1.0.0',
|
||||
})
|
||||
|
||||
/** 获取主题模式 */
|
||||
@@ -114,6 +116,11 @@ export const useAppStore = defineStore('appGlobal', () => {
|
||||
sidebar.manualSetting = false
|
||||
}
|
||||
|
||||
/** 设置版本号 */
|
||||
const setVersion = (version: string) =>{
|
||||
state.version = version.toUpperCase()
|
||||
useLocalStorage(LocalStorageKeys.VERSION).setValue(version)
|
||||
}
|
||||
|
||||
return { antdTheme, setThemeMode, state, sidebar, isCollapse, manualToggleSidebar, autoCloseSidebar, autoOpenSidebar, resetManualSetting }
|
||||
return { antdTheme, setThemeMode, state, sidebar, isCollapse, manualToggleSidebar, autoCloseSidebar, autoOpenSidebar, resetManualSetting, setVersion }
|
||||
})
|
||||
@@ -2,8 +2,8 @@
|
||||
* @Author: LPY
|
||||
* @Date: 2025-05-30 15:21:14
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2025-08-26 17:57:09
|
||||
* @FilePath: \glkvm-cloud\web-ui\src\views\layout\layHeader\layHeader.vue
|
||||
* @LastEditTime: 2026-01-05 14:32:56
|
||||
* @FilePath: \glkvm-cloud\ui\src\views\layout\layHeader\layHeader.vue
|
||||
* @Description: 顶部集成页
|
||||
-->
|
||||
<template>
|
||||
@@ -12,6 +12,8 @@
|
||||
<img src="@/assets/svg/logo.svg" height="20">
|
||||
</div>
|
||||
<div class="lay-header-right">
|
||||
<!-- version -->
|
||||
<BaseText style="margin-right: 24px;">{{ appStore.state.version || '--' }}</BaseText>
|
||||
<!-- github -->
|
||||
<ATooltip>
|
||||
<template #title>{{ githubLink }}</template>
|
||||
@@ -38,9 +40,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAppStore } from '@/stores/modules/app'
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// github链接
|
||||
const githubLink = 'https://github.com/gl-inet/glkvm-cloud'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: LPY
|
||||
* @Date: 2025-05-30 10:48:43
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2025-11-12 17:01:59
|
||||
* @LastEditTime: 2026-01-05 14:25:45
|
||||
* @FilePath: \glkvm-cloud\ui\src\views\login\loginPage.vue
|
||||
* @Description: 登录页面
|
||||
-->
|
||||
@@ -103,6 +103,7 @@ import { useRouter } from 'vue-router'
|
||||
import { LoginParams, AuthConfig } from '@/models/user'
|
||||
import { message, Input, Form } from 'ant-design-vue'
|
||||
import { reqAuthConfig } from '@/api/user'
|
||||
import { useAppStore } from '@/stores/modules/app'
|
||||
|
||||
const AInput = Input
|
||||
const AForm = Form
|
||||
@@ -153,7 +154,7 @@ onMounted(async () => {
|
||||
// 提取配置数据 (Extract config data)
|
||||
const configData = response?.info || response?.data?.info || response?.data || response
|
||||
authConfig.value = configData
|
||||
|
||||
useAppStore().setVersion(configData.kvmCloudVersion)
|
||||
} catch (error) {
|
||||
console.error('Failed to load auth config:', error)
|
||||
// 回退 - 无LDAP可用 (Fallback - no LDAP available)
|
||||
|
||||
Reference in New Issue
Block a user