35 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
GL.iNet-Yongping.Xie 8703f91ebf feat: support configurable device access domain in proxy mode
Allow device remote access to use a different root domain from the Web UI when
running behind a reverse proxy.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-01-06 23:13:39 -08:00
GL.iNet-Yongping.Xie 1c473458cf Merge remote-tracking branch 'origin/dev-ui-0105' into feature/version 2026-01-05 18:41:05 -08:00
GL.iNet-Yongping.Xie c6c09dbae1 feat: show version in GUI
Display the current application version in the GUI.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-01-05 18:40:03 -08:00
GL.iNet-Yongping.Xie 9258aa5f43 feat: show version in GUI
Display the current application version in the GUI.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-01-05 18:36:16 -08:00
pengyu.lu 8994cccb25 feat: Add the display of version numbers 2026-01-05 14:36:34 +08:00
GL.iNet-Yongping.Xie 60994b9513 fix: update documentation formatting
Update documentation formatting.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-12-29 00:22:03 -08:00
GL.iNet-Yongping.Xie c99b96ca01 Merge branch 'arm64' 2025-12-29 00:06:08 -08:00
GL.iNet-Yongping.Xie 27792291ed feat: add arm64 platform support
Add support for the arm64 platform and update the documentation
to reflect arm64-specific installation and configuration steps.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-12-29 00:05:49 -08:00
GL.iNet-Yongping.Xie 4adb10f577 fix: adjust reverse proxy port detection logic
Refine how the front-end proxy port is detected when running
behind Nginx to ensure correct redirect URL generation.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-12-25 20:15:52 -08:00
GL.iNet-Yongping.Xie cd231e996b fix: clarify reverse proxy mode configuration
Add brief comments explaining the reverse proxy mode switch.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-12-24 19:12:07 -08:00
GL.iNet-Yongping.Xie 739aa235b3 feat: update reverse proxy mode documentation
Update and clarify the documentation for reverse proxy mode, including usage guidelines and deployment considerations when running behind front-end proxies.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-12-22 01:46:51 -08:00
GL.iNet-Yongping.Xie 329468bf61 feat: add reverse proxy mode support
Introduce a reverse proxy mode to better integrate with front-end proxies such as Nginx.
In this mode, both port-based access to GLKVM Cloud and direct web access to device UIs are supported simultaneously, improving deployment flexibility behind proxies.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-12-22 01:22:16 -08:00
GL.iNet-Yongping.Xie f955f4f46f fix: remove unused files
Remove unused files to keep the codebase clean and maintainable.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-12-10 00:02:17 -08:00
GL.iNet-Yongping.Xie f34e7f3195 fix: prevent nil pointer dereference
Fix a nil pointer dereference issue to avoid unexpected crashes.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-12-10 00:00:18 -08:00
GL.iNet-Yongping.Xie afd4b19981 fix: prevent nil pointer dereference
Fix a nil pointer dereference issue to avoid unexpected crashes.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-12-09 23:59:41 -08:00
GL.iNet-Yongping.Xie f76e587ed2 Merge branch 'dev' 2025-12-09 23:38:57 -08:00
GL.iNet-Yongping.Xie 5f8d8f50ca fix: preserve device description on reconnect
Fix an issue where the device description was unintentionally reset
when a device came online and updated its metadata.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-12-09 23:32:04 -08:00
GL.iNet-Yongping.Xie 96aa0be17d fix: preserve device description on reconnect
Fix an issue where the device description was unintentionally reset
when a device came online and updated its metadata.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-12-09 23:25:53 -08:00
GL.iNet-Yongping.Xie f665ecf432 fix: preserve SQLite data across container restarts
Ensure the SQLite database file is not dropped or recreated on startup,
so existing data is retained when the container restarts.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-12-09 23:09:29 -08:00
GL.iNet-Yongping.Xie 4991433901 feat: prioritize online devices in device list
Add sorting logic to list online devices before offline ones,
while keeping alphabetical order by device ID within each group.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-12-09 22:50:39 -08:00
pengyu.lu 7ed0e263d1 feat: Add the function of deleting devices 2025-12-10 14:24:14 +08:00
GL.iNet-Yongping.Xie 38b35ed80f feat: add device deletion endpoint
Add a new API endpoint to delete device metadata by device_id.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-12-09 20:24:32 -08:00
pengyu.lu 61172cc9c4 feat: Optimize the UI and add the function of editing descriptions 2025-12-10 11:43:37 +08:00
GL.iNet-Yongping.Xie 0d1d2b0adf feat: add device online/offline status management
1. Add support for device online and offline status detection based on in-memory connections.
2. Enhance the device list to support updating and displaying device descriptions for easier management.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-12-09 02:24:13 -08:00
GL.iNet-Yongping.Xie c2318c1291 fix: update Chinese README documentation
Improve the Chinese version of the installation guide by updating the Docker-based installation instructions to align with the latest deployment workflow and configuration format.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-12-03 17:57:27 -08:00
GL.iNet-Yongping.Xie 0e5af6fa62 fix: update OIDC login documentation
1. Updated the OIDC login documentation to provide clearer and more
   accurate instructions for configuration and usage.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-11-23 17:47:28 -08:00
GL.iNet-Yongping.Xie 72f1986309 Merge branch 'dev' 2025-11-12 23:48:34 -08:00
GL.iNet-Yongping.Xie e9e773408d docs: update README with OIDC feature support details
1. Updated the README documentation to include clearer and more complete
   information about OIDC feature support.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-11-12 23:47:27 -08:00
GL.iNet-Yongping.Xie f6f06582dd feat: add whitelist support for subid and group
1. Added whitelist functionality for subid, group, and related fields to
   allow finer-grained access control and improved authentication
   flexibility.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-11-12 23:08:45 -08:00
GL.iNet-Yongping.Xie 1ab54dae68 feat: add token signature verification logic
1. Implemented token signature verification to ensure the integrity and
   authenticity of tokens before processing authentication requests.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-11-12 18:51:34 -08:00
GL.iNet-Yongping.Xie d9a66add94 feat: switch code comments from Chinese to English
1. Converted all code comments from Chinese to English to improve
   readability and better support international and English-speaking
   users.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-11-12 17:53:48 -08:00
GL.iNet-Yongping.Xie de48a9041e feat: add OIDC whitelist feature
1. Added OIDC whitelist functionality to allow specific users or domains
   to bypass standard authentication checks for controlled access.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-11-12 03:19:33 -08:00
GL.iNet-Yongping.Xie a31607ad09 feat: add support for custom OIDC login flow
Added support for a customizable OIDC login flow. Verified successful authentication using Google as the OIDC provider.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2025-11-12 01:54:04 -08:00
pengyu.lu a9711565cd feat: Add the function of logging in with OIDC 2025-11-12 17:51:54 +08:00
39 changed files with 3031 additions and 528 deletions
+4 -1
View File
@@ -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"]
+84 -17
View File
@@ -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
+33 -2
View File
@@ -15,7 +15,10 @@ Self-Deployed Lightweight Cloud is a lightweight KVM remote cloud platform tailo
- **Data Security** - Private deployment with full data control
- **Dedicated Bandwidth** - Exclusive bandwidth for self-hosted deployments
- **Lightweight Design** - Optimized for small businesses and individual users
- **Enterprise Authentication** - LDAP login support for enterprise 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
@@ -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
@@ -182,6 +189,30 @@ For detailed LDAP configuration options and setup instructions, see the [Docker
- **LDAP Authentication**: Enter username and password for directory service authentication
- **Legacy Authentication**: Leave username empty and use the web management password
#### 🔐 OIDC Authentication Configuration (Optional)
GLKVM Cloud provides full support for **OIDC (OpenID Connect)** authentication, allowing seamless integration with modern identity providers such as **Google, Auth0, Authing** and any other standard-compliant OIDC provider.
**Key Features**
- **Modern Authentication**
Secure sign-in through any OIDC provider supporting Authorization Code Flow.
- **Email / Username / Group Whitelisting**
Restrict access based on:
- Email or domain (e.g. *@example.com*)
- Stable user ID (*sub*)
- Username (*preferred_username* or *name*)
- Groups attribute
- **Full OpenID Connect Compliance**
Supports issuer validation, token signature verification, and nonce protection.
- **Flexible Provider Support**
Works with public clouds (Google, Azure AD, Auth0, Okta) and self-hosted solutions.
**Configuration**
For detailed OIDC configuration options and setup instructions, see the
**[Docker Compose README](docker-compose/README.md)**.
#### 🔄 Restart Services After Configuration Changes
After replacing certificates or updating LDAP configuration, restart the GLKVM Cloud services to apply the changes:
+39 -1
View File
@@ -16,7 +16,10 @@
* **数据安全** - 私有化部署,数据完全可控
* **独享带宽** - 自部署环境下可享受专属带宽
* **轻量设计** - 专为小型企业和个人优化
* **企业级认证** - 支持 LDAP 登录,适用于企业用户
* **企业级认证** - 同时支持 **LDAP** 和 **OIDC** 登录方式,适用于企业用户。
- **部署方式** - 同时支持 **内网部署** 和 **公网部署**
- **平台兼容性** - 同时支持 **x86_64** 和 **arm64** 平台
## 自部署指南
@@ -59,12 +62,27 @@
## 📦 安装
我们提供 **两种** 安装 GLKVM Cloud 的方式:
#### A) 一键安装脚本(推荐,仅支持 x86_64 / amd64)
> **注意:** 一键安装脚本基于 **Docker**。它会自动完成 Docker / Docker Compose 的安装、拉取镜像、根据模板渲染配置文件,并启动所有服务。
>
> **平台支持:** 当前仅支持 **x86_64(amd64)** 平台。
使用 **root 权限** 运行以下命令安装 GLKVM 轻量云:
```bash
( command -v curl >/dev/null 2>&1 && curl -fsSL https://kvm-cloud.gl-inet.com/selfhost/install.sh || wget -qO- https://kvm-cloud.gl-inet.com/selfhost/install.sh ) | sudo bash
```
#### 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) 平台。
### 🌐 平台访问
安装完成后,你可以通过以下方式访问平台:
@@ -155,6 +173,7 @@ Web UI 的默认登录密码会在安装脚本运行结束时显示:
GLKVM 轻量云支持 LDAP 身份认证,适用于企业环境,可以与现有的目录服务(如 Active Directory、OpenLDAP 或 FreeIPA)集成。
**主要功能:**
- **双重认证模式**:同时支持 LDAP 和传统密码认证
- **基于组的授权**:限制特定 LDAP 组访问
- **基于用户的授权**:仅允许特定用户访问
@@ -168,6 +187,25 @@ GLKVM 轻量云支持 LDAP 身份认证,适用于企业环境,可以与现
- **LDAP 认证**:输入用户名和密码进行目录服务认证
- **传统认证**:留空用户名并使用 Web 管理密码
#### 🔐 OIDC 登录认证配置(可选)
GLKVM Cloud 完整支持 **OIDC(OpenID Connect)** 登录认证,可无缝集成现代身份提供商,例如 **Google、Auth0、Authing**,以及任何符合 OIDC 标准的认证服务。
**主要功能**
- **现代化认证方式**
支持使用任意支持 Authorization Code Flow 的 OIDC 身份提供商进行安全登录。
- **邮箱 / 用户名 / 用户组白名单控制**
可根据以下信息限制用户访问:
- 邮箱或域名(如 *@example.com*)
- 用户 ID(*sub*)
- 用户名(*preferred_username* 或 *name*)
- 用户组
- **完全符合 OpenID Connect 标准**
支持 Issuer 校验、ID Token 签名验证、Nonce 防重放保护等安全机制。
- **高度灵活的提供商支持**
兼容各类公共云 IdP(Google、Azure AD、Auth0、Okta 等)以及自建身份服务(Keycloak、Authentik、Dex 等)。
#### 🔄 配置更改后重启服务
替换证书或更新 LDAP 配置后,需要重启 GLKVM 轻量云服务以应用更改:
+270 -74
View File
@@ -31,6 +31,7 @@ import (
"net"
"net/http"
"path"
"sort"
"strings"
"time"
@@ -55,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 {
@@ -130,32 +141,203 @@ func (srv *RttyServer) ListenAPI() error {
authorized.GET("/devs", func(c *gin.Context) {
devs := make([]*DeviceInfo, 0)
g := srv.GetGroup(c.Query("group"), false)
keyword := c.Query("keyword")
if g == nil {
// 1. Query all device metadata from DB (offline + online)
metas, err := GetAllDeviceMeta(keyword)
if err != nil || len(metas) == 0 {
c.JSON(http.StatusOK, devs)
return
}
g.devices.Range(func(key, value any) bool {
dev := value.(*Device)
// 2. Build online device map from memory
onlineMap := make(map[string]*Device)
devs = append(devs, &DeviceInfo{
Group: dev.group,
ID: dev.id,
Desc: dev.desc,
Connected: uint32(time.Now().Unix() - dev.timestamp),
Uptime: dev.uptime,
Proto: dev.proto,
IPaddr: dev.conn.RemoteAddr().(*net.TCPAddr).IP.String(),
g := srv.GetGroup("", false)
if g != nil {
g.devices.Range(func(key, value any) bool {
dev := value.(*Device)
onlineMap[dev.id] = dev
return true
})
}
return true
now := time.Now().Unix()
// 3. Iterate metas (DB is the source of truth)
for _, meta := range metas {
info := &DeviceInfo{
ID: meta.DeviceID,
Mac: meta.Mac,
Connected: 0,
Uptime: 0,
Desc: meta.Description,
Proto: 0,
IPaddr: meta.IP, // fallback: last known IP
}
// 4. If device is online, override with in-memory data
if dev, ok := onlineMap[meta.DeviceID]; ok {
info.Connected = uint32(now - dev.timestamp)
info.Uptime = dev.uptime
info.Proto = dev.proto
if addr, ok := dev.conn.RemoteAddr().(*net.TCPAddr); ok {
info.IPaddr = addr.IP.String()
} else if host, _, err := net.SplitHostPort(dev.conn.RemoteAddr().String()); err == nil {
info.IPaddr = host
}
}
devs = append(devs, info)
}
// Sort devices:
// 1. Online devices first (Connected > 0)
// 2. Within the same online/offline group, sort by device ID alphabetically
sort.Slice(devs, func(i, j int) bool {
di := devs[i]
dj := devs[j]
// Determine online status
diOnline := di.Connected > 0
djOnline := dj.Connected > 0
if diOnline != djOnline {
return diOnline
}
// If both devices are in the same state (online or offline),
// sort by device ID in ascending alphabetical order
return di.ID < dj.ID
})
c.JSON(http.StatusOK, devs)
})
// UpdateDeviceMetaRequest defines the JSON payload to update device metadata.
// Only DeviceID is mandatory; other fields are optional and will be updated
// only when provided.
type UpdateDeviceMetaRequest struct {
DeviceID string `json:"deviceId" binding:"required"` // DeviceID is the unique device identifier (immutable).
Description string `json:"description,omitempty"` // Description can be updated if provided.
}
// Update device metadata (new interface)
authorized.POST("/devs/update", func(c *gin.Context) {
var req UpdateDeviceMetaRequest
// 1. Parse JSON body
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid request body",
"err": err.Error(),
})
return
}
// 2. Load existing metadata by device_id
meta, err := GetDeviceMetaByDeviceID(req.DeviceID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"msg": "failed to query device meta",
"err": err.Error(),
})
return
}
if meta == nil {
c.JSON(http.StatusNotFound, gin.H{
"code": 404,
"msg": "device meta not found",
})
return
}
// 3. Merge data: deviceID/mac/ip, now only description
newDesc := meta.Description
if req.Description != "" {
newDesc = req.Description
}
// 4. Reuse SaveOrUpdateDeviceMeta for UPSERT
if err := SaveOrUpdateDeviceMeta(
meta.DeviceID, // keep original device_id
meta.Mac, // keep original MAC, not editable
newDesc, // new description from request
meta.IP,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"msg": "failed to update device meta",
"err": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "ok",
})
})
// DeleteDeviceMetaRequest is used to logically delete a device meta record.
// Only DeviceID is required.
type DeleteDeviceMetaRequest struct {
DeviceID string `json:"deviceId" binding:"required"` // DeviceID is the unique device identifier (immutable).
}
// Delete device metadata (physical delete)
authorized.POST("/devs/delete", func(c *gin.Context) {
var req DeleteDeviceMetaRequest
// 1. Parse JSON body
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "invalid request body",
"err": err.Error(),
})
return
}
// 2. Check existence first (optional but recommended)
meta, err := GetDeviceMetaByDeviceID(req.DeviceID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"msg": "failed to query device meta",
"err": err.Error(),
})
return
}
if meta == nil {
c.JSON(http.StatusNotFound, gin.H{
"code": 404,
"msg": "device meta not found",
})
return
}
// 3. Physical delete
if err := DeleteDeviceMetaByDeviceID(req.DeviceID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"msg": "failed to delete device meta",
"err": err.Error(),
})
return
}
// 4. Success response
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "ok",
})
})
authorized.GET("/dev/:devid", func(c *gin.Context) {
if dev := srv.GetDevice(c.Query("group"), c.Param("devid")); dev != nil {
info := &DeviceInfo{
@@ -215,65 +397,69 @@ func (srv *RttyServer) ListenAPI() error {
c.Status(http.StatusOK)
})
r.POST("/signin", func(c *gin.Context) {
type credentials struct {
Username string `json:"username"`
Password string `json:"password"`
AuthMethod string `json:"authMethod"`
}
r.POST("/signin", func(c *gin.Context) {
type credentials struct {
Username string `json:"username"`
Password string `json:"password"`
AuthMethod string `json:"authMethod"`
}
creds := credentials{}
creds := credentials{}
err := c.BindJSON(&creds)
if err != nil {
c.Status(http.StatusBadRequest)
return
}
err := c.BindJSON(&creds)
if err != nil {
c.Status(http.StatusBadRequest)
return
}
// 自动确定认证方法或使用指定的方法 (Auto-determine auth method or use specified method)
authMethod := creds.AuthMethod
if authMethod == "" {
// 基于是否提供用户名进行自动检测 (Auto-detect based on whether username is provided)
if creds.Username != "" && cfg.LdapEnabled {
authMethod = "ldap"
} else {
authMethod = "legacy"
}
}
// 自动确定认证方法或使用指定的方法 (Auto-determine auth method or use specified method)
authMethod := creds.AuthMethod
if authMethod == "" {
// 基于是否提供用户名进行自动检测 (Auto-detect based on whether username is provided)
if creds.Username != "" && cfg.LdapEnabled {
authMethod = "ldap"
} else {
authMethod = "legacy"
}
}
success, errorType := AuthenticateUserWithError(cfg, creds.Username, creds.Password, authMethod)
if success {
sid := utils.GenUniqueID()
httpSessions.Set(sid, true, cache.WithEx(httpSessionExpire))
c.SetCookie("sid", sid, 0, "", "", false, true)
c.Status(http.StatusOK)
return
}
success, errorType := AuthenticateUserWithError(cfg, creds.Username, creds.Password, authMethod)
if success {
sid := utils.GenUniqueID()
httpSessions.Set(sid, true, cache.WithEx(httpSessionExpire))
c.SetCookie("sid", sid, 0, "", "", false, true)
c.Status(http.StatusOK)
return
}
// 根据错误类型返回适当的错误信息 (Return appropriate error message based on error type)
if errorType == "authorization" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authorized"})
} else {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication failed"})
}
})
// 根据错误类型返回适当的错误信息 (Return appropriate error message based on error type)
if errorType == "authorization" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authorized"})
} else {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication failed"})
}
})
r.GET("/auth-config", func(c *gin.Context) {
authConfig := gin.H{
"ldapEnabled": cfg.LdapEnabled,
"legacyPassword": cfg.Password != "",
}
c.JSON(http.StatusOK, authConfig)
})
r.GET("/auth-config", func(c *gin.Context) {
authConfig := gin.H{
"ldapEnabled": cfg.LdapEnabled,
"legacyPassword": cfg.Password != "",
"oidcEnabled": cfg.OIDCEnabled,
"kvmCloudVersion": KVMCloudVersion,
}
c.JSON(http.StatusOK, authConfig)
})
r.GET("/alive", func(c *gin.Context) {
if !httpAuth(cfg, c) {
c.AbortWithStatus(http.StatusUnauthorized)
} else {
c.Status(http.StatusOK)
}
})
r.GET("/alive", func(c *gin.Context) {
if !httpAuth(cfg, c) {
c.AbortWithStatus(http.StatusUnauthorized)
} else {
c.Status(http.StatusOK)
}
})
// ===== 添加OIDC路由 =====
RegisterOIDCRoutes(r, cfg)
fs, err := fs.Sub(staticFs, "ui/dist")
if err != nil {
return err
@@ -323,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{
@@ -349,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())
+299 -136
View File
@@ -25,171 +25,334 @@
package main
import (
"fmt"
"os"
"strconv"
"fmt"
"os"
"strconv"
"strings"
"github.com/kylelemons/go-gypsy/yaml"
"github.com/urfave/cli/v3"
"github.com/kylelemons/go-gypsy/yaml"
"github.com/urfave/cli/v3"
)
type Config struct {
AddrDev string
AddrUser string
AddrHttpProxy string
HttpProxyRedirURL string
HttpProxyRedirDomain string
Token string
DevHookUrl string
UserHookUrl string
LocalAuth bool
Password string
AllowOrigins bool
PprofAddr string
SslCert string
SslKey string
WebrtcIP string
WebrtcPort string
WebrtcUsername string
WebrtcPassword string
// LDAP配置 (LDAP Configuration)
LdapEnabled bool
LdapServer string
LdapPort int
LdapUseTLS bool
LdapBindDN string
LdapBindPassword string
LdapBaseDN string
LdapUserFilter string
LdapAllowedGroups string
LdapAllowedUsers string
AddrDev string
AddrUser string
AddrHttpProxy string
HttpProxyRedirURL string
HttpProxyRedirDomain string
Token string
DevHookUrl string
UserHookUrl string
LocalAuth bool
Password string
AllowOrigins bool
PprofAddr string
SslCert string
SslKey string
WebrtcIP string
WebrtcPort string
WebrtcUsername string
WebrtcPassword string
// // LDAP Configuration
LdapEnabled bool
LdapServer string
LdapPort int
LdapUseTLS bool
LdapBindDN string
LdapBindPassword string
LdapBaseDN string
LdapUserFilter string
LdapAllowedGroups string
LdapAllowedUsers string
// Generic OIDC Provider (supports any standard OIDC provider)
OIDCEnabled bool
OIDCGenericIssuer string
OIDCGenericClientID string
OIDCGenericClientSecret string
OIDCGenericAuthURL string
OIDCGenericTokenURL string
OIDCGenericRedirectURL string
OIDCGenericScopes []string
OIDCGenericAllowedUsers []string
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
const (
SslCert = "/home/certificate/glkvm_cer"
SslKey = "/home/certificate/glkvm_key"
SslCert = "/home/certificate/glkvm_cer"
SslKey = "/home/certificate/glkvm_key"
)
func (cfg *Config) Parse(c *cli.Command) error {
conf := c.String("conf")
if conf != "" {
err := parseYamlCfg(cfg, conf)
if err != nil {
return err
}
}
conf := c.String("conf")
if conf != "" {
err := parseYamlCfg(cfg, conf)
if err != nil {
return err
}
}
getFlagOpt(c, "addr-dev", &cfg.AddrDev)
getFlagOpt(c, "addr-user", &cfg.AddrUser)
getFlagOpt(c, "addr-http-proxy", &cfg.AddrHttpProxy)
getFlagOpt(c, "http-proxy-redir-url", &cfg.HttpProxyRedirURL)
getFlagOpt(c, "http-proxy-redir-domain", &cfg.HttpProxyRedirDomain)
getFlagOpt(c, "dev-hook-url", &cfg.DevHookUrl)
getFlagOpt(c, "user-hook-url", &cfg.UserHookUrl)
getFlagOpt(c, "local-auth", &cfg.LocalAuth)
getFlagOpt(c, "token", &cfg.Token)
getFlagOpt(c, "password", &cfg.Password)
getFlagOpt(c, "allow-origins", &cfg.AllowOrigins)
getFlagOpt(c, "pprof", &cfg.PprofAddr)
getFlagOpt(c, "addr-dev", &cfg.AddrDev)
getFlagOpt(c, "addr-user", &cfg.AddrUser)
getFlagOpt(c, "addr-http-proxy", &cfg.AddrHttpProxy)
getFlagOpt(c, "http-proxy-redir-url", &cfg.HttpProxyRedirURL)
getFlagOpt(c, "http-proxy-redir-domain", &cfg.HttpProxyRedirDomain)
getFlagOpt(c, "dev-hook-url", &cfg.DevHookUrl)
getFlagOpt(c, "user-hook-url", &cfg.UserHookUrl)
getFlagOpt(c, "local-auth", &cfg.LocalAuth)
getFlagOpt(c, "token", &cfg.Token)
getFlagOpt(c, "password", &cfg.Password)
getFlagOpt(c, "allow-origins", &cfg.AllowOrigins)
getFlagOpt(c, "pprof", &cfg.PprofAddr)
cfg.SslCert = SslCert
cfg.SslKey = SslKey
cfg.SslCert = SslCert
cfg.SslKey = SslKey
getFlagOpt(c, "webrtc-ip", &cfg.WebrtcIP)
getFlagOpt(c, "webrtc-port", &cfg.WebrtcPort)
getFlagOpt(c, "webrtc-username", &cfg.WebrtcUsername)
getFlagOpt(c, "webrtc-password", &cfg.WebrtcPassword)
getFlagOpt(c, "webrtc-ip", &cfg.WebrtcIP)
getFlagOpt(c, "webrtc-port", &cfg.WebrtcPort)
getFlagOpt(c, "webrtc-username", &cfg.WebrtcUsername)
getFlagOpt(c, "webrtc-password", &cfg.WebrtcPassword)
// LDAP配置标志 (LDAP Configuration flags)
getFlagOpt(c, "ldap-enabled", &cfg.LdapEnabled)
getFlagOpt(c, "ldap-server", &cfg.LdapServer)
getFlagOpt(c, "ldap-port", &cfg.LdapPort)
getFlagOpt(c, "ldap-use-tls", &cfg.LdapUseTLS)
getFlagOpt(c, "ldap-bind-dn", &cfg.LdapBindDN)
getFlagOpt(c, "ldap-bind-password", &cfg.LdapBindPassword)
getFlagOpt(c, "ldap-base-dn", &cfg.LdapBaseDN)
getFlagOpt(c, "ldap-user-filter", &cfg.LdapUserFilter)
getFlagOpt(c, "ldap-allowed-groups", &cfg.LdapAllowedGroups)
getFlagOpt(c, "ldap-allowed-users", &cfg.LdapAllowedUsers)
// LDAP configuration flags
getFlagOpt(c, "ldap-enabled", &cfg.LdapEnabled)
getFlagOpt(c, "ldap-server", &cfg.LdapServer)
getFlagOpt(c, "ldap-port", &cfg.LdapPort)
getFlagOpt(c, "ldap-use-tls", &cfg.LdapUseTLS)
getFlagOpt(c, "ldap-bind-dn", &cfg.LdapBindDN)
getFlagOpt(c, "ldap-bind-password", &cfg.LdapBindPassword)
getFlagOpt(c, "ldap-base-dn", &cfg.LdapBaseDN)
getFlagOpt(c, "ldap-user-filter", &cfg.LdapUserFilter)
getFlagOpt(c, "ldap-allowed-groups", &cfg.LdapAllowedGroups)
getFlagOpt(c, "ldap-allowed-users", &cfg.LdapAllowedUsers)
return nil
// Generic OIDC Provider (standard OIDC provider flags)
getFlagOpt(c, "oidc-enabled", &cfg.OIDCEnabled)
getFlagOpt(c, "oidc-generic-issuer", &cfg.OIDCGenericIssuer)
getFlagOpt(c, "oidc-generic-client-id", &cfg.OIDCGenericClientID)
getFlagOpt(c, "oidc-generic-client-secret", &cfg.OIDCGenericClientSecret)
getFlagOpt(c, "oidc-generic-auth-url", &cfg.OIDCGenericAuthURL)
getFlagOpt(c, "oidc-generic-token-url", &cfg.OIDCGenericTokenURL)
getFlagOpt(c, "oidc-generic-redirect-url", &cfg.OIDCGenericRedirectURL)
getFlagOpt(c, "oidc-generic-scopes", &cfg.OIDCGenericScopes)
getFlagOpt(c, "oidc-generic-allowed-users", &cfg.OIDCGenericAllowedUsers)
getFlagOpt(c, "oidc-generic-allowed-subs", &cfg.OIDCGenericAllowedSubs)
getFlagOpt(c, "oidc-generic-allowed-usernames", &cfg.OIDCGenericAllowedUsernames)
getFlagOpt(c, "oidc-generic-allowed-groups", &cfg.OIDCGenericAllowedGroups)
return nil
}
func getConfigOpt(yamlCfg *yaml.File, name string, opt any) {
val, err := yamlCfg.Get(name)
if err != nil {
return
}
val, err := yamlCfg.Get(name)
if err != nil {
return
}
switch opt := opt.(type) {
case *string:
*opt = val
case *int:
*opt, _ = strconv.Atoi(val)
case *bool:
*opt, _ = strconv.ParseBool(val)
}
switch opt := opt.(type) {
case *string:
*opt = val
case *int:
*opt, _ = strconv.Atoi(val)
case *bool:
*opt, _ = strconv.ParseBool(val)
}
}
func parseYamlCfg(cfg *Config, conf string) error {
yamlCfg, err := yaml.ReadFile(conf)
if err != nil {
return fmt.Errorf(`read config file: %s`, err.Error())
}
yamlCfg, err := yaml.ReadFile(conf)
if err != nil {
return fmt.Errorf(`read config file: %s`, err.Error())
}
getConfigOpt(yamlCfg, "addr-dev", &cfg.AddrDev)
getConfigOpt(yamlCfg, "addr-user", &cfg.AddrUser)
getConfigOpt(yamlCfg, "addr-http-proxy", &cfg.AddrHttpProxy)
getConfigOpt(yamlCfg, "http-proxy-redir-url", &cfg.HttpProxyRedirURL)
getConfigOpt(yamlCfg, "http-proxy-redir-domain", &cfg.HttpProxyRedirDomain)
getConfigOpt(yamlCfg, "addr-dev", &cfg.AddrDev)
getConfigOpt(yamlCfg, "addr-user", &cfg.AddrUser)
getConfigOpt(yamlCfg, "addr-http-proxy", &cfg.AddrHttpProxy)
getConfigOpt(yamlCfg, "http-proxy-redir-url", &cfg.HttpProxyRedirURL)
getConfigOpt(yamlCfg, "http-proxy-redir-domain", &cfg.HttpProxyRedirDomain)
getConfigOpt(yamlCfg, "token", &cfg.Token)
getConfigOpt(yamlCfg, "dev-hook-url", &cfg.DevHookUrl)
getConfigOpt(yamlCfg, "user-hook-url", &cfg.UserHookUrl)
getConfigOpt(yamlCfg, "local-auth", &cfg.LocalAuth)
getConfigOpt(yamlCfg, "password", &cfg.Password)
getConfigOpt(yamlCfg, "allow-origins", &cfg.AllowOrigins)
getConfigOpt(yamlCfg, "token", &cfg.Token)
getConfigOpt(yamlCfg, "dev-hook-url", &cfg.DevHookUrl)
getConfigOpt(yamlCfg, "user-hook-url", &cfg.UserHookUrl)
getConfigOpt(yamlCfg, "local-auth", &cfg.LocalAuth)
getConfigOpt(yamlCfg, "password", &cfg.Password)
getConfigOpt(yamlCfg, "allow-origins", &cfg.AllowOrigins)
getConfigOpt(yamlCfg, "webrtc-ip", &cfg.WebrtcIP)
getConfigOpt(yamlCfg, "webrtc-port", &cfg.WebrtcPort)
getConfigOpt(yamlCfg, "webrtc-username", &cfg.WebrtcUsername)
getConfigOpt(yamlCfg, "webrtc-password", &cfg.WebrtcPassword)
getConfigOpt(yamlCfg, "webrtc-ip", &cfg.WebrtcIP)
getConfigOpt(yamlCfg, "webrtc-port", &cfg.WebrtcPort)
getConfigOpt(yamlCfg, "webrtc-username", &cfg.WebrtcUsername)
getConfigOpt(yamlCfg, "webrtc-password", &cfg.WebrtcPassword)
// LDAP配置 (LDAP Configuration)
getConfigOpt(yamlCfg, "ldap-enabled", &cfg.LdapEnabled)
getConfigOpt(yamlCfg, "ldap-server", &cfg.LdapServer)
getConfigOpt(yamlCfg, "ldap-port", &cfg.LdapPort)
getConfigOpt(yamlCfg, "ldap-use-tls", &cfg.LdapUseTLS)
getConfigOpt(yamlCfg, "ldap-bind-dn", &cfg.LdapBindDN)
// 注意:为了避免特殊字符解析问题和提高安全性,ldap-bind-password故意不从YAML读取
// Note: ldap-bind-password is intentionally not read from YAML to avoid special character parsing issues and for security.
// It's always read directly from the LDAP_BIND_PASSWORD environment variable below
getConfigOpt(yamlCfg, "ldap-base-dn", &cfg.LdapBaseDN)
getConfigOpt(yamlCfg, "ldap-user-filter", &cfg.LdapUserFilter)
getConfigOpt(yamlCfg, "ldap-allowed-groups", &cfg.LdapAllowedGroups)
getConfigOpt(yamlCfg, "ldap-allowed-users", &cfg.LdapAllowedUsers)
// LDAP密码始终从环境变量读取以避免YAML特殊字符解析问题和提高安全性
// LDAP password is always read from environment variable to avoid YAML special character parsing issues and for security.
if envPassword := os.Getenv("LDAP_BIND_PASSWORD"); envPassword != "" {
cfg.LdapBindPassword = envPassword
}
return nil
// LDAP配置 (LDAP Configuration)
getConfigOpt(yamlCfg, "ldap-enabled", &cfg.LdapEnabled)
getConfigOpt(yamlCfg, "ldap-server", &cfg.LdapServer)
getConfigOpt(yamlCfg, "ldap-port", &cfg.LdapPort)
getConfigOpt(yamlCfg, "ldap-use-tls", &cfg.LdapUseTLS)
getConfigOpt(yamlCfg, "ldap-bind-dn", &cfg.LdapBindDN)
// Note: ldap-bind-password is intentionally not read from YAML to avoid special character parsing issues and for security.
// It's always read directly from the LDAP_BIND_PASSWORD environment variable below
getConfigOpt(yamlCfg, "ldap-base-dn", &cfg.LdapBaseDN)
getConfigOpt(yamlCfg, "ldap-user-filter", &cfg.LdapUserFilter)
getConfigOpt(yamlCfg, "ldap-allowed-groups", &cfg.LdapAllowedGroups)
getConfigOpt(yamlCfg, "ldap-allowed-users", &cfg.LdapAllowedUsers)
// LDAP password is always read from environment variable to avoid YAML special character parsing issues and for security.
if envPassword := os.Getenv("LDAP_BIND_PASSWORD"); envPassword != "" {
cfg.LdapBindPassword = envPassword
}
// ===== OIDC configuration (generic OIDC provider) =====
// Switch and basic endpoints
getConfigOpt(yamlCfg, "oidc-enabled", &cfg.OIDCEnabled)
getConfigOpt(yamlCfg, "oidc-generic-issuer", &cfg.OIDCGenericIssuer)
getConfigOpt(yamlCfg, "oidc-generic-client-id", &cfg.OIDCGenericClientID)
// Note: oidc-generic-client-secret is intentionally not read from YAML
// to avoid checking secrets into config files and leaking in logs.
// It is always read directly from the OIDC_CLIENT_SECRET environment variable below.
if envSecret := os.Getenv("OIDC_CLIENT_SECRET"); envSecret != "" {
cfg.OIDCGenericClientSecret = envSecret
}
getConfigOpt(yamlCfg, "oidc-generic-auth-url", &cfg.OIDCGenericAuthURL)
getConfigOpt(yamlCfg, "oidc-generic-token-url", &cfg.OIDCGenericTokenURL)
getConfigOpt(yamlCfg, "oidc-generic-redirect-url", &cfg.OIDCGenericRedirectURL)
// OIDC scopes (string can be space- or comma-separated, parsed by splitScopes)
if s, err := yamlCfg.Get("oidc-generic-scopes"); err == nil && strings.TrimSpace(s) != "" {
cfg.OIDCGenericScopes = splitScopes(s)
}
// Default scopes (when OIDC is enabled but scopes are still empty)
if cfg.OIDCEnabled && len(cfg.OIDCGenericScopes) == 0 {
cfg.OIDCGenericScopes = []string{"openid", "profile", "email"}
}
// Whitelists for OIDC logins, all parsed via splitScopes (space/comma/newline separated)
// 1) Email-based whitelist
if s, err := yamlCfg.Get("oidc-generic-allowed-users"); err == nil && strings.TrimSpace(s) != "" {
cfg.OIDCGenericAllowedUsers = splitScopes(s)
}
// 2) Subject (sub) whitelist
if s, err := yamlCfg.Get("oidc-generic-allowed-subs"); err == nil && strings.TrimSpace(s) != "" {
cfg.OIDCGenericAllowedSubs = splitScopes(s)
}
// 3) Username whitelist (preferred_username / name)
if s, err := yamlCfg.Get("oidc-generic-allowed-usernames"); err == nil && strings.TrimSpace(s) != "" {
cfg.OIDCGenericAllowedUsernames = splitScopes(s)
}
// 4) Groups whitelist
if s, err := yamlCfg.Get("oidc-generic-allowed-groups"); err == nil && strings.TrimSpace(s) != "" {
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
}
func getFlagOpt(c *cli.Command, name string, opt any) {
if !c.IsSet(name) {
return
}
if !c.IsSet(name) {
return
}
switch opt := opt.(type) {
case *string:
*opt = c.String(name)
case *int:
*opt = c.Int(name)
case *bool:
*opt = c.Bool(name)
}
switch opt := opt.(type) {
case *string:
*opt = c.String(name)
case *int:
*opt = c.Int(name)
case *bool:
*opt = c.Bool(name)
}
}
// splitScopes splits a whitespace/comma/newline-separated scope string
// into a deduplicated, cleaned string slice.
func splitScopes(s string) []string {
// Replace commas with spaces to unify delimiters
s = strings.ReplaceAll(s, ",", " ")
// Remove optional YAML list characters such as brackets (lenient parsing)
s = strings.NewReplacer("[", " ", "]", " ").Replace(s)
parts := strings.Fields(s)
uniq := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" || p == "-" { // Support accidental "-" items
continue
}
if _, ok := seen[p]; ok {
continue
}
seen[p] = struct{}{}
uniq = append(uniq, p)
}
return uniq
}
Executable
+16
View File
@@ -0,0 +1,16 @@
package db
// DeviceMeta represents a record in the gl_device table.
type DeviceMeta struct {
DeviceID string `gorm:"primaryKey;column:device_id"` // DeviceID is the globally unique and immutable ID of the device.
Mac string `gorm:"uniqueIndex;column:mac"` // Mac is the unique and immutable MAC address of the device.
IP string `gorm:"column:ip"` // IP is the current IP address of the device.
Description string `gorm:"column:description"` // Description is a human-readable description of the device.
CreateTime int64 `gorm:"column:create_time"` // CreateTime is the creation timestamp (Unix time).
UpdateTime int64 `gorm:"column:update_time"` // UpdateTime is the last update timestamp (Unix time).
}
// TableName sets the name of the table in the database that this struct binds to.
func (DeviceMeta) TableName() string {
return "devices"
}
Executable
+41
View File
@@ -0,0 +1,41 @@
package db
import (
"github.com/glebarez/sqlite"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
)
const dbFileName = "/home/database/glkvm-cloud.db"
var deviceDB *gorm.DB
// GetDbClient returns the database client instance.
func GetDbClient() *gorm.DB {
return deviceDB
}
// Init initializes the SQLite database connection and sets up logging.
func Init() {
// Open a SQLite database connection
db, err := gorm.Open(sqlite.Open(dbFileName), &gorm.Config{})
if err != nil {
log.Info().Msg(err.Error())
// Panic if the database connection fails
panic("failed to connect database")
}
// Set the global database client
deviceDB = db
// Auto-migrate the Device schema
err = db.AutoMigrate(&DeviceMeta{})
if err != nil {
// Panic if auto-migration fails
panic(err)
}
// Retrieve and log the initial data records
list := make([]DeviceMeta, 0)
db.Find(&list)
log.Info().Msgf("==== SQLite init done ====, data record:%d \n", len(list))
}
+24 -3
View File
@@ -47,8 +47,8 @@ import (
)
type DeviceInfo struct {
Group string `json:"group"`
ID string `json:"id"`
Mac string `json:"mac"`
Connected uint32 `json:"connected"`
Uptime uint32 `json:"uptime"`
Desc string `json:"description"`
@@ -228,8 +228,29 @@ func handleDeviceConnection(srv *RttyServer, conn net.Conn) {
return
}
log.Info().Msgf("device '%s' registered, group '%s' proto %d, heartbeat %v",
dev.id, dev.group, dev.proto, dev.heartbeat)
deviceRemoteIP := ""
if addr, ok := dev.conn.RemoteAddr().(*net.TCPAddr); ok {
deviceRemoteIP = addr.IP.String()
} else if host, _, err := net.SplitHostPort(dev.conn.RemoteAddr().String()); err == nil {
deviceRemoteIP = host
}
log.Info().Msgf("device '%s' registered, group '%s' proto %d, heartbeat %v, remoteIP '%s'",
dev.id, dev.group, dev.proto, dev.heartbeat, deviceRemoteIP)
// 2. Load existing metadata by device_id
description := ""
meta, err := GetDeviceMetaByDeviceID(dev.id)
if err == nil && meta != nil {
description = meta.Description
}
if err := SaveOrUpdateDeviceMeta(
dev.id,
dev.desc, // device register mac info with desc filed
description,
deviceRemoteIP,
); err != nil {
return
}
for {
conn.SetReadDeadline(time.Now().Add(dev.heartbeat * 3 / 2))
+125
View File
@@ -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=
+87 -1
View File
@@ -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=
@@ -36,3 +97,28 @@ 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=
+142
View File
@@ -0,0 +1,142 @@
# 快速开始(Quick Start)
本指南展示如何使用提供的 Docker Compose 环境模板部署 **glkvm-cloud**。
### 1. **克隆仓库并准备环境模板**
```bash
git clone https://github.com/gl-inet/glkvm-cloud.git
cd glkvm-cloud/docker-compose/
```
* **x86_64(amd64)平台**:
```bash
cp .env.example .env
```
* **arm64(AArch64)平台**:
```bash
cp .env.arm64.example .env
```
### 2. **配置环境变量**
编辑 `.env` 文件,并根据需求更新关键参数:
- `RTTYS_TOKEN`:设备连接令牌(留空则使用默认值)
- `RTTYS_PASS`:Web 管理密码(留空则使用默认值 **StrongP@ssw0rd**)
- `TURN_USER` / `TURN_PASS`:coturn 鉴权凭据(留空则使用默认值)
- `GLKVM_ACCESS_IP`:GLKVM Cloud 访问地址(留空则启动时自动检测)
#### **LDAP 认证(可选)**
- `LDAP_ENABLED`:设为 `true` 启用 LDAP(默认 `false`)
- `LDAP_SERVER`:LDAP 服务器域名或 IP
- `LDAP_PORT`:端口(默认 `389`,TLS 使用 `636`)
- `LDAP_USE_TLS`:设为 `true` 启用 TLS 加密(默认 `false`)
- `LDAP_BIND_DN`:服务账号 DN
- `LDAP_BIND_PASSWORD`:服务账号密码
- `LDAP_BASE_DN`:用户查询的 Base DN
- `LDAP_USER_FILTER`:用户查询过滤器(默认 `(uid=%s)`)
- `LDAP_ALLOWED_GROUPS`:允许访问的群组列表(可选)
- `LDAP_ALLOWED_USERS`:允许访问的用户列表(可选)
⚠️ **注意:所有配置均需在 `.env` 中完成,不需要修改 `docker-compose.yml`、模板或脚本。**
#### **OIDC 认证(可选)**
- `OIDC_ENABLED`:设为 `true` 启用 OIDC(默认 `false`)
- `OIDC_ISSUER`:OIDC Issuer 地址
示例:`https://accounts.google.com`、`https://your-tenant.auth0.com/`
- `OIDC_CLIENT_ID`:OIDC 客户端 ID
- `OIDC_CLIENT_SECRET`:OIDC 客户端密钥
- `OIDC_AUTH_URL`:授权端点 URL
- `OIDC_TOKEN_URL`:令牌端点 URL
- `OIDC_REDIRECT_URL`:OIDC 回调地址
域名可自定义,但路径必须为 `/auth/oidc/callback`
示例:`https://your-domain.example.com/auth/oidc/callback`
- `OIDC_SCOPES`:请求的 OIDC Scope(默认 `"openid profile email"`)
- `OIDC_ALLOWED_USERS`:允许的邮箱或域(可选)
示例:`user@example.com,@example.com`
- `OIDC_ALLOWED_SUBS`:允许的 OIDC `sub` ID 列表(可选)
- `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. **启动服务**
```bash
docker-compose up -d
```
如果你修改了 `.env` 或模板文件,请重新加载服务:
```bash
docker-compose down && docker-compose up -d
```
### 4. **访问平台**
安装完成后,通过以下地址访问平台:
```bash
https://<你的服务器公网 IP>
```
+93 -2
View File
@@ -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**
@@ -20,7 +28,7 @@
- `GLKVM_ACCESS_IP`: glkvm cloud access address (leave empty to auto-detect at startup)
**LDAP Authentication (Optional):**
- `LDAP_ENABLED`: set to `true` to enable LDAP authentication (default: `false`)
- `LDAP_SERVER`: LDAP server hostname or IP address
- `LDAP_PORT`: LDAP server port (default: `389`, for TLS use `636`)
@@ -35,6 +43,89 @@
⚠️ **Note:** All configuration should be done in the `.env` file.
You don’t need to modify `docker-compose.yml`, templates, or scripts directly.
**OIDC Authentication (Optional):**
- `OIDC_ENABLED`: set to `true` to enable OIDC authentication (default: `false`)
- `OIDC_ISSUER`: OIDC issuer URL provided by your identity provider
(e.g. `https://accounts.google.com`, `https://your-tenant.auth0.com/`)
- `OIDC_CLIENT_ID`: client ID issued by your OIDC provider
- `OIDC_CLIENT_SECRET`: client secret issued by your OIDC provider
- `OIDC_AUTH_URL`: authorization endpoint URL
- `OIDC_TOKEN_URL`: token endpoint URL
- `OIDC_REDIRECT_URL`: redirect (callback) URL registered in your OIDC provider
Domain is user-defined, but the path must be fixed: `/auth/oidc/callback`
Example: `https://your-domain.example.com/auth/oidc/callback`
- `OIDC_SCOPES`: space-separated list of requested scopes (default: `"openid profile email"`)
- `OIDC_ALLOWED_USERS`: comma-separated list of allowed emails or domains (optional)
Example: `user@example.com,@example.com`
- `OIDC_ALLOWED_SUBS`: comma-separated list of allowed OIDC subject (`sub`) IDs (optional)
- `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
+27 -8
View File
@@ -5,6 +5,7 @@ services:
image: ${GLKVM_IMAGE:-glzhitong/glkvm-cloud:latest}
container_name: glkvm_cloud
restart: always
network_mode: "host"
environment:
# Preferred: set GLKVM_ACCESS_IP explicitly; if empty, entrypoint will auto-detect once.
GLKVM_ACCESS_IP: ${GLKVM_ACCESS_IP:-}
@@ -34,22 +35,43 @@ services:
LDAP_USER_FILTER: ${LDAP_USER_FILTER:-(uid=%s)}
LDAP_ALLOWED_GROUPS: ${LDAP_ALLOWED_GROUPS:-}
LDAP_ALLOWED_USERS: ${LDAP_ALLOWED_USERS:-}
# ---- OIDC Authentication ----
OIDC_ENABLED: ${OIDC_ENABLED:-false}
OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
OIDC_AUTH_URL: ${OIDC_AUTH_URL:-}
OIDC_TOKEN_URL: ${OIDC_TOKEN_URL:-}
OIDC_REDIRECT_URL: ${OIDC_REDIRECT_URL:-}
OIDC_SCOPES: ${OIDC_SCOPES:-openid profile email}
# Whitelists (optional)
OIDC_ALLOWED_USERS: ${OIDC_ALLOWED_USERS:-}
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
- ./certificate/glkvm.cer:/home/certificate/glkvm_cer:ro
- ./certificate/glkvm.key:/home/certificate/glkvm_key:ro
- ./database:/home/database:rw
entrypoint: ["/bin/sh", "/docker-entrypoint.sh"]
command: ["rttys"]
ports:
- "${RTTYS_WEBUI_PORT:-443}:${RTTYS_WEBUI_PORT:-443}"
- "${RTTYS_HTTP_PROXY_PORT:-10443}:${RTTYS_HTTP_PROXY_PORT:-10443}"
- "${RTTYS_DEVICE_PORT:-5912}:${RTTYS_DEVICE_PORT:-5912}"
coturn:
image: ${COTURN_IMAGE:-coturn/coturn:edge-alpine}
container_name: glkvm_coturn
restart: always
network_mode: "host"
environment:
# Same semantics as above: prefer explicit value, else auto-detect
GLKVM_ACCESS_IP: ${GLKVM_ACCESS_IP:-}
@@ -60,7 +82,4 @@ services:
command: ["coturn"]
volumes:
- ./templates/turnserver.conf.template:/tpl/turnserver.conf.tmpl:ro
- ./scripts/docker-entrypoint.sh:/docker-entrypoint.sh:ro
ports:
- "${TURN_PORT:-3478}:3478/tcp"
- "${TURN_PORT:-3478}:3478/udp"
- ./scripts/docker-entrypoint.sh:/docker-entrypoint.sh:ro
+87
View File
@@ -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;
}
}
+4 -1
View File
@@ -65,7 +65,10 @@ case "$1" in
RTTYS_DEVICE_PORT RTTYS_WEBUI_PORT RTTYS_HTTP_PROXY_PORT \
LDAP_ENABLED LDAP_SERVER LDAP_PORT LDAP_USE_TLS \
LDAP_BIND_DN LDAP_BIND_PASSWORD LDAP_BASE_DN \
LDAP_USER_FILTER LDAP_ALLOWED_GROUPS LDAP_ALLOWED_USERS
LDAP_USER_FILTER LDAP_ALLOWED_GROUPS LDAP_ALLOWED_USERS \
OIDC_ENABLED OIDC_CLIENT_ID OIDC_AUTH_URL OIDC_TOKEN_URL \
OIDC_REDIRECT_URL OIDC_CLIENT_SECRET OIDC_SCOPES OIDC_ALLOWED_USERS OIDC_ISSUER \
OIDC_ALLOWED_SUBS OIDC_ALLOWED_USERNAMES OIDC_ALLOWED_GROUPS
exec rttys -c /home/rttys.conf
;;
@@ -26,3 +26,24 @@ ldap-base-dn: {{LDAP_BASE_DN}}
ldap-user-filter: {{LDAP_USER_FILTER}}
ldap-allowed-groups: {{LDAP_ALLOWED_GROUPS}}
ldap-allowed-users: {{LDAP_ALLOWED_USERS}}
# OIDC Authentication (generic OIDC provider)
oidc-enabled: {{OIDC_ENABLED}}
oidc-generic-issuer: {{OIDC_ISSUER}}
oidc-generic-client-id: {{OIDC_CLIENT_ID}}
# Note: oidc-generic-client-secret is read directly from the OIDC_CLIENT_SECRET
# environment variable and is intentionally not parsed from YAML to avoid
# leaking secrets in config files.
oidc-generic-auth-url: {{OIDC_AUTH_URL}}
oidc-generic-token-url: {{OIDC_TOKEN_URL}}
oidc-generic-redirect-url: {{OIDC_REDIRECT_URL}}
oidc-generic-scopes: {{OIDC_SCOPES}}
# Whitelists (all are optional)
oidc-generic-allowed-users: {{OIDC_ALLOWED_USERS}}
oidc-generic-allowed-subs: {{OIDC_ALLOWED_SUBS}}
oidc-generic-allowed-usernames: {{OIDC_ALLOWED_USERNAMES}}
oidc-generic-allowed-groups: {{OIDC_ALLOWED_GROUPS}}
+16
View File
@@ -3,12 +3,15 @@ module rttys
go 1.24.4
require (
github.com/coreos/go-oidc/v3 v3.16.0
github.com/dwdcth/consoleEx v0.0.0-20180521133551-f56f6eb78b76
github.com/fanjindong/go-cache v0.0.6
github.com/gin-contrib/cors v1.7.6
github.com/gin-gonic/gin v1.10.1
github.com/glebarez/sqlite v1.11.0
github.com/go-ldap/ldap/v3 v3.4.8
github.com/google/uuid v1.6.0
github.com/gorilla/sessions v1.2.1
github.com/gorilla/websocket v1.5.3
github.com/json-iterator/go v1.1.12
github.com/kylelemons/go-gypsy v1.0.0
@@ -17,6 +20,7 @@ require (
github.com/urfave/cli/v3 v3.3.8
github.com/valyala/bytebufferpool v1.0.0
golang.org/x/term v0.33.0
gorm.io/gorm v1.31.1
)
require (
@@ -24,13 +28,19 @@ require (
github.com/bytedance/sonic v1.13.3 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
@@ -38,13 +48,19 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
golang.org/x/arch v0.18.0 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/oauth2 v0.28.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.27.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)
+33
View File
@@ -10,11 +10,15 @@ github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFos
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/dwdcth/consoleEx v0.0.0-20180521133551-f56f6eb78b76 h1:eObfFy0e/9OQCd5tHy+855jrW7zTihdgIPD7hf2SOQ0=
github.com/dwdcth/consoleEx v0.0.0-20180521133551-f56f6eb78b76/go.mod h1:WPzFRpaqRmrZAD1vMpqUGZR24FE1EBoSG9lHKQyZOMM=
github.com/fanjindong/go-cache v0.0.6 h1:4xl8MnfW8pFLH9cRjs0uNfVbFNqV342yl/pgX3Ql9gM=
@@ -27,8 +31,14 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA=
github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ldap/ldap/v3 v3.4.8 h1:loKJyspcRezt2Q3ZRMq2p/0v8iOurlmeXDPw6fikSvQ=
github.com/go-ldap/ldap/v3 v3.4.8/go.mod h1:qS3Sjlu76eHfHGpUdWkAXQTw4beih+cHsco2jXlIXrk=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@@ -45,9 +55,13 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
@@ -66,6 +80,10 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
@@ -97,6 +115,9 @@ github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
@@ -144,6 +165,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -191,4 +214,14 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
+124 -43
View File
@@ -88,7 +88,9 @@ func (srv *RttyServer) ListenHttpProxy() {
}
defer ln.Close()
if cfg.SslCert != "" && cfg.SslKey != "" {
// 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())
@@ -142,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")
@@ -177,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)
@@ -333,22 +369,104 @@ func httpProxyRedirect(srv *RttyServer, c *gin.Context, group string) {
host := c.Request.Host
hostname, _, err := net.SplitHostPort(host)
if err != nil {
// 没有端口时直接使用 host
hostname = host
}
log.Info().Msgf("hostname: %s", hostname)
// 检查是否是 IP 地址
ip := net.ParseIP(hostname)
isIP := ip != nil
if isIP {
// IP 访问,直接跳转
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)
// 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")
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,
)
// -------------------------------------------------
// 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)
@@ -619,40 +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
}
}
+247 -185
View File
@@ -25,222 +25,284 @@
package main
import (
"context"
_ "net/http/pprof"
"os"
"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.8.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)",
},
&cli.BoolFlag{
Name: "verbose",
Aliases: []string{"V"},
Usage: "more detailed output",
},
},
Action: cmdAction,
}
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)",
},
err := cmd.Run(context.Background(), os.Args)
if err != nil {
log.Fatal().Msg(err.Error())
}
// ---- 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,
}
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
}
srv := &RttyServer{cfg: cfg}
// ===== 打印完整配置(验证配置是否加载正确) =====
{
importJSON, _ := json.MarshalIndent(cfg, "", " ")
log.Info().Msg("==== Loaded Configuration ====")
log.Info().Msg(string(importJSON))
log.Info().Msg("==============================")
}
return srv.Run()
// Initialize the SQLite database connection
db.Init()
srv := &RttyServer{cfg: cfg}
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
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
}
Executable
+476
View File
@@ -0,0 +1,476 @@
package main
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
oidc "github.com/coreos/go-oidc/v3/oidc"
"github.com/fanjindong/go-cache"
"github.com/gin-gonic/gin"
"github.com/gorilla/sessions"
"github.com/rs/zerolog/log"
"io"
"math/rand"
"net/http"
"net/url"
"rttys/utils"
"strings"
"time"
)
var (
// Session store for OAuth flow
oauthStore *sessions.CookieStore
// Global OIDC verifier
oidcVerifier *oidc.IDTokenVerifier
)
// Register OIDC routes
func RegisterOIDCRoutes(r *gin.Engine, cfg *Config) {
if !cfg.OIDCEnabled {
return
}
// ===== Initialize OIDC provider & ID token verifier =====
issuer := strings.TrimSpace(cfg.OIDCGenericIssuer)
if issuer == "" {
log.Error().Msg("OIDC is enabled but issuer (OIDCGenericIssuer) is empty")
return
}
ctx := context.Background()
//// Normalize issuer to always end with a single '/'
//issuer = strings.TrimRight(issuer, "/") + "/"
cfg.OIDCGenericIssuer = issuer
provider, err := oidc.NewProvider(ctx, issuer)
if err != nil {
log.Error().Err(err).Msg("Failed to initialize OIDC provider")
return
}
oidcVerifier = provider.Verifier(&oidc.Config{
ClientID: cfg.OIDCGenericClientID,
// You can set SkipIssuerCheck, SkipClientIDCheck here if needed, but not recommended.
})
// Initialize session store
sessionSecret := generateRandomString(32)
oauthStore = sessions.NewCookieStore([]byte(sessionSecret))
// Configure session options
oauthStore.Options = &sessions.Options{
Path: "/",
MaxAge: 300, // 5 minutes, enough to complete the OAuth flow
HttpOnly: true,
SameSite: http.SameSiteLaxMode, // Important: allow cookies on cross-site navigation (OIDC redirect)
Domain: "", // Empty means current host/domain will be used
}
// OIDC auth routes (public, no existing auth required)
r.GET("/auth/oidc/login", oidcLoginHandler(cfg))
r.GET("/auth/oidc/callback", oidcCallbackHandler(cfg))
}
// Start OIDC login
func oidcLoginHandler(cfg *Config) gin.HandlerFunc {
return func(c *gin.Context) {
// Generate state and nonce
nonce := generateRandomString(32)
state := generateRandomString(32)
log.Info().Msgf("OIDC login initiated: nonce=%s, state=%s...", nonce[:10], state[:10])
// Save to session
session, _ := oauthStore.Get(c.Request, "oidc-session")
session.Values["state"] = state
session.Values["nonce"] = nonce
if err := session.Save(c.Request, c.Writer); err != nil {
log.Error().Err(err).Msg("Failed to save OIDC session")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save session"})
return
}
// Build authorization URL
params := url.Values{}
params.Add("client_id", cfg.OIDCGenericClientID)
params.Add("redirect_uri", cfg.OIDCGenericRedirectURL)
params.Add("response_type", "code")
params.Add("scope", strings.Join(cfg.OIDCGenericScopes, " "))
params.Add("state", state)
params.Add("nonce", nonce)
authURL := cfg.OIDCGenericAuthURL + "?" + params.Encode()
log.Info().Msgf("OIDC login redirect=%s", authURL[:100]+"...")
c.Redirect(http.StatusFound, authURL)
}
}
// Handle OIDC callback
func oidcCallbackHandler(cfg *Config) gin.HandlerFunc {
return func(c *gin.Context) {
// Get session
session, err := oauthStore.Get(c.Request, "oidc-session")
if err != nil {
log.Error().Err(err).Msg("Failed to get OIDC session")
c.Redirect(http.StatusFound, "/?error=session_error")
return
}
// Validate state
state := c.Query("state")
savedState, ok := session.Values["state"].(string)
if !ok || state != savedState {
log.Warn().Msg("OIDC state mismatch")
c.Redirect(http.StatusFound, "/?error=invalid_state")
return
}
// Check OAuth error
if errorMsg := c.Query("error"); errorMsg != "" {
errorDesc := c.Query("error_description")
log.Warn().Msgf("OIDC error: %s - %s", errorMsg, errorDesc)
c.Redirect(http.StatusFound, "/?error="+errorMsg)
return
}
// Read authorization code
code := c.Query("code")
if code == "" {
c.Redirect(http.StatusFound, "/?error=no_code")
return
}
// Exchange authorization code for tokens
tokens, err := exchangeCodeForTokens(cfg, code)
if err != nil {
log.Error().Err(err).Msg("Failed to exchange code for tokens")
c.Redirect(http.StatusFound, "/?error=token_exchange_failed")
return
}
// Extract user info
var userEmail string
var userName string
// Standard OIDC – verify and parse ID token
rawIDToken, ok := tokens["id_token"].(string)
if !ok {
log.Error().Msg("No ID token in response")
c.Redirect(http.StatusFound, "/?error=no_id_token")
return
}
if oidcVerifier == nil {
log.Error().Msg("OIDC verifier is not initialized")
c.Redirect(http.StatusFound, "/?error=server_config")
return
}
// ==== Signature + standard claims verification ====
idToken, err := oidcVerifier.Verify(c.Request.Context(), rawIDToken)
if err != nil {
log.Error().Err(err).Msg("Failed to verify ID token signature/claims")
c.Redirect(http.StatusFound, "/?error=invalid_token")
return
}
// Decode claims into a map
claims := map[string]interface{}{}
if err := idToken.Claims(&claims); err != nil {
log.Error().Err(err).Msg("Failed to parse ID token claims")
c.Redirect(http.StatusFound, "/?error=invalid_token")
return
}
// Pretty-print all claims as JSON for debugging
if claimsJSON, err := json.MarshalIndent(claims, "", " "); err == nil {
log.Info().Msg("========== OIDC ID Token Claims ==========")
log.Info().Msg(string(claimsJSON))
log.Info().Msg("==========================================")
} else {
log.Warn().Err(err).Msg("Failed to marshal OIDC claims to JSON")
}
// Validate nonce
if savedNonce, ok := session.Values["nonce"].(string); ok {
if claims["nonce"] != savedNonce {
log.Warn().Msg("OIDC nonce mismatch")
c.Redirect(http.StatusFound, "/?error=invalid_nonce")
return
}
}
sub, _ := claims["sub"].(string)
userEmail, _ = claims["email"].(string)
userName, _ = claims["name"].(string)
if userName == "" {
// Fallback: use email or sub as display name
if userEmail != "" {
userName = userEmail
} else {
userName = sub
}
}
// sub is required by OIDC spec and should never be empty
if sub == "" {
log.Error().Msg("OIDC token is missing 'sub' claim")
c.Redirect(http.StatusFound, "/?error=user_info_failed")
return
}
log.Info().Msgf("OIDC login successful: email=%s", userEmail)
// ====== OIDC whitelist enforcement ======
if !isOIDCUserAllowed(cfg, claims) {
log.Warn().Msgf("OIDC user not allowed by whitelist rules, sub=%v, email=%v", claims["sub"], claims["email"])
c.Redirect(http.StatusFound, "/?error=authorization")
return
}
// Create application session
sid := utils.GenUniqueID()
httpSessions.Set(sid, gin.H{
"email": userEmail,
"name": userName,
"oidc": true,
}, cache.WithEx(httpSessionExpire))
c.SetCookie("sid", sid, 0, "", "", cfg.SslCert != "", true)
// Clean up OAuth session
session.Options.MaxAge = -1
session.Save(c.Request, c.Writer)
// Redirect to home page
c.Redirect(http.StatusFound, "/")
}
}
// Exchange authorization code for tokens
func exchangeCodeForTokens(cfg *Config, code string) (map[string]interface{}, error) {
data := url.Values{}
data.Set("code", code)
data.Set("client_id", cfg.OIDCGenericClientID)
data.Set("client_secret", cfg.OIDCGenericClientSecret)
data.Set("redirect_uri", cfg.OIDCGenericRedirectURL)
data.Set("grant_type", "authorization_code")
req, err := http.NewRequest("POST", cfg.OIDCGenericTokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token request failed: %s", string(body))
}
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
return result, nil
}
// Generate a random string with given length
func generateRandomString(length int) string {
b := make([]byte, length)
rand.Read(b)
return base64.URLEncoding.EncodeToString(b)[:length]
}
func isOIDCUserAllowed(cfg *Config, claims map[string]interface{}) bool {
email, _ := claims["email"].(string)
sub, _ := claims["sub"].(string)
preferredUsername, _ := claims["preferred_username"].(string)
name, _ := claims["name"].(string)
// 1) Email whitelist
if len(cfg.OIDCGenericAllowedUsers) > 0 {
if !isEmailAllowed(cfg.OIDCGenericAllowedUsers, email) {
return false
}
}
// 2) Sub (subject) whitelist
if len(cfg.OIDCGenericAllowedSubs) > 0 {
if !contains(cfg.OIDCGenericAllowedSubs, sub) {
return false
}
}
// 3) Username whitelist (preferred_username > name)
if len(cfg.OIDCGenericAllowedUsernames) > 0 {
u := preferredUsername
if u == "" {
u = name
}
if !contains(cfg.OIDCGenericAllowedUsernames, u) {
return false
}
}
// 4) Groups whitelist
if len(cfg.OIDCGenericAllowedGroups) > 0 {
groups := extractStringSlice(claims["groups"])
if !intersects(groups, cfg.OIDCGenericAllowedGroups) {
return false
}
}
return true
}
// Email whitelist check rules:
// - Exact match (case-insensitive)
// - Simple domain match: entries starting with "@example.com" or "*@example.com"
// mean "allow all users under this domain"
func isEmailAllowed(allowed []string, email string) bool {
if len(allowed) == 0 {
return true
}
e := strings.ToLower(strings.TrimSpace(email))
for _, raw := range allowed {
a := strings.ToLower(strings.TrimSpace(raw))
if a == "" {
continue
}
if strings.HasPrefix(a, "*@") || strings.HasPrefix(a, "@") {
// Domain match
dom := strings.TrimPrefix(a, "*@")
dom = strings.TrimPrefix(dom, "@")
if strings.HasSuffix(e, "@"+dom) {
return true
}
continue
}
// Exact match
if e == a {
return true
}
}
return false
}
// contains reports whether slice contains the given value v.
// Comparison is done after trimming spaces on both sides.
func contains(slice []string, v string) bool {
v = strings.TrimSpace(v)
if v == "" {
return false
}
for _, s := range slice {
if strings.TrimSpace(s) == v {
return true
}
}
return false
}
// extractStringSlice tries to normalize a generic claim value into a []string.
//
// It supports:
// - []string
// - []interface{} (only string elements are kept)
// - string (split by comma and/or whitespace)
// Any other type will result in an empty slice.
func extractStringSlice(v interface{}) []string {
if v == nil {
return nil
}
switch vv := v.(type) {
case []string:
// Return a shallow copy to avoid accidental modification.
out := make([]string, 0, len(vv))
for _, s := range vv {
s = strings.TrimSpace(s)
if s != "" {
out = append(out, s)
}
}
return out
case []interface{}:
out := make([]string, 0, len(vv))
for _, item := range vv {
s, ok := item.(string)
if !ok {
continue
}
s = strings.TrimSpace(s)
if s != "" {
out = append(out, s)
}
}
return out
case string:
// Allow comma- or whitespace-separated group lists.
s := strings.TrimSpace(vv)
if s == "" {
return nil
}
// Replace commas with spaces, then split on whitespace.
s = strings.ReplaceAll(s, ",", " ")
parts := strings.Fields(s)
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
default:
return nil
}
}
// intersects reports whether slice a and b share at least one common element.
// Matching is done after trimming spaces on both sides.
func intersects(a, b []string) bool {
if len(a) == 0 || len(b) == 0 {
return false
}
m := make(map[string]struct{}, len(a))
for _, s := range a {
s = strings.TrimSpace(s)
if s == "" {
continue
}
m[s] = struct{}{}
}
for _, s := range b {
s = strings.TrimSpace(s)
if s == "" {
continue
}
if _, ok := m[s]; ok {
return true
}
}
return false
}
Executable
+149
View File
@@ -0,0 +1,149 @@
package main
import (
"errors"
"fmt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"rttys/db"
"rttys/utils"
"time"
)
// SaveOrUpdateDeviceMeta inserts or updates device metadata in the database.
// It performs an UPSERT operation based on device_id.
func SaveOrUpdateDeviceMeta(deviceID, mac, description, ip string) error {
deviceDB := db.GetDbClient()
if deviceDB == nil {
return fmt.Errorf("deviceDB is not initialized")
}
now := time.Now().Unix()
meta := &db.DeviceMeta{
DeviceID: deviceID,
Mac: utils.NormalizeMac(mac),
IP: ip,
Description: description,
CreateTime: now,
UpdateTime: now,
}
// Use device_id as the conflict key and update fields on conflict
return deviceDB.Clauses(clause.OnConflict{
Columns: []clause.Column{
{Name: "device_id"},
},
DoUpdates: clause.Assignments(map[string]any{
"mac": meta.Mac,
"ip": meta.IP,
"description": meta.Description,
"update_time": now,
}),
}).Create(meta).Error
}
// GetDeviceMetaByDeviceID retrieves device metadata by device_id.
// It returns (nil, nil) if the record does not exist.
func GetDeviceMetaByDeviceID(deviceID string) (*db.DeviceMeta, error) {
deviceDB := db.GetDbClient()
if deviceDB == nil {
return nil, fmt.Errorf("deviceDB is not initialized")
}
var meta db.DeviceMeta
if err := deviceDB.
Where("device_id = ?", deviceID).
First(&meta).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &meta, nil
}
// GetDeviceMetaByMac retrieves device metadata by MAC address.
// The MAC address is normalized before querying.
// It returns (nil, nil) if the record does not exist.
func GetDeviceMetaByMac(mac string) (*db.DeviceMeta, error) {
deviceDB := db.GetDbClient()
if deviceDB == nil {
return nil, fmt.Errorf("deviceDB is not initialized")
}
normMac := utils.NormalizeMac(mac)
var meta db.DeviceMeta
if err := deviceDB.
Where("mac = ?", normMac).
First(&meta).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &meta, nil
}
// GetAllDeviceMeta retrieves device metadata records from the database.
// If keyword is empty, it returns all records ordered by create_time ASC.
// If keyword is non-empty, it searches by device_id, normalized MAC, or description (fuzzy match).
func GetAllDeviceMeta(keyword string) ([]db.DeviceMeta, error) {
deviceDB := db.GetDbClient()
if deviceDB == nil {
return nil, fmt.Errorf("deviceDB is not initialized")
}
var list []db.DeviceMeta
query := deviceDB.Model(&db.DeviceMeta{})
if keyword != "" {
// Normalize MAC in case the keyword is a MAC address
normMac := utils.NormalizeMac(keyword)
likeDesc := "%" + keyword + "%"
query = query.Where(
"device_id = ? OR mac = ? OR description LIKE ?",
keyword,
normMac,
likeDesc,
)
}
if err := query.
Order("create_time ASC").
Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
// DeleteDeviceMetaByDeviceID deletes device metadata by device_id.
// It returns gorm.ErrRecordNotFound if no record is deleted.
func DeleteDeviceMetaByDeviceID(deviceID string) error {
deviceDB := db.GetDbClient()
if deviceDB == nil {
return fmt.Errorf("deviceDB is not initialized")
}
result := deviceDB.
Where("device_id = ?", deviceID).
Delete(&db.DeviceMeta{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
+1 -1
View File
@@ -11,7 +11,7 @@
"build:dev": "vite build --mode development",
"build:test": "vite build --mode test",
"build:prodCn": "vite build --mode productionCn",
"deploy": "scp -r ./dist root@47.115.78.134:/root/glkvm_cloud/ui/dist1",
"deploy": "scp -r ./dist root@107.173.152.173:/root/glkvm_cloud/ui/dist",
"preview": "vite preview",
"lint-all": "eslint src/**/*.{js,vue}",
"lint": "lint-staged",
+12 -2
View File
@@ -2,8 +2,8 @@
* @Author: shufei.han
* @Date: 2025-06-11 11:48:02
* @LastEditors: LPY
* @LastEditTime: 2025-08-26 16:07:23
* @FilePath: \glkvm-cloud\web-ui\src\api\device.ts
* @LastEditTime: 2025-12-10 14:22:23
* @FilePath: \glkvm-cloud\ui\src\api\device.ts
* @Description: 设备相关API
*/
import { ExecuteCommandParams, type DeviceInfo } from '@/models/device'
@@ -22,4 +22,14 @@ export const getAddDeviceScriptInfoApi = () => {
/** 执行命令 */
export const reqExecuteCommand = (data: ExecuteCommandParams) => {
return httpService.post(`/cmd/${data.id}?group=${data.group}&wait=${data.wait}`, data)
}
/** 修改描述 */
export const reqEditDescription = (data: { deviceId: string, description: string }) => {
return httpService.post('/devs/update', data)
}
/** 删除设备 */
export const reqDeleteDevice = (data: { deviceId: string }) => {
return httpService.post('/devs/delete', data)
}
+10 -9
View File
@@ -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;
+4 -2
View File
@@ -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',
}
/**
+15 -3
View File
@@ -14,7 +14,9 @@
"cancel": "Cancel",
"ok": "OK",
"close": "Close",
"about": "About"
"about": "About",
"maxLength": "Maximum length is {length} characters",
"more": "More"
},
"login": {
"authorizationRequired": "Authorization Required",
@@ -27,7 +29,9 @@
"signOut": "Sign Out",
"authOptions": "Authentication Options",
"ldapAuth": "Enter username and password for LDAP authentication",
"webManagementAuth": "Leave username empty and use web management password"
"webManagementAuth": "Leave username empty and use web management password",
"or": "OR",
"loginWithOidc": "Log in with OIDC"
},
"device": {
"devices": "Devices",
@@ -64,7 +68,15 @@
"errorMessage": "Error Message",
"commandResponseDetail": "Command Response Detail",
"standardOutput": "Standard output",
"standardErrorOutput": "Standard error output"
"standardErrorOutput": "Standard error output",
"status": "Status",
"online": "Online",
"offline": "Offline",
"editDescription": "Edit Description",
"inputDescription": "Input Description",
"requiredDescription": "Please input description",
"deleteDevice": "Delete Device",
"deleteDeviceConfirmTips": "Are you sure you want to delete this device? This action cannot be undone."
},
"rtty": {
"requestingDeviceToCreateTerminal": "Requesting device to create terminal...",
+15 -3
View File
@@ -14,7 +14,9 @@
"cancel": "取消",
"ok": "确定",
"close": "关闭",
"about": "关于"
"about": "关于",
"maxLength": "最大长度为{length}个字符",
"more": "更多"
},
"login": {
"authorizationRequired": "需要授权",
@@ -27,7 +29,9 @@
"signOut": "退出",
"authOptions": "认证方式",
"ldapAuth": "输入用户名和密码进行LDAP认证",
"webManagementAuth": "留空用户名并使用Web管理密码"
"webManagementAuth": "留空用户名并使用Web管理密码",
"or": "或",
"loginWithOidc": "使用OIDC登录"
},
"device": {
"devices": "设备数",
@@ -64,7 +68,15 @@
"errorMessage": "错误信息",
"commandResponseDetail": "命令响应详情",
"standardOutput": "标准输出",
"standardErrorOutput": "标准错误输出"
"standardErrorOutput": "标准错误输出",
"status": "状态",
"online": "在线",
"offline": "离线",
"editDescription": "编辑描述",
"inputDescription": "输入描述",
"requiredDescription": "请输入描述",
"deleteDevice": "删除设备",
"deleteDeviceConfirmTips": "你确定要删除这台设备吗?此操作不可撤销。"
},
"rtty": {
"requestingDeviceToCreateTerminal": "正在请求设备创建终端...",
+4 -3
View File
@@ -1,9 +1,9 @@
/*
* @Author: LPY
* @Date: 2025-06-09 16:37:00
* @LastEditors: CU-Jon
* @LastEditTime: 2025-09-26 14:02:57 EDT
* @FilePath: \glkvm-cloud\web-ui\src\models\user.ts
* @LastEditors: LPY
* @LastEditTime: 2025-11-12 16:19:19
* @FilePath: \glkvm-cloud\ui\src\models\user.ts
* @Description: 用户相关类型声明
*/
@@ -18,4 +18,5 @@ export interface LoginParams {
export interface AuthConfig {
ldapEnabled: boolean;
legacyPassword: boolean;
oidcEnabled: boolean;
}
+3 -2
View File
@@ -2,11 +2,12 @@
* @Author: LPY
* @Date: 2025-05-30 09:54:42
* @LastEditors: LPY
* @LastEditTime: 2025-08-22 12:09:36
* @FilePath: \glkvm-cloud\web-ui\src\router\whiteList.ts
* @LastEditTime: 2025-11-12 16:36:23
* @FilePath: \glkvm-cloud\ui\src\router\whiteList.ts
* @Description: 路由白名单页
*/
export default [
'/login',
'/error',
'/auth/oidc/login',
]
+10 -3
View File
@@ -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,7 +2,7 @@
* @Author: shufei.han
* @Date: 2025-06-11 12:04:48
* @LastEditors: LPY
* @LastEditTime: 2025-08-29 15:06:55
* @LastEditTime: 2025-12-10 14:22:45
* @FilePath: \glkvm-cloud\ui\src\views\device\components\deviceListView.vue
* @Description:
-->
@@ -28,18 +28,39 @@
rowKey="id"
:rowSelection="{ selectedRowKeys: state.selectedRowKeys, onChange: onSelectChange }"
>
<template #status="{ record }">
<BaseTag primary v-if="isDeviceOnline(record)">{{ $t('device.online') }}</BaseTag>
<BaseTag v-else>{{ $t('device.offline') }}</BaseTag>
</template>
<template #connected="{ record }">
{{ record.connected ? calculateWithDuration(record.connected) : '' }}
{{ record.connected ? calculateWithDuration(record.connected) : '-' }}
</template>
<template #uptime="{ record }">
{{ record.uptime ? calculateWithDuration(record.uptime) : '' }}
{{ record.uptime ? calculateWithDuration(record.uptime) : '-' }}
</template>
<template #action="{ record }">
<div class="flex-start">
<a target="_blank" rel="noopener noreferrer" @click="handleRemoteSSH(record.id)">{{ $t('device.remoteSSH') }}</a>
<a target="_blank" rel="noopener noreferrer" style="margin-left: 16px;" @click="handleRemoteControl(record.id)">
<a
target="_blank"
rel="noopener noreferrer"
:class="[{'disabled': !isDeviceOnline(record)}]"
@click="handleRemoteSSH(record.id, record)">{{ $t('device.remoteSSH') }}</a>
<a
target="_blank"
rel="noopener noreferrer"
style="margin-left: 16px;"
:class="[{'disabled': !isDeviceOnline(record)}]"
@click="handleRemoteControl(record.id, record)">
{{ $t('device.remoteControl') }}
</a>
<BaseDropdownSelect :options="DEVICE_OPTIONS(record)" @update:value="(v) => handleAction(v, record)">
<a
target="_blank"
rel="noopener noreferrer"
style="margin-left: 16px;">
{{ $t('common.more') }}
</a>
</BaseDropdownSelect>
</div>
</template>
</BaseTable>
@@ -66,6 +87,14 @@
:selection="state.selectedRows"
:formData="executeCommandFormData"
/>
<!-- 修改描述弹窗 -->
<EditDescriptionDialog
v-model:open="editDescriptionOpen"
:deviceId="editingDeviceId"
:currentDescription="currentDescription"
@handleApply="handleEditDescriptionApply"
/>
</BaseLoadingContainer>
</template>
@@ -80,17 +109,23 @@ import { computed, reactive, ref } from 'vue'
import ExecuteCommandDialog from './executeCommandDialog.vue'
import { DeviceInfo, ExecuteCommandFormData } from '@/models/device'
import CommandResponseDialog from './commandResponseDialog.vue'
import { BaseDropdownSelect, BaseInfo, BaseTag } from 'gl-web-main/components'
import EditDescriptionDialog from './editDescriptionDialog.vue'
import { baseCustomModal, SelectOptions } from 'gl-web-main'
import { reqDeleteDevice } from '@/api/device'
const deviceStore = useDeviceStore()
const deviceColumns = computed<TableColumnType[]>(() => {
return [
{title: t('device.deviceID'), dataIndex: 'id', ellipsis: true},
{title: t('MAC'), dataIndex: 'mac', ellipsis: true},
{title: t('device.status'), dataIndex: 'status', ellipsis: true},
{title: t('device.connectedTime'), dataIndex: 'connected', ellipsis: true},
{title: t('device.uptime'), dataIndex: 'uptime', ellipsis: true},
{title: t('device.IPAddress'), dataIndex: 'ipaddr', ellipsis: true},
{title: t('device.description'), dataIndex: 'description', ellipsis: true},
{title: t('common.action'), dataIndex: 'action', width: 220},
{title: t('common.action'), dataIndex: 'action', width: 270},
]
})
@@ -155,7 +190,8 @@ const executeCommandApply = (formData: ExecuteCommandFormData) => {
}
/** 远程SSH */
const handleRemoteSSH = async (id: string) => {
const handleRemoteSSH = async (id: string, device: DeviceInfo) => {
if (!isDeviceOnline(device)) return
try {
let url = `/#/rtty/${id}`
window.open(url)
@@ -165,7 +201,8 @@ const handleRemoteSSH = async (id: string) => {
}
/** 远程控制 */
const handleRemoteControl = async (id: string) => {
const handleRemoteControl = async (id: string, device: DeviceInfo) => {
if (!isDeviceOnline(device)) return
try {
let proto = 'https'
let ipaddr = '127.0.0.1'
@@ -177,6 +214,67 @@ const handleRemoteControl = async (id: string) => {
console.log(error)
}
}
/** 计算设备是否在线 */
const isDeviceOnline = (device: DeviceInfo) => {
return device.connected && device.connected > 0
}
enum DeviceActions {
/** 编辑描述 */
EDIT_DESCRIPTION,
/** 删除设备 */
DELETE,
}
const DEVICE_OPTIONS = (device: DeviceInfo) => {
if (isDeviceOnline(device)) {
return [
new SelectOptions(DeviceActions.EDIT_DESCRIPTION, t('device.editDescription')),
]
} else {
return [
new SelectOptions(DeviceActions.EDIT_DESCRIPTION, t('device.editDescription')),
new SelectOptions(DeviceActions.DELETE, t('device.deleteDevice')),
]
}
}
const handleAction = async (action: DeviceActions, device: DeviceInfo) => {
switch (action) {
case DeviceActions.EDIT_DESCRIPTION:
handleEditDescription(device.id, device.description)
break
case DeviceActions.DELETE:
baseCustomModal({
type: 'confirm',
title: t('device.deleteDevice'),
content: t('device.deleteDeviceConfirmTips'),
onOk: async () => {
await reqDeleteDevice({ deviceId: device.id })
deviceStore.getDeviceList()
message.success(t('common.success'))
},
})
break
}
}
/** 修改描述 */
const editDescriptionOpen = ref(false)
const editingDeviceId = ref<string>('')
const currentDescription = ref<string>('')
const handleEditDescription = (deviceId: string, description: string) => {
editingDeviceId.value = deviceId
currentDescription.value = description
editDescriptionOpen.value = true
}
const handleEditDescriptionApply = () => {
deviceStore.getDeviceList()
message.success(t('common.success'))
}
</script>
<style lang="scss" scoped>
@@ -197,5 +295,10 @@ const handleRemoteControl = async (id: string) => {
.pagination {
height: 40px;
}
.disabled {
cursor: not-allowed;
color: var(--gl-color-text-disabled);
}
}
</style>
@@ -0,0 +1,92 @@
<!--
* @Author: LPY
* @Date: 2025-12-10 11:11:51
* @LastEditors: LPY
* @LastEditTime: 2025-12-10 11:41:16
* @FilePath: \glkvm-cloud\ui\src\views\device\components\editDescriptionDialog.vue
* @Description: 修改描述弹窗
-->
<template>
<BaseModal
:width="500"
:open="props.open"
:title="$t('device.editDescription')"
destroyOnClose
:beforeOk="handleApply"
@close="emits('update:open', false)"
>
<AForm
:colon="false"
:rules="formRules"
:model="state.formData"
ref="formRef"
@validate="handleValidate"
>
<AFormItem name="description" :label="$t('device.description')" :labelCol="{ span: 8 }" :wrapperCol="{ span: 16 }" labelAlign="left">
<AInput v-model:value="state.formData.description" name="description" :placeholder="$t('device.inputDescription')" style="width: 100%;" />
</AFormItem>
</AForm>
</BaseModal>
</template>
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import { BaseModal } from 'gl-web-main/components'
import { FormRules, OnBeforeOk, useValidateInfo } from 'gl-web-main'
import { t } from '@/hooks/useLanguage'
import { FormInstance } from 'ant-design-vue'
import { reqEditDescription } from '@/api/device'
const props = defineProps<{ open: boolean, deviceId: string, currentDescription: string }>()
const emits = defineEmits<{
(e: 'update:open', value: boolean): void;
(e: 'handleApply'): void;
}>()
const { handleValidate } = useValidateInfo()
const formRef = ref<FormInstance>()
const state = reactive<{formData: { description: string }}>({
formData: {
description: '',
},
})
/** 表单验证 */
const formRules: FormRules = {
description: [
{ required: true, message: t('device.requiredDescription'), trigger: 'change' },
{ max: 256, message: t('common.maxLength', { length: 256 }), trigger: 'change' },
],
}
/** 提交 */
const handleApply: OnBeforeOk = (done) => {
formRef.value.validate().then(() => {
reqEditDescription({ deviceId: props.deviceId, description: state.formData.description }).then(() => {
emits('handleApply')
done(true)
}).catch(() => {
done(false)
})
}).catch(() => {
done(false)
})
}
/** 初始化数据 */
watch(() => props.open, (newVal) => {
if (newVal) {
init()
}
})
const init = () => {
state.formData.description = props.currentDescription || ''
}
</script>
<style lang="scss">
</style>
+6 -2
View File
@@ -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'
+56 -8
View File
@@ -1,9 +1,9 @@
<!--
* @Author: LPY
* @Date: 2025-05-30 10:48:43
* @LastEditors: CU-Jon
* @LastEditTime: 2025-09-26 14:02:57 EDT
* @FilePath: \glkvm-cloud\web-ui\src\views\login\loginPage.vue
* @LastEditors: LPY
* @LastEditTime: 2026-01-05 14:25:45
* @FilePath: \glkvm-cloud\ui\src\views\login\loginPage.vue
* @Description: 登录页面
-->
<template>
@@ -13,6 +13,7 @@
<BaseText type="large-title-m">
{{ $t('login.authorizationRequired') }}
</BaseText>
<!-- 认证选项帮助 - 仅在启用LDAP时显示 (Auth options help - only show when LDAP is enabled) -->
<div v-if="isLdapEnabled" class="auth-help-container">
<div
@@ -28,7 +29,11 @@
style="margin-left: 8px; color: #656d76; cursor: help;"
>
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
<path d="M5.255 5.786a.237.237 0 0 0 .241.247h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286zm1.557 5.763c0 .533.425.927 1.01.927.609 0 1.028-.394 1.028-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.40z"/>
<path d="M5.255 5.786a.237.237 0 0 0 .241.247h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343
1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0
.25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655
.59-2.75 2.286zm1.557 5.763c0 .533.425.927 1.01.927.609 0 1.028-.394 1.028-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009
.388-1.009.40z"/>
</svg>
</div>
<!-- 提示框 (Tooltip) -->
@@ -72,6 +77,16 @@
<BaseButton medium type="primary" style="width: 100%;margin-top: 16px;" :loading="state.loading" @click="handleLogin">
{{ $t('login.signIn') }}
</BaseButton>
<div v-if="isOidcEnabled" class="google-login-box">
<a-divider style="border-color: var(--gl-color-line-divider1);color: var(--gl-color-text-level3);font-weight: normal;">
{{ $t('login.or') }}
</a-divider>
<BaseButton medium class="google-login-btn" :loading="state.oidcLoading" @click="handleLoginWithOidc">
<!-- <BaseSvg name="gl-icon-google" :size="20" style="margin-right: 10px;"/> -->
{{ $t('login.loginWithOidc') }}
</BaseButton>
</div>
</LoginBox>
</BaseWhitePage>
</template>
@@ -88,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
@@ -105,12 +121,18 @@ const isLdapEnabled = computed(() => {
return authConfig.value?.ldapEnabled === true
})
const state = reactive<{formModel: LoginParams, loading: boolean}>({
// 计算是否允许OIDC认证
const isOidcEnabled = computed(() => {
return authConfig.value?.oidcEnabled === true
})
const state = reactive<{formModel: LoginParams, loading: boolean, oidcLoading: boolean}>({
formModel: {
username: '',
password: '',
},
loading: false,
oidcLoading: false,
})
const showTooltip = ref(false)
@@ -132,11 +154,11 @@ 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)
authConfig.value = { ldapEnabled: false, legacyPassword: true }
authConfig.value = { ldapEnabled: false, legacyPassword: true, oidcEnabled: false }
}
})
@@ -149,7 +171,7 @@ const handleLogin = () => {
const loginData: LoginParams = {
username: state.formModel.username,
password: state.formModel.password,
authMethod: (state.formModel.username && authConfig.value?.ldapEnabled) ? 'ldap' : 'legacy'
authMethod: (state.formModel.username && authConfig.value?.ldapEnabled) ? 'ldap' : 'legacy',
}
await useUserStore().login(loginData)
@@ -178,6 +200,13 @@ const handleLogin = () => {
}
})
}
// oidc登录按钮
const handleLoginWithOidc = () => {
state.oidcLoading = true
const baseUrl = window.location.origin
window.location.href = `${baseUrl}/auth/oidc/login`
}
</script>
<style scoped lang="scss">
@@ -235,4 +264,23 @@ const handleLogin = () => {
}
}
}
.google-login-box {
width: 100%;
.google-login-btn {
width: 100%;
color: var(--gl-color-text-google);
background-color: var(--gl-color-bg-google);
border-color: var(--gl-color-line-google);
font-size: 14px;
font-weight: 500;
height: 40px;
border-radius: 64px;
display: flex;
justify-content: center;
align-items: center;
}
}
</style>
+12 -8
View File
@@ -23,35 +23,39 @@ export default defineConfig(({ mode }) => {
port: 3011,
proxy: {
'/devs': {
target: 'https://106.55.158.199',
target: 'https://107.173.152.173',
secure: false,
},
'/signin': {
target: 'https://106.55.158.199',
target: 'https://107.173.152.173',
secure: false,
},
'/signout': {
target: 'https://106.55.158.199',
target: 'https://107.173.152.173',
secure: false,
},
'/alive': {
target: 'https://106.55.158.199',
target: 'https://107.173.152.173',
secure: false,
},
'/get': {
target: 'https://106.55.158.199',
target: 'https://107.173.152.173',
secure: false,
},
'^/cmd/.*': {
target: 'https://106.55.158.199',
target: 'https://107.173.152.173',
secure: false,
},
'^/connect/.*': {
ws: true,
target: 'https://106.55.158.199',
target: 'https://107.173.152.173',
},
'^/web/*': {
target: 'https://106.55.158.199',
target: 'https://107.173.152.173',
},
'/auth-config': {
target: 'https://107.173.152.173',
secure: false,
},
},
},
Executable
+7
View File
@@ -0,0 +1,7 @@
package utils
import "strings"
func NormalizeMac(mac string) string {
return strings.ReplaceAll(strings.ToLower(mac), ":", "")
}