mirror of
https://github.com/gl-inet/glkvm-cloud.git
synced 2026-10-04 04:32:22 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 739aa235b3 | |||
| 329468bf61 | |||
| f955f4f46f | |||
| f34e7f3195 | |||
| afd4b19981 | |||
| f76e587ed2 | |||
| 5f8d8f50ca | |||
| 96aa0be17d | |||
| f665ecf432 | |||
| 4991433901 | |||
| 7ed0e263d1 | |||
| 38b35ed80f | |||
| 61172cc9c4 | |||
| 0d1d2b0adf | |||
| c2318c1291 | |||
| 0e5af6fa62 |
@@ -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)
|
||||
|
||||
|
||||
### 🌐 平台访问
|
||||
|
||||
安装完成后,你可以通过以下方式访问平台:
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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=
|
||||
|
||||
Executable
+115
@@ -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>
|
||||
```
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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...",
|
||||
|
||||
@@ -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
@@ -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
@@ -0,0 +1,7 @@
|
||||
package utils
|
||||
|
||||
import "strings"
|
||||
|
||||
func NormalizeMac(mac string) string {
|
||||
return strings.ReplaceAll(strings.ToLower(mac), ":", "")
|
||||
}
|
||||
Reference in New Issue
Block a user