16 Commits

Author SHA1 Message Date
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
24 changed files with 1079 additions and 55 deletions
+11
View File
@@ -59,12 +59,23 @@
## 📦 安装
我们提供 **两种** 安装 GLKVM Cloud 的方式:
#### A) 一键安装脚本(推荐)
> **注意:** 一键安装脚本基于 **Docker**。它会自动完成 Docker / Docker Compose 的安装、拉取镜像、根据模板渲染配置文件,并启动所有服务。
使用 **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)
### 🌐 平台访问
安装完成后,你可以通过以下方式访问平台:
+202 -20
View File
@@ -31,6 +31,7 @@ import (
"net"
"net/http"
"path"
"sort"
"strings"
"time"
@@ -130,32 +131,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{
@@ -326,13 +498,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{
@@ -352,7 +531,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())
+17
View File
@@ -78,6 +78,12 @@ type Config struct {
OIDCGenericAllowedSubs []string
OIDCGenericAllowedUsernames []string
OIDCGenericAllowedGroups []string
// =====================================================
// Reverse Proxy / Proxy Mode
// =====================================================
// Enable proxy mode (app is behind Nginx/Traefik/Caddy/Cloudflare)
ReverseProxyEnabled bool
}
// docker mode fixed path for reading certificate
@@ -251,6 +257,17 @@ func parseYamlCfg(cfg *Config, conf string) error {
cfg.OIDCGenericAllowedGroups = splitScopes(s)
}
// Reverse proxy mode is always read from environment variable
// to avoid config drift when running behind different proxies per deployment.
if v := strings.TrimSpace(os.Getenv("REVERSE_PROXY_ENABLED")); v != "" {
// Accept common truthy values: "true/false", "1/0", "yes/no", "on/off"
if b, err := strconv.ParseBool(v); err == nil {
cfg.ReverseProxyEnabled = b
} else {
return fmt.Errorf("invalid REVERSE_PROXY_ENABLED value %q, expected boolean (true/false/1/0)", v)
}
}
return nil
}
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))
+4
View File
@@ -2,6 +2,10 @@
GLKVM_IMAGE=glzhitong/glkvm-cloud:latest
COTURN_IMAGE=coturn/coturn:edge-alpine
# 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.
REVERSE_PROXY_ENABLED=false
# GLKVM access IP seen by devices/users.
# Leave empty to auto-detect at container start.
GLKVM_ACCESS_IP=
+115
View File
@@ -0,0 +1,115 @@
# 快速开始(Quick Start)
本指南展示如何使用提供的 Docker Compose 环境模板部署 **glkvm-cloud**。
### 1. **克隆仓库并准备环境模板**
```bash
git clone https://github.com/gl-inet/glkvm-cloud.git
cd glkvm-cloud/docker-compose/
cp .env.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
# 启用反向代理模式(例如在 GLKVM Cloud 前使用 Nginx)
# 启用后,TLS 由反向代理终止,GLKVM Cloud 内部使用明文 HTTP
REVERSE_PROXY_ENABLED=false
```
当 `REVERSE_PROXY_ENABLED` 设置为 `true` 时,GLKVM Cloud 将运行在 **反向代理(如 Nginx)之后**:
- HTTPS 证书由反向代理管理(而不是由 GLKVM Cloud 本身管理)
- GLKVM Cloud 内部以明文 HTTP 方式监听
- 同一个 HTTPS 端口可同时用于:
- 访问 GLKVM Cloud Web 管理界面
- 访问远程 KVM 设备
例如,在正确配置 Nginx 的情况下:
```nginx
# 转发原始的主机名、协议、端口以及客户端 IP
# 在反向代理模式下,这些 Header 是必须的
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;
```
你可以通过以下地址访问:
```text
https://www.example.com → GLKVM Cloud 管理界面
https://<device_id>.example.com → 远程设备访问
```
这两个地址可以共用 **同一个 HTTPS 端口(443)**,由反向代理根据访问的域名进行路由区分。
⚠️ **注意:所有配置均需在 `.env` 中完成,不需要修改 `docker-compose.yml`、模板或脚本。**
### 3. **启动服务**
```bash
docker-compose up -d
```
如果你修改了 `.env` 或模板文件,请重新加载服务:
```bash
docker-compose down && docker-compose up -d
```
### 4. **访问平台**
安装完成后,通过以下地址访问平台:
```bash
https://<你的服务器公网 IP>
```
+39 -2
View File
@@ -42,7 +42,6 @@
(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
*(recommended to set via environment variable rather than YAML files)*
- `OIDC_AUTH_URL`: authorization endpoint URL
- `OIDC_TOKEN_URL`: token endpoint URL
- `OIDC_REDIRECT_URL`: redirect (callback) URL registered in your OIDC provider
@@ -54,8 +53,46 @@
- `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
# Enable reverse proxy mode (e.g. Nginx in front of GLKVM Cloud).
# When enabled, TLS is terminated by the reverse proxy and GLKVM Cloud runs in plain HTTP.
REVERSE_PROXY_ENABLED=false
```
When `REVERSE_PROXY_ENABLED` is set to `true`, GLKVM Cloud is designed to run **behind a reverse proxy** such as Nginx:
- HTTPS certificates are managed by the reverse proxy (not by GLKVM Cloud itself)
- GLKVM Cloud listens on plain HTTP internally
- The same HTTPS port can be used for both:
- Accessing the GLKVM Cloud web UI
- Accessing remote KVM devices
For example, with proper Nginx configuration,
```nginx
# Forward original host, scheme, port and client IP
# These headers are required when running GLKVM Cloud behind a reverse proxy.
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;
```
you can use:
```text
https://www.example.com → GLKVM Cloud web interface
https://<device_id>.example.com → Remote device access
```
Both addresses can share the **same HTTPS port (443)**, while routing is handled by the reverse proxy based on the domain name.
⚠️ **Note:** All configuration should be done in the `.env` file.
⚠️ **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**
+4
View File
@@ -49,11 +49,15 @@ services:
OIDC_ALLOWED_SUBS: ${OIDC_ALLOWED_SUBS:-}
OIDC_ALLOWED_USERNAMES: ${OIDC_ALLOWED_USERNAMES:-}
OIDC_ALLOWED_GROUPS: ${OIDC_ALLOWED_GROUPS:-}
# ---- Reverse Proxy ----
REVERSE_PROXY_ENABLED: ${REVERSE_PROXY_ENABLED:-false}
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:
+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;
}
}
+11
View File
@@ -8,6 +8,7 @@ require (
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
@@ -19,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 (
@@ -26,8 +28,10 @@ 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
@@ -35,6 +39,8 @@ require (
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
@@ -42,6 +48,7 @@ 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
@@ -52,4 +59,8 @@ require (
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
)
+25
View File
@@ -17,6 +17,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
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=
@@ -29,6 +31,10 @@ 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=
@@ -49,6 +55,8 @@ 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=
@@ -72,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=
@@ -103,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=
@@ -199,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=
+74 -6
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())
@@ -333,22 +335,88 @@ 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 {
// 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"
}
// 1) external port: prefer the one user actually accessed (Host or forwarded headers)
port := ""
// Prefer port from Host
if _, p, err := net.SplitHostPort(c.Request.Host); err == nil && p != "" {
port = p
}
// Fallback to forwarded headers
if 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
}
}
}
// 2) If still empty, fallback to cfg.AddrHttpProxy (which is a PORT, not a path)
if port == "" && strings.TrimSpace(cfg.AddrHttpProxy) != "" {
portTmp := strings.TrimSpace(cfg.AddrHttpProxy)
// Common cases: ":10443", "0.0.0.0:10443", "[::]:10443"
if _, p, err := net.SplitHostPort(portTmp); err == nil {
port = p
}
}
// 3) Build host: in proxy mode redirect domain to be redirHost
hostPort := redirHost
if port != "" {
// avoid adding default ports
if (scheme == "https" && port != "443") || (scheme == "http" && port != "80") {
hostPort = net.JoinHostPort(redirHost, port)
}
}
// 4) Path: use the current request path
redirectPath := c.Request.URL.Path
if redirectPath == "" {
redirectPath = "/"
}
u := &url.URL{
Scheme: scheme,
Host: hostPort,
Path: redirectPath,
}
q := u.Query()
q.Set("sid", sid)
u.RawQuery = q.Encode()
location = u.String()
log.Info().Msgf("Using domain redirect (proxy mode): %s", location)
}
}
log.Info().Msgf("Final redirect location: %s", location)
+4
View File
@@ -29,6 +29,7 @@ import (
"encoding/json"
_ "net/http/pprof"
"os"
"rttys/db"
"runtime"
"runtime/debug"
@@ -285,6 +286,9 @@ func cmdAction(c context.Context, cmd *cli.Command) error {
log.Info().Msg("==============================")
}
// Initialize the SQLite database connection
db.Init()
srv := &RttyServer{cfg: cfg}
return srv.Run()
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)
}
+12 -2
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",
@@ -66,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...",
+12 -2
View File
@@ -14,7 +14,9 @@
"cancel": "取消",
"ok": "确定",
"close": "关闭",
"about": "关于"
"about": "关于",
"maxLength": "最大长度为{length}个字符",
"more": "更多"
},
"login": {
"authorizationRequired": "需要授权",
@@ -66,7 +68,15 @@
"errorMessage": "错误信息",
"commandResponseDetail": "命令响应详情",
"standardOutput": "标准输出",
"standardErrorOutput": "标准错误输出"
"standardErrorOutput": "标准错误输出",
"status": "状态",
"online": "在线",
"offline": "离线",
"editDescription": "编辑描述",
"inputDescription": "输入描述",
"requiredDescription": "请输入描述",
"deleteDevice": "删除设备",
"deleteDeviceConfirmTips": "你确定要删除这台设备吗?此操作不可撤销。"
},
"rtty": {
"requestingDeviceToCreateTerminal": "正在请求设备创建终端...",
@@ -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>
+9 -9
View File
@@ -23,38 +23,38 @@ export default defineConfig(({ mode }) => {
port: 3011,
proxy: {
'/devs': {
target: 'https://oidc.clanxie.cn',
target: 'https://107.173.152.173',
secure: false,
},
'/signin': {
target: 'https://oidc.clanxie.cn',
target: 'https://107.173.152.173',
secure: false,
},
'/signout': {
target: 'https://oidc.clanxie.cn',
target: 'https://107.173.152.173',
secure: false,
},
'/alive': {
target: 'https://oidc.clanxie.cn',
target: 'https://107.173.152.173',
secure: false,
},
'/get': {
target: 'https://oidc.clanxie.cn',
target: 'https://107.173.152.173',
secure: false,
},
'^/cmd/.*': {
target: 'https://oidc.clanxie.cn',
target: 'https://107.173.152.173',
secure: false,
},
'^/connect/.*': {
ws: true,
target: 'https://oidc.clanxie.cn',
target: 'https://107.173.152.173',
},
'^/web/*': {
target: 'https://oidc.clanxie.cn',
target: 'https://107.173.152.173',
},
'/auth-config': {
target: 'https://oidc.clanxie.cn',
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), ":", "")
}