mirror of
https://github.com/gl-inet/glkvm-cloud.git
synced 2026-10-04 12:41:42 +00:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f955f4f46f | |||
| f34e7f3195 | |||
| afd4b19981 | |||
| f76e587ed2 | |||
| 5f8d8f50ca | |||
| 96aa0be17d | |||
| f665ecf432 | |||
| 4991433901 | |||
| 7ed0e263d1 | |||
| 38b35ed80f | |||
| 61172cc9c4 | |||
| 0d1d2b0adf | |||
| c2318c1291 | |||
| 0e5af6fa62 | |||
| 72f1986309 | |||
| e9e773408d | |||
| f6f06582dd | |||
| 1ab54dae68 | |||
| d9a66add94 | |||
| de48a9041e | |||
| a31607ad09 | |||
| a9711565cd | |||
| b29ca0c117 | |||
| 7ce942d0d3 | |||
| 5e16e10e9a | |||
| 3e9e5bb93a | |||
| 3c6c214479 | |||
| ed3da45ddf | |||
| b089169270 | |||
| 2349554c22 | |||
| 77886cf052 | |||
| 9be7a854ce | |||
| 2b6aecf72a | |||
| efa73caf75 |
@@ -36,6 +36,10 @@ build-run: build run
|
||||
# Build frontend, build Go binary, and run
|
||||
full-run: ui build run
|
||||
|
||||
# Build Docker image
|
||||
docker-build: ui build
|
||||
docker build -t glkvm:v1 .
|
||||
# Build Docker image without updating ui
|
||||
docker-build: build
|
||||
docker build -t glkvm-cloud:build .
|
||||
|
||||
# Full Build Docker image
|
||||
docker-fullbuild: ui build
|
||||
docker build -t glkvm-cloud:build .
|
||||
@@ -13,7 +13,9 @@ Self-Deployed Lightweight Cloud is a lightweight KVM remote cloud platform tailo
|
||||
- **Batch Operations** - Batch command execution capabilities
|
||||
- **Rapid Deployment** - Quick self-deployment with simple operations
|
||||
- **Data Security** - Private deployment with full data control
|
||||
- **Dedicated Bandwidth** - Exclusive bandwidth for self-hosted deployments
|
||||
- **Lightweight Design** - Optimized for small businesses and individual users
|
||||
- **Enterprise Authentication** - Supports both **LDAP** and **OIDC** login methods for enterprise users.
|
||||
|
||||
## Self-Hosting Guide
|
||||
|
||||
@@ -34,9 +36,10 @@ The following mainstream operating systems have been tested and verified
|
||||
|
||||
| Component | Minimum Requirement |
|
||||
| :-----------------: | :-----------------: |
|
||||
| CPU | 1 cores or above |
|
||||
| CPU | 1 core or above |
|
||||
| Memory | ≥ 1 GB |
|
||||
| Storage | ≥ 40 GB |
|
||||
| Network Bandwidth | ≥ 3 Mbps |
|
||||
| KVM Device Firmware | ≥ v1.5.0 |
|
||||
|
||||
#### 🔐 Cloud Security Group Settings
|
||||
@@ -56,12 +59,22 @@ If your server provider uses a **cloud security group** (e.g., AWS, Aliyun, etc.
|
||||
------
|
||||
### 📦 Installation
|
||||
|
||||
Run the following command **as root** to install GLKVM Cloud:
|
||||
We provide **two** ways to install GLKVM Cloud:
|
||||
|
||||
#### A) One-line installer (recommended)
|
||||
|
||||
> **Note:** The one-line installer is **Docker-based**. It automates Docker/Compose setup, pulls images, renders configs from templates, and starts services for you.
|
||||
|
||||
Run **as root**:
|
||||
|
||||
```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 manual install
|
||||
|
||||
> Full reference: see [`docker-compose/README.md`](https://github.com/gl-inet/glkvm-cloud/blob/main/docker-compose/README.md)
|
||||
|
||||
### 🌐 Platform Access
|
||||
|
||||
Once the installation is complete, access the platform via:
|
||||
@@ -151,9 +164,51 @@ Replace the following files in:
|
||||
|
||||
⚠️ **Make sure the filenames remain unchanged.**
|
||||
|
||||
#### 🔄 Restart Services After Certificate Replacement
|
||||
#### 🔐 LDAP Authentication Configuration (Optional)
|
||||
|
||||
After replacing the certificates, restart the GLKVM Cloud services to apply the changes:
|
||||
GLKVM Cloud supports LDAP authentication for enterprise environments, allowing you to integrate with existing directory services like Active Directory, OpenLDAP, or FreeIPA.
|
||||
|
||||
**Key Features:**
|
||||
- **Dual Authentication Mode**: Support both LDAP and traditional password authentication simultaneously
|
||||
- **Group-based Authorization**: Restrict access to specific LDAP groups
|
||||
- **User-based Authorization**: Allow access for specific users only
|
||||
- **TLS/SSL Support**: Secure LDAP connections with encryption
|
||||
- **Multiple LDAP Systems**: Compatible with Active Directory, OpenLDAP, FreeIPA, and generic LDAP servers
|
||||
|
||||
**Configuration:**
|
||||
For detailed LDAP configuration options and setup instructions, see the [Docker Compose README](docker-compose/README.md).
|
||||
|
||||
**Note**: When LDAP is enabled, users can choose between:
|
||||
- **LDAP Authentication**: Enter username and password for directory service authentication
|
||||
- **Legacy Authentication**: Leave username empty and use the web management password
|
||||
|
||||
#### 🔐 OIDC Authentication Configuration (Optional)
|
||||
|
||||
GLKVM Cloud provides full support for **OIDC (OpenID Connect)** authentication, allowing seamless integration with modern identity providers such as **Google, Auth0, Authing** and any other standard-compliant OIDC provider.
|
||||
|
||||
**Key Features**
|
||||
|
||||
- **Modern Authentication**
|
||||
Secure sign-in through any OIDC provider supporting Authorization Code Flow.
|
||||
- **Email / Username / Group Whitelisting**
|
||||
Restrict access based on:
|
||||
- Email or domain (e.g. *@example.com*)
|
||||
- Stable user ID (*sub*)
|
||||
- Username (*preferred_username* or *name*)
|
||||
- Groups attribute
|
||||
- **Full OpenID Connect Compliance**
|
||||
Supports issuer validation, token signature verification, and nonce protection.
|
||||
- **Flexible Provider Support**
|
||||
Works with public clouds (Google, Azure AD, Auth0, Okta) and self-hosted solutions.
|
||||
|
||||
**Configuration**
|
||||
|
||||
For detailed OIDC configuration options and setup instructions, see the
|
||||
**[Docker Compose README](docker-compose/README.md)**.
|
||||
|
||||
#### 🔄 Restart Services After Configuration Changes
|
||||
|
||||
After replacing certificates or updating LDAP configuration, restart the GLKVM Cloud services to apply the changes:
|
||||
|
||||
```bash
|
||||
cd ~/glkvm_cloud
|
||||
|
||||
+55
-3
@@ -14,7 +14,9 @@
|
||||
* **批量操作** - 支持批量执行命令
|
||||
* **快速部署** - 简单命令即可完成自部署
|
||||
* **数据安全** - 私有化部署,数据完全可控
|
||||
* **独享带宽** - 自部署环境下可享受专属带宽
|
||||
* **轻量设计** - 专为小型企业和个人优化
|
||||
* **企业级认证** - 同时支持 **LDAP** 和 **OIDC** 登录方式,适用于企业用户。
|
||||
|
||||
## 自部署指南
|
||||
|
||||
@@ -34,10 +36,11 @@
|
||||
#### 系统要求
|
||||
|
||||
| 组件 | 最低配置要求 |
|
||||
| ------------ | ------------ |
|
||||
| :------------: | :------------: |
|
||||
| CPU | 1 核及以上 |
|
||||
| 内存 | ≥ 1 GB |
|
||||
| 存储 | ≥ 40 GB |
|
||||
| 网络带宽 | ≥ 3 Mbps |
|
||||
| KVM 固件版本 | ≥ v1.5.0 |
|
||||
|
||||
#### 🔐 云安全组端口要求
|
||||
@@ -56,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)
|
||||
|
||||
|
||||
### 🌐 平台访问
|
||||
|
||||
安装完成后,你可以通过以下方式访问平台:
|
||||
@@ -147,9 +161,47 @@ Web UI 的默认登录密码会在安装脚本运行结束时显示:
|
||||
* `glkvm.cer`
|
||||
* `glkvm.key`
|
||||
|
||||
---
|
||||
#### 🔐 LDAP 身份认证配置(可选)
|
||||
|
||||
#### 🔄 替换证书后重启服务
|
||||
GLKVM 轻量云支持 LDAP 身份认证,适用于企业环境,可以与现有的目录服务(如 Active Directory、OpenLDAP 或 FreeIPA)集成。
|
||||
|
||||
**主要功能:**
|
||||
|
||||
- **双重认证模式**:同时支持 LDAP 和传统密码认证
|
||||
- **基于组的授权**:限制特定 LDAP 组访问
|
||||
- **基于用户的授权**:仅允许特定用户访问
|
||||
- **TLS/SSL 支持**:加密 LDAP 连接
|
||||
- **多 LDAP 系统支持**:兼容 Active Directory、OpenLDAP、FreeIPA 和通用 LDAP 服务器
|
||||
|
||||
**配置方法:**
|
||||
详细的 LDAP 配置选项和设置说明,请参见 [Docker Compose README](docker-compose/README.md)。
|
||||
|
||||
**注意**:启用 LDAP 后,用户可以选择:
|
||||
- **LDAP 认证**:输入用户名和密码进行目录服务认证
|
||||
- **传统认证**:留空用户名并使用 Web 管理密码
|
||||
|
||||
#### 🔐 OIDC 登录认证配置(可选)
|
||||
|
||||
GLKVM Cloud 完整支持 **OIDC(OpenID Connect)** 登录认证,可无缝集成现代身份提供商,例如 **Google、Auth0、Authing**,以及任何符合 OIDC 标准的认证服务。
|
||||
|
||||
**主要功能**
|
||||
|
||||
- **现代化认证方式**
|
||||
支持使用任意支持 Authorization Code Flow 的 OIDC 身份提供商进行安全登录。
|
||||
- **邮箱 / 用户名 / 用户组白名单控制**
|
||||
可根据以下信息限制用户访问:
|
||||
- 邮箱或域名(如 *@example.com*)
|
||||
- 用户 ID(*sub*)
|
||||
- 用户名(*preferred_username* 或 *name*)
|
||||
- 用户组
|
||||
- **完全符合 OpenID Connect 标准**
|
||||
支持 Issuer 校验、ID Token 签名验证、Nonce 防重放保护等安全机制。
|
||||
- **高度灵活的提供商支持**
|
||||
兼容各类公共云 IdP(Google、Azure AD、Auth0、Okta 等)以及自建身份服务(Keycloak、Authentik、Dex 等)。
|
||||
|
||||
#### 🔄 配置更改后重启服务
|
||||
|
||||
替换证书或更新 LDAP 配置后,需要重启 GLKVM 轻量云服务以应用更改:
|
||||
|
||||
```bash
|
||||
cd ~/glkvm_cloud
|
||||
|
||||
@@ -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{
|
||||
@@ -217,7 +389,9 @@ func (srv *RttyServer) ListenAPI() error {
|
||||
|
||||
r.POST("/signin", func(c *gin.Context) {
|
||||
type credentials struct {
|
||||
Password string `json:"password"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
AuthMethod string `json:"authMethod"`
|
||||
}
|
||||
|
||||
creds := credentials{}
|
||||
@@ -228,17 +402,41 @@ func (srv *RttyServer) ListenAPI() error {
|
||||
return
|
||||
}
|
||||
|
||||
if httpLogin(cfg, creds.Password) {
|
||||
// 自动确定认证方法或使用指定的方法 (Auto-determine auth method or use specified method)
|
||||
authMethod := creds.AuthMethod
|
||||
if authMethod == "" {
|
||||
// 基于是否提供用户名进行自动检测 (Auto-detect based on whether username is provided)
|
||||
if creds.Username != "" && cfg.LdapEnabled {
|
||||
authMethod = "ldap"
|
||||
} else {
|
||||
authMethod = "legacy"
|
||||
}
|
||||
}
|
||||
|
||||
success, errorType := AuthenticateUserWithError(cfg, creds.Username, creds.Password, authMethod)
|
||||
if success {
|
||||
sid := utils.GenUniqueID()
|
||||
|
||||
httpSessions.Set(sid, true, cache.WithEx(httpSessionExpire))
|
||||
|
||||
c.SetCookie("sid", sid, 0, "", "", false, true)
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusUnauthorized)
|
||||
// 根据错误类型返回适当的错误信息 (Return appropriate error message based on error type)
|
||||
if errorType == "authorization" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authorized"})
|
||||
} else {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication failed"})
|
||||
}
|
||||
})
|
||||
|
||||
r.GET("/auth-config", func(c *gin.Context) {
|
||||
authConfig := gin.H{
|
||||
"ldapEnabled": cfg.LdapEnabled,
|
||||
"legacyPassword": cfg.Password != "",
|
||||
"oidcEnabled": cfg.OIDCEnabled,
|
||||
}
|
||||
c.JSON(http.StatusOK, authConfig)
|
||||
})
|
||||
|
||||
r.GET("/alive", func(c *gin.Context) {
|
||||
@@ -249,6 +447,8 @@ func (srv *RttyServer) ListenAPI() error {
|
||||
}
|
||||
})
|
||||
|
||||
// ===== 添加OIDC路由 =====
|
||||
RegisterOIDCRoutes(r, cfg)
|
||||
fs, err := fs.Sub(staticFs, "ui/dist")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -295,15 +495,23 @@ func (srv *RttyServer) ListenAPI() error {
|
||||
host := c.Request.Host
|
||||
hostname, _, err := net.SplitHostPort(host)
|
||||
if err != nil {
|
||||
// 没有端口时直接使用 host
|
||||
hostname = host
|
||||
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
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"hostname": hostname,
|
||||
"hostname": chosen, // reuse the same chosen value
|
||||
"port": cfg.AddrDev,
|
||||
"token": cfg.Token,
|
||||
"webrtcIP": cfg.WebrtcIP,
|
||||
"webrtcIP": chosen, // same as hostname
|
||||
"webrtcPort": cfg.WebrtcPort,
|
||||
"webrtcUsername": cfg.WebrtcUsername,
|
||||
"webrtcPassword": cfg.WebrtcPassword,
|
||||
@@ -332,6 +540,10 @@ func (srv *RttyServer) ListenAPI() error {
|
||||
return r.RunListener(ln)
|
||||
}
|
||||
|
||||
func isIP(addr string) bool {
|
||||
return net.ParseIP(addr) != nil
|
||||
}
|
||||
|
||||
func callUserHookUrl(cfg *Config, c *gin.Context) bool {
|
||||
if cfg.UserHookUrl == "" {
|
||||
return true
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIEVzCCAj+gAwIBAgIRALBXPpFzlydw27SHyzpFKzgwDQYJKoZIhvcNAQELBQAw
|
||||
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
|
||||
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjQwMzEzMDAwMDAw
|
||||
WhcNMjcwMzEyMjM1OTU5WjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg
|
||||
RW5jcnlwdDELMAkGA1UEAxMCRTYwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATZ8Z5G
|
||||
h/ghcWCoJuuj+rnq2h25EqfUJtlRFLFhfHWWvyILOR/VvtEKRqotPEoJhC6+QJVV
|
||||
6RlAN2Z17TJOdwRJ+HB7wxjnzvdxEP6sdNgA1O1tHHMWMxCcOrLqbGL0vbijgfgw
|
||||
gfUwDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcD
|
||||
ATASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSTJ0aYA6lRaI6Y1sRCSNsj
|
||||
v1iU0jAfBgNVHSMEGDAWgBR5tFnme7bl5AFzgAiIyBpY9umbbjAyBggrBgEFBQcB
|
||||
AQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly94MS5pLmxlbmNyLm9yZy8wEwYDVR0g
|
||||
BAwwCjAIBgZngQwBAgEwJwYDVR0fBCAwHjAcoBqgGIYWaHR0cDovL3gxLmMubGVu
|
||||
Y3Iub3JnLzANBgkqhkiG9w0BAQsFAAOCAgEAfYt7SiA1sgWGCIpunk46r4AExIRc
|
||||
MxkKgUhNlrrv1B21hOaXN/5miE+LOTbrcmU/M9yvC6MVY730GNFoL8IhJ8j8vrOL
|
||||
pMY22OP6baS1k9YMrtDTlwJHoGby04ThTUeBDksS9RiuHvicZqBedQdIF65pZuhp
|
||||
eDcGBcLiYasQr/EO5gxxtLyTmgsHSOVSBcFOn9lgv7LECPq9i7mfH3mpxgrRKSxH
|
||||
pOoZ0KXMcB+hHuvlklHntvcI0mMMQ0mhYj6qtMFStkF1RpCG3IPdIwpVCQqu8GV7
|
||||
s8ubknRzs+3C/Bm19RFOoiPpDkwvyNfvmQ14XkyqqKK5oZ8zhD32kFRQkxa8uZSu
|
||||
h4aTImFxknu39waBxIRXE4jKxlAmQc4QjFZoq1KmQqQg0J/1JF8RlFvJas1VcjLv
|
||||
YlvUB2t6npO6oQjB3l+PNf0DpQH7iUx3Wz5AjQCi6L25FjyE06q6BZ/QlmtYdl/8
|
||||
ZYao4SRqPEs/6cAiF+Qf5zg2UkaWtDphl1LKMuTNLotvsX99HP69V2faNyegodQ0
|
||||
LyTApr/vT01YPE46vNsDLgK+4cL6TrzC/a4WcmF5SRJ938zrv/duJHLXQIku5v0+
|
||||
EwOy59Hdm0PT/Er/84dDV0CSjdR/2XuZM3kpysSKLgD1cKiDA+IRguODCxfO9cyY
|
||||
Ig46v9mFmBvyH04=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -1,22 +0,0 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDkzCCAxmgAwIBAgISBQsXxrfK6EpotI2fXWteV3kMMAoGCCqGSM49BAMDMDIx
|
||||
CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJF
|
||||
NjAeFw0yNTA3MjgwNTM5MjNaFw0yNTEwMjYwNTM5MjJaMBcxFTATBgNVBAMTDGNs
|
||||
YW54aWUubGlmZTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABKcExKR+nZ21Hpm6
|
||||
rqR/QuJ35yRwhBtTim+5cColIDIocjasMhB1ho49IINEa/hp5EHqTsMsgSkGEZ6R
|
||||
unr3D0qjggIoMIICJDAOBgNVHQ8BAf8EBAMCB4AwHQYDVR0lBBYwFAYIKwYBBQUH
|
||||
AwEGCCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFKlsFBhG/CpyaZvz
|
||||
3/gFZdP0vxsWMB8GA1UdIwQYMBaAFJMnRpgDqVFojpjWxEJI2yO/WJTSMDIGCCsG
|
||||
AQUFBwEBBCYwJDAiBggrBgEFBQcwAoYWaHR0cDovL2U2LmkubGVuY3Iub3JnLzAn
|
||||
BgNVHREEIDAegg4qLmNsYW54aWUubGlmZYIMY2xhbnhpZS5saWZlMBMGA1UdIAQM
|
||||
MAowCAYGZ4EMAQIBMC0GA1UdHwQmMCQwIqAgoB6GHGh0dHA6Ly9lNi5jLmxlbmNy
|
||||
Lm9yZy8xMy5jcmwwggECBgorBgEEAdZ5AgQCBIHzBIHwAO4AdQDtPEvW6AbCpKIA
|
||||
V9vLJOI4Ad9RL+3EhsVwDyDdtz4/4AAAAZhPwIK1AAAEAwBGMEQCIGoi+WQh9Bhb
|
||||
4f68AFmojUBHdj0cJhH8Wgv26QxqKUXZAiAyrxtJ9viGLjivjDUPm8NjmgquuLeH
|
||||
UAMkhSdvZmKK1QB1AA3h8jAr0w3BQGISCepVLvxHdHyx1+kw7w5CHrR+Tqo0AAAB
|
||||
mE/AmiYAAAQDAEYwRAIgbXMFtU65Mrr1ZaQSdX3Jc+5YO1Y/yApwx7vbGKOzriQC
|
||||
IFgfpun9rGzPSXUBW+oEjkvUeN1Yf2Thu4YIvkUfuceiMAoGCCqGSM49BAMDA2gA
|
||||
MGUCMCU9wGtvD/A/LqIdeNFtS8/Um7Sv0iyAn8JGcAu/GCI2oavEQk90mPvqMuqX
|
||||
W2rUQgIxANHH0WTA3121/bK4Rhu5w8kXUI8AWnxizZ4hDjGCKi6WmJCGhLqtleYK
|
||||
hhrMK8WjmQ==
|
||||
-----END CERTIFICATE-----
|
||||
@@ -1,15 +0,0 @@
|
||||
Le_Domain='clanxie.life'
|
||||
Le_Alt='*.clanxie.life'
|
||||
Le_Webroot='dns_cf'
|
||||
Le_PreHook=''
|
||||
Le_PostHook=''
|
||||
Le_RenewHook=''
|
||||
Le_API='https://acme-v02.api.letsencrypt.org/directory'
|
||||
Le_Keylength='ec-256'
|
||||
Le_OrderFinalize='https://acme-v02.api.letsencrypt.org/acme/finalize/2400063957/411715523371'
|
||||
Le_LinkOrder='https://acme-v02.api.letsencrypt.org/acme/order/2400063957/411715523371'
|
||||
Le_LinkCert='https://acme-v02.api.letsencrypt.org/acme/cert/050b17c6b7cae84a68b48d9f5d6b5e57790c'
|
||||
Le_CertCreateTime='1753684679'
|
||||
Le_CertCreateTimeStr='2025-07-28T06:37:59Z'
|
||||
Le_NextRenewTimeStr='2025-09-25T06:37:59Z'
|
||||
Le_NextRenewTime='1758782279'
|
||||
@@ -1,9 +0,0 @@
|
||||
-----BEGIN CERTIFICATE REQUEST-----
|
||||
MIIBKzCB0gIBADAXMRUwEwYDVQQDDAxjbGFueGllLmxpZmUwWTATBgcqhkjOPQIB
|
||||
BggqhkjOPQMBBwNCAASnBMSkfp2dtR6Zuq6kf0Lid+ckcIQbU4pvuXAqJSAyKHI2
|
||||
rDIQdYaOPSCDRGv4aeRB6k7DLIEpBhGekbp69w9KoFkwVwYJKoZIhvcNAQkOMUow
|
||||
SDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwJwYDVR0RBCAwHoIMY2xh
|
||||
bnhpZS5saWZlgg4qLmNsYW54aWUubGlmZTAKBggqhkjOPQQDAgNIADBFAiEAvyNm
|
||||
66wN/4Ni5cLfH8KpwHcgoVGXDoVAawiVaMdmsD0CIAKsdhG5SWWqvNYhYjKIVKbh
|
||||
Fv8cJ8R1rsaQPO98zj5k
|
||||
-----END CERTIFICATE REQUEST-----
|
||||
@@ -1,8 +0,0 @@
|
||||
[ req_distinguished_name ]
|
||||
[ req ]
|
||||
distinguished_name = req_distinguished_name
|
||||
req_extensions = v3_req
|
||||
[ v3_req ]
|
||||
extendedKeyUsage=serverAuth,clientAuth
|
||||
|
||||
subjectAltName=DNS:clanxie.life,DNS:*.clanxie.life
|
||||
@@ -1,5 +0,0 @@
|
||||
-----BEGIN EC PRIVATE KEY-----
|
||||
MHcCAQEEIJW3lmfzDnyLpgkX0Jlu0J3Bo1OyVa610GHAdvQmah8loAoGCCqGSM49
|
||||
AwEHoUQDQgAEpwTEpH6dnbUembqupH9C4nfnJHCEG1OKb7lwKiUgMihyNqwyEHWG
|
||||
jj0gg0Rr+GnkQepOwyyBKQYRnpG6evcPSg==
|
||||
-----END EC PRIVATE KEY-----
|
||||
@@ -1,49 +0,0 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDkzCCAxmgAwIBAgISBQsXxrfK6EpotI2fXWteV3kMMAoGCCqGSM49BAMDMDIx
|
||||
CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJF
|
||||
NjAeFw0yNTA3MjgwNTM5MjNaFw0yNTEwMjYwNTM5MjJaMBcxFTATBgNVBAMTDGNs
|
||||
YW54aWUubGlmZTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABKcExKR+nZ21Hpm6
|
||||
rqR/QuJ35yRwhBtTim+5cColIDIocjasMhB1ho49IINEa/hp5EHqTsMsgSkGEZ6R
|
||||
unr3D0qjggIoMIICJDAOBgNVHQ8BAf8EBAMCB4AwHQYDVR0lBBYwFAYIKwYBBQUH
|
||||
AwEGCCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFKlsFBhG/CpyaZvz
|
||||
3/gFZdP0vxsWMB8GA1UdIwQYMBaAFJMnRpgDqVFojpjWxEJI2yO/WJTSMDIGCCsG
|
||||
AQUFBwEBBCYwJDAiBggrBgEFBQcwAoYWaHR0cDovL2U2LmkubGVuY3Iub3JnLzAn
|
||||
BgNVHREEIDAegg4qLmNsYW54aWUubGlmZYIMY2xhbnhpZS5saWZlMBMGA1UdIAQM
|
||||
MAowCAYGZ4EMAQIBMC0GA1UdHwQmMCQwIqAgoB6GHGh0dHA6Ly9lNi5jLmxlbmNy
|
||||
Lm9yZy8xMy5jcmwwggECBgorBgEEAdZ5AgQCBIHzBIHwAO4AdQDtPEvW6AbCpKIA
|
||||
V9vLJOI4Ad9RL+3EhsVwDyDdtz4/4AAAAZhPwIK1AAAEAwBGMEQCIGoi+WQh9Bhb
|
||||
4f68AFmojUBHdj0cJhH8Wgv26QxqKUXZAiAyrxtJ9viGLjivjDUPm8NjmgquuLeH
|
||||
UAMkhSdvZmKK1QB1AA3h8jAr0w3BQGISCepVLvxHdHyx1+kw7w5CHrR+Tqo0AAAB
|
||||
mE/AmiYAAAQDAEYwRAIgbXMFtU65Mrr1ZaQSdX3Jc+5YO1Y/yApwx7vbGKOzriQC
|
||||
IFgfpun9rGzPSXUBW+oEjkvUeN1Yf2Thu4YIvkUfuceiMAoGCCqGSM49BAMDA2gA
|
||||
MGUCMCU9wGtvD/A/LqIdeNFtS8/Um7Sv0iyAn8JGcAu/GCI2oavEQk90mPvqMuqX
|
||||
W2rUQgIxANHH0WTA3121/bK4Rhu5w8kXUI8AWnxizZ4hDjGCKi6WmJCGhLqtleYK
|
||||
hhrMK8WjmQ==
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIEVzCCAj+gAwIBAgIRALBXPpFzlydw27SHyzpFKzgwDQYJKoZIhvcNAQELBQAw
|
||||
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
|
||||
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjQwMzEzMDAwMDAw
|
||||
WhcNMjcwMzEyMjM1OTU5WjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg
|
||||
RW5jcnlwdDELMAkGA1UEAxMCRTYwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATZ8Z5G
|
||||
h/ghcWCoJuuj+rnq2h25EqfUJtlRFLFhfHWWvyILOR/VvtEKRqotPEoJhC6+QJVV
|
||||
6RlAN2Z17TJOdwRJ+HB7wxjnzvdxEP6sdNgA1O1tHHMWMxCcOrLqbGL0vbijgfgw
|
||||
gfUwDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcD
|
||||
ATASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSTJ0aYA6lRaI6Y1sRCSNsj
|
||||
v1iU0jAfBgNVHSMEGDAWgBR5tFnme7bl5AFzgAiIyBpY9umbbjAyBggrBgEFBQcB
|
||||
AQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly94MS5pLmxlbmNyLm9yZy8wEwYDVR0g
|
||||
BAwwCjAIBgZngQwBAgEwJwYDVR0fBCAwHjAcoBqgGIYWaHR0cDovL3gxLmMubGVu
|
||||
Y3Iub3JnLzANBgkqhkiG9w0BAQsFAAOCAgEAfYt7SiA1sgWGCIpunk46r4AExIRc
|
||||
MxkKgUhNlrrv1B21hOaXN/5miE+LOTbrcmU/M9yvC6MVY730GNFoL8IhJ8j8vrOL
|
||||
pMY22OP6baS1k9YMrtDTlwJHoGby04ThTUeBDksS9RiuHvicZqBedQdIF65pZuhp
|
||||
eDcGBcLiYasQr/EO5gxxtLyTmgsHSOVSBcFOn9lgv7LECPq9i7mfH3mpxgrRKSxH
|
||||
pOoZ0KXMcB+hHuvlklHntvcI0mMMQ0mhYj6qtMFStkF1RpCG3IPdIwpVCQqu8GV7
|
||||
s8ubknRzs+3C/Bm19RFOoiPpDkwvyNfvmQ14XkyqqKK5oZ8zhD32kFRQkxa8uZSu
|
||||
h4aTImFxknu39waBxIRXE4jKxlAmQc4QjFZoq1KmQqQg0J/1JF8RlFvJas1VcjLv
|
||||
YlvUB2t6npO6oQjB3l+PNf0DpQH7iUx3Wz5AjQCi6L25FjyE06q6BZ/QlmtYdl/8
|
||||
ZYao4SRqPEs/6cAiF+Qf5zg2UkaWtDphl1LKMuTNLotvsX99HP69V2faNyegodQ0
|
||||
LyTApr/vT01YPE46vNsDLgK+4cL6TrzC/a4WcmF5SRJ938zrv/duJHLXQIku5v0+
|
||||
EwOy59Hdm0PT/Er/84dDV0CSjdR/2XuZM3kpysSKLgD1cKiDA+IRguODCxfO9cyY
|
||||
Ig46v9mFmBvyH04=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -25,126 +25,271 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/kylelemons/go-gypsy/yaml"
|
||||
"github.com/urfave/cli/v3"
|
||||
"github.com/kylelemons/go-gypsy/yaml"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AddrDev string
|
||||
AddrUser string
|
||||
AddrHttpProxy string
|
||||
HttpProxyRedirURL string
|
||||
HttpProxyRedirDomain string
|
||||
Token string
|
||||
DevHookUrl string
|
||||
UserHookUrl string
|
||||
LocalAuth bool
|
||||
Password string
|
||||
AllowOrigins bool
|
||||
PprofAddr string
|
||||
SslCert string
|
||||
SslKey string
|
||||
WebrtcIP string
|
||||
WebrtcPort string
|
||||
WebrtcUsername string
|
||||
WebrtcPassword string
|
||||
AddrDev string
|
||||
AddrUser string
|
||||
AddrHttpProxy string
|
||||
HttpProxyRedirURL string
|
||||
HttpProxyRedirDomain string
|
||||
Token string
|
||||
DevHookUrl string
|
||||
UserHookUrl string
|
||||
LocalAuth bool
|
||||
Password string
|
||||
AllowOrigins bool
|
||||
PprofAddr string
|
||||
SslCert string
|
||||
SslKey string
|
||||
WebrtcIP string
|
||||
WebrtcPort string
|
||||
WebrtcUsername string
|
||||
WebrtcPassword string
|
||||
// // LDAP Configuration
|
||||
LdapEnabled bool
|
||||
LdapServer string
|
||||
LdapPort int
|
||||
LdapUseTLS bool
|
||||
LdapBindDN string
|
||||
LdapBindPassword string
|
||||
LdapBaseDN string
|
||||
LdapUserFilter string
|
||||
LdapAllowedGroups string
|
||||
LdapAllowedUsers string
|
||||
|
||||
// Generic OIDC Provider (supports any standard OIDC provider)
|
||||
OIDCEnabled bool
|
||||
OIDCGenericIssuer string
|
||||
OIDCGenericClientID string
|
||||
OIDCGenericClientSecret string
|
||||
OIDCGenericAuthURL string
|
||||
OIDCGenericTokenURL string
|
||||
OIDCGenericRedirectURL string
|
||||
OIDCGenericScopes []string
|
||||
OIDCGenericAllowedUsers []string
|
||||
OIDCGenericAllowedSubs []string
|
||||
OIDCGenericAllowedUsernames []string
|
||||
OIDCGenericAllowedGroups []string
|
||||
}
|
||||
|
||||
// docker mode fixed path for reading certificate
|
||||
const (
|
||||
SslCert = "/home/certificate/glkvm_cer"
|
||||
SslKey = "/home/certificate/glkvm_key"
|
||||
SslCert = "/home/certificate/glkvm_cer"
|
||||
SslKey = "/home/certificate/glkvm_key"
|
||||
)
|
||||
|
||||
func (cfg *Config) Parse(c *cli.Command) error {
|
||||
conf := c.String("conf")
|
||||
if conf != "" {
|
||||
err := parseYamlCfg(cfg, conf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
conf := c.String("conf")
|
||||
if conf != "" {
|
||||
err := parseYamlCfg(cfg, conf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
getFlagOpt(c, "addr-dev", &cfg.AddrDev)
|
||||
getFlagOpt(c, "addr-user", &cfg.AddrUser)
|
||||
getFlagOpt(c, "addr-http-proxy", &cfg.AddrHttpProxy)
|
||||
getFlagOpt(c, "http-proxy-redir-url", &cfg.HttpProxyRedirURL)
|
||||
getFlagOpt(c, "http-proxy-redir-domain", &cfg.HttpProxyRedirDomain)
|
||||
getFlagOpt(c, "dev-hook-url", &cfg.DevHookUrl)
|
||||
getFlagOpt(c, "user-hook-url", &cfg.UserHookUrl)
|
||||
getFlagOpt(c, "local-auth", &cfg.LocalAuth)
|
||||
getFlagOpt(c, "token", &cfg.Token)
|
||||
getFlagOpt(c, "password", &cfg.Password)
|
||||
getFlagOpt(c, "allow-origins", &cfg.AllowOrigins)
|
||||
getFlagOpt(c, "pprof", &cfg.PprofAddr)
|
||||
getFlagOpt(c, "addr-dev", &cfg.AddrDev)
|
||||
getFlagOpt(c, "addr-user", &cfg.AddrUser)
|
||||
getFlagOpt(c, "addr-http-proxy", &cfg.AddrHttpProxy)
|
||||
getFlagOpt(c, "http-proxy-redir-url", &cfg.HttpProxyRedirURL)
|
||||
getFlagOpt(c, "http-proxy-redir-domain", &cfg.HttpProxyRedirDomain)
|
||||
getFlagOpt(c, "dev-hook-url", &cfg.DevHookUrl)
|
||||
getFlagOpt(c, "user-hook-url", &cfg.UserHookUrl)
|
||||
getFlagOpt(c, "local-auth", &cfg.LocalAuth)
|
||||
getFlagOpt(c, "token", &cfg.Token)
|
||||
getFlagOpt(c, "password", &cfg.Password)
|
||||
getFlagOpt(c, "allow-origins", &cfg.AllowOrigins)
|
||||
getFlagOpt(c, "pprof", &cfg.PprofAddr)
|
||||
|
||||
cfg.SslCert = SslCert
|
||||
cfg.SslKey = SslKey
|
||||
cfg.SslCert = SslCert
|
||||
cfg.SslKey = SslKey
|
||||
|
||||
getFlagOpt(c, "webrtc-ip", &cfg.WebrtcIP)
|
||||
getFlagOpt(c, "webrtc-port", &cfg.WebrtcPort)
|
||||
getFlagOpt(c, "webrtc-username", &cfg.WebrtcUsername)
|
||||
getFlagOpt(c, "webrtc-password", &cfg.WebrtcPassword)
|
||||
getFlagOpt(c, "webrtc-ip", &cfg.WebrtcIP)
|
||||
getFlagOpt(c, "webrtc-port", &cfg.WebrtcPort)
|
||||
getFlagOpt(c, "webrtc-username", &cfg.WebrtcUsername)
|
||||
getFlagOpt(c, "webrtc-password", &cfg.WebrtcPassword)
|
||||
|
||||
return nil
|
||||
// LDAP configuration flags
|
||||
getFlagOpt(c, "ldap-enabled", &cfg.LdapEnabled)
|
||||
getFlagOpt(c, "ldap-server", &cfg.LdapServer)
|
||||
getFlagOpt(c, "ldap-port", &cfg.LdapPort)
|
||||
getFlagOpt(c, "ldap-use-tls", &cfg.LdapUseTLS)
|
||||
getFlagOpt(c, "ldap-bind-dn", &cfg.LdapBindDN)
|
||||
getFlagOpt(c, "ldap-bind-password", &cfg.LdapBindPassword)
|
||||
getFlagOpt(c, "ldap-base-dn", &cfg.LdapBaseDN)
|
||||
getFlagOpt(c, "ldap-user-filter", &cfg.LdapUserFilter)
|
||||
getFlagOpt(c, "ldap-allowed-groups", &cfg.LdapAllowedGroups)
|
||||
getFlagOpt(c, "ldap-allowed-users", &cfg.LdapAllowedUsers)
|
||||
|
||||
// Generic OIDC Provider (standard OIDC provider flags)
|
||||
getFlagOpt(c, "oidc-enabled", &cfg.OIDCEnabled)
|
||||
getFlagOpt(c, "oidc-generic-issuer", &cfg.OIDCGenericIssuer)
|
||||
getFlagOpt(c, "oidc-generic-client-id", &cfg.OIDCGenericClientID)
|
||||
getFlagOpt(c, "oidc-generic-client-secret", &cfg.OIDCGenericClientSecret)
|
||||
getFlagOpt(c, "oidc-generic-auth-url", &cfg.OIDCGenericAuthURL)
|
||||
getFlagOpt(c, "oidc-generic-token-url", &cfg.OIDCGenericTokenURL)
|
||||
getFlagOpt(c, "oidc-generic-redirect-url", &cfg.OIDCGenericRedirectURL)
|
||||
getFlagOpt(c, "oidc-generic-scopes", &cfg.OIDCGenericScopes)
|
||||
getFlagOpt(c, "oidc-generic-allowed-users", &cfg.OIDCGenericAllowedUsers)
|
||||
getFlagOpt(c, "oidc-generic-allowed-subs", &cfg.OIDCGenericAllowedSubs)
|
||||
getFlagOpt(c, "oidc-generic-allowed-usernames", &cfg.OIDCGenericAllowedUsernames)
|
||||
getFlagOpt(c, "oidc-generic-allowed-groups", &cfg.OIDCGenericAllowedGroups)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getConfigOpt(yamlCfg *yaml.File, name string, opt any) {
|
||||
val, err := yamlCfg.Get(name)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
val, err := yamlCfg.Get(name)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch opt := opt.(type) {
|
||||
case *string:
|
||||
*opt = val
|
||||
case *int:
|
||||
*opt, _ = strconv.Atoi(val)
|
||||
case *bool:
|
||||
*opt, _ = strconv.ParseBool(val)
|
||||
}
|
||||
switch opt := opt.(type) {
|
||||
case *string:
|
||||
*opt = val
|
||||
case *int:
|
||||
*opt, _ = strconv.Atoi(val)
|
||||
case *bool:
|
||||
*opt, _ = strconv.ParseBool(val)
|
||||
}
|
||||
}
|
||||
|
||||
func parseYamlCfg(cfg *Config, conf string) error {
|
||||
yamlCfg, err := yaml.ReadFile(conf)
|
||||
if err != nil {
|
||||
return fmt.Errorf(`read config file: %s`, err.Error())
|
||||
}
|
||||
yamlCfg, err := yaml.ReadFile(conf)
|
||||
if err != nil {
|
||||
return fmt.Errorf(`read config file: %s`, err.Error())
|
||||
}
|
||||
|
||||
getConfigOpt(yamlCfg, "addr-dev", &cfg.AddrDev)
|
||||
getConfigOpt(yamlCfg, "addr-user", &cfg.AddrUser)
|
||||
getConfigOpt(yamlCfg, "addr-http-proxy", &cfg.AddrHttpProxy)
|
||||
getConfigOpt(yamlCfg, "http-proxy-redir-url", &cfg.HttpProxyRedirURL)
|
||||
getConfigOpt(yamlCfg, "http-proxy-redir-domain", &cfg.HttpProxyRedirDomain)
|
||||
getConfigOpt(yamlCfg, "addr-dev", &cfg.AddrDev)
|
||||
getConfigOpt(yamlCfg, "addr-user", &cfg.AddrUser)
|
||||
getConfigOpt(yamlCfg, "addr-http-proxy", &cfg.AddrHttpProxy)
|
||||
getConfigOpt(yamlCfg, "http-proxy-redir-url", &cfg.HttpProxyRedirURL)
|
||||
getConfigOpt(yamlCfg, "http-proxy-redir-domain", &cfg.HttpProxyRedirDomain)
|
||||
|
||||
getConfigOpt(yamlCfg, "token", &cfg.Token)
|
||||
getConfigOpt(yamlCfg, "dev-hook-url", &cfg.DevHookUrl)
|
||||
getConfigOpt(yamlCfg, "user-hook-url", &cfg.UserHookUrl)
|
||||
getConfigOpt(yamlCfg, "local-auth", &cfg.LocalAuth)
|
||||
getConfigOpt(yamlCfg, "password", &cfg.Password)
|
||||
getConfigOpt(yamlCfg, "allow-origins", &cfg.AllowOrigins)
|
||||
getConfigOpt(yamlCfg, "token", &cfg.Token)
|
||||
getConfigOpt(yamlCfg, "dev-hook-url", &cfg.DevHookUrl)
|
||||
getConfigOpt(yamlCfg, "user-hook-url", &cfg.UserHookUrl)
|
||||
getConfigOpt(yamlCfg, "local-auth", &cfg.LocalAuth)
|
||||
getConfigOpt(yamlCfg, "password", &cfg.Password)
|
||||
getConfigOpt(yamlCfg, "allow-origins", &cfg.AllowOrigins)
|
||||
|
||||
getConfigOpt(yamlCfg, "webrtc-ip", &cfg.WebrtcIP)
|
||||
getConfigOpt(yamlCfg, "webrtc-port", &cfg.WebrtcPort)
|
||||
getConfigOpt(yamlCfg, "webrtc-username", &cfg.WebrtcUsername)
|
||||
getConfigOpt(yamlCfg, "webrtc-password", &cfg.WebrtcPassword)
|
||||
return nil
|
||||
getConfigOpt(yamlCfg, "webrtc-ip", &cfg.WebrtcIP)
|
||||
getConfigOpt(yamlCfg, "webrtc-port", &cfg.WebrtcPort)
|
||||
getConfigOpt(yamlCfg, "webrtc-username", &cfg.WebrtcUsername)
|
||||
getConfigOpt(yamlCfg, "webrtc-password", &cfg.WebrtcPassword)
|
||||
|
||||
// LDAP配置 (LDAP Configuration)
|
||||
getConfigOpt(yamlCfg, "ldap-enabled", &cfg.LdapEnabled)
|
||||
getConfigOpt(yamlCfg, "ldap-server", &cfg.LdapServer)
|
||||
getConfigOpt(yamlCfg, "ldap-port", &cfg.LdapPort)
|
||||
getConfigOpt(yamlCfg, "ldap-use-tls", &cfg.LdapUseTLS)
|
||||
getConfigOpt(yamlCfg, "ldap-bind-dn", &cfg.LdapBindDN)
|
||||
|
||||
// Note: ldap-bind-password is intentionally not read from YAML to avoid special character parsing issues and for security.
|
||||
// It's always read directly from the LDAP_BIND_PASSWORD environment variable below
|
||||
getConfigOpt(yamlCfg, "ldap-base-dn", &cfg.LdapBaseDN)
|
||||
getConfigOpt(yamlCfg, "ldap-user-filter", &cfg.LdapUserFilter)
|
||||
getConfigOpt(yamlCfg, "ldap-allowed-groups", &cfg.LdapAllowedGroups)
|
||||
getConfigOpt(yamlCfg, "ldap-allowed-users", &cfg.LdapAllowedUsers)
|
||||
|
||||
// LDAP password is always read from environment variable to avoid YAML special character parsing issues and for security.
|
||||
if envPassword := os.Getenv("LDAP_BIND_PASSWORD"); envPassword != "" {
|
||||
cfg.LdapBindPassword = envPassword
|
||||
}
|
||||
|
||||
// ===== OIDC configuration (generic OIDC provider) =====
|
||||
// Switch and basic endpoints
|
||||
getConfigOpt(yamlCfg, "oidc-enabled", &cfg.OIDCEnabled)
|
||||
getConfigOpt(yamlCfg, "oidc-generic-issuer", &cfg.OIDCGenericIssuer)
|
||||
getConfigOpt(yamlCfg, "oidc-generic-client-id", &cfg.OIDCGenericClientID)
|
||||
|
||||
// Note: oidc-generic-client-secret is intentionally not read from YAML
|
||||
// to avoid checking secrets into config files and leaking in logs.
|
||||
// It is always read directly from the OIDC_CLIENT_SECRET environment variable below.
|
||||
if envSecret := os.Getenv("OIDC_CLIENT_SECRET"); envSecret != "" {
|
||||
cfg.OIDCGenericClientSecret = envSecret
|
||||
}
|
||||
|
||||
getConfigOpt(yamlCfg, "oidc-generic-auth-url", &cfg.OIDCGenericAuthURL)
|
||||
getConfigOpt(yamlCfg, "oidc-generic-token-url", &cfg.OIDCGenericTokenURL)
|
||||
getConfigOpt(yamlCfg, "oidc-generic-redirect-url", &cfg.OIDCGenericRedirectURL)
|
||||
|
||||
// OIDC scopes (string can be space- or comma-separated, parsed by splitScopes)
|
||||
if s, err := yamlCfg.Get("oidc-generic-scopes"); err == nil && strings.TrimSpace(s) != "" {
|
||||
cfg.OIDCGenericScopes = splitScopes(s)
|
||||
}
|
||||
// Default scopes (when OIDC is enabled but scopes are still empty)
|
||||
if cfg.OIDCEnabled && len(cfg.OIDCGenericScopes) == 0 {
|
||||
cfg.OIDCGenericScopes = []string{"openid", "profile", "email"}
|
||||
}
|
||||
|
||||
// Whitelists for OIDC logins, all parsed via splitScopes (space/comma/newline separated)
|
||||
// 1) Email-based whitelist
|
||||
if s, err := yamlCfg.Get("oidc-generic-allowed-users"); err == nil && strings.TrimSpace(s) != "" {
|
||||
cfg.OIDCGenericAllowedUsers = splitScopes(s)
|
||||
}
|
||||
|
||||
// 2) Subject (sub) whitelist
|
||||
if s, err := yamlCfg.Get("oidc-generic-allowed-subs"); err == nil && strings.TrimSpace(s) != "" {
|
||||
cfg.OIDCGenericAllowedSubs = splitScopes(s)
|
||||
}
|
||||
|
||||
// 3) Username whitelist (preferred_username / name)
|
||||
if s, err := yamlCfg.Get("oidc-generic-allowed-usernames"); err == nil && strings.TrimSpace(s) != "" {
|
||||
cfg.OIDCGenericAllowedUsernames = splitScopes(s)
|
||||
}
|
||||
|
||||
// 4) Groups whitelist
|
||||
if s, err := yamlCfg.Get("oidc-generic-allowed-groups"); err == nil && strings.TrimSpace(s) != "" {
|
||||
cfg.OIDCGenericAllowedGroups = splitScopes(s)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getFlagOpt(c *cli.Command, name string, opt any) {
|
||||
if !c.IsSet(name) {
|
||||
return
|
||||
}
|
||||
if !c.IsSet(name) {
|
||||
return
|
||||
}
|
||||
|
||||
switch opt := opt.(type) {
|
||||
case *string:
|
||||
*opt = c.String(name)
|
||||
case *int:
|
||||
*opt = c.Int(name)
|
||||
case *bool:
|
||||
*opt = c.Bool(name)
|
||||
}
|
||||
switch opt := opt.(type) {
|
||||
case *string:
|
||||
*opt = c.String(name)
|
||||
case *int:
|
||||
*opt = c.Int(name)
|
||||
case *bool:
|
||||
*opt = c.Bool(name)
|
||||
}
|
||||
}
|
||||
|
||||
// splitScopes splits a whitespace/comma/newline-separated scope string
|
||||
// into a deduplicated, cleaned string slice.
|
||||
func splitScopes(s string) []string {
|
||||
// Replace commas with spaces to unify delimiters
|
||||
s = strings.ReplaceAll(s, ",", " ")
|
||||
// Remove optional YAML list characters such as brackets (lenient parsing)
|
||||
s = strings.NewReplacer("[", " ", "]", " ").Replace(s)
|
||||
|
||||
parts := strings.Fields(s)
|
||||
uniq := make([]string, 0, len(parts))
|
||||
seen := make(map[string]struct{}, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" || p == "-" { // Support accidental "-" items
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[p]; ok {
|
||||
continue
|
||||
}
|
||||
seen[p] = struct{}{}
|
||||
uniq = append(uniq, p)
|
||||
}
|
||||
return uniq
|
||||
}
|
||||
|
||||
Executable
+16
@@ -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))
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
rttys:
|
||||
container_name: glkvm_cloud
|
||||
image: glkvm:v1
|
||||
ports:
|
||||
- "443:443"
|
||||
- "10443:10443"
|
||||
- "5912:5912"
|
||||
volumes:
|
||||
- ./rttys.conf:/home/rttys.conf:ro
|
||||
- ./certificate/glkvm.cer:/home/certificate/glkvm_cer:ro
|
||||
- ./certificate/glkvm.key:/home/certificate/glkvm_key:ro
|
||||
command: ["-c", "/home/rttys.conf"]
|
||||
restart: always
|
||||
|
||||
coturn:
|
||||
image: coturn/coturn:edge-alpine
|
||||
container_name: glkvm_coturn
|
||||
restart: always
|
||||
ports:
|
||||
- "3478:3478"
|
||||
- "3478:3478/udp"
|
||||
- "5349:5349"
|
||||
- "5349:5349/udp"
|
||||
volumes:
|
||||
- ./turnserver.conf:/etc/turnserver.conf:ro
|
||||
command: ["-c", "/etc/turnserver.conf"]
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
# Images
|
||||
GLKVM_IMAGE=glzhitong/glkvm-cloud:latest
|
||||
COTURN_IMAGE=coturn/coturn:edge-alpine
|
||||
|
||||
# GLKVM access IP seen by devices/users.
|
||||
# Leave empty to auto-detect at container start.
|
||||
GLKVM_ACCESS_IP=
|
||||
|
||||
# rttys
|
||||
RTTYS_TOKEN=DeviceTokenYouCanChangeMe
|
||||
RTTYS_PASS=StrongP@ssw0rd
|
||||
RTTYS_DEVICE_PORT=5912
|
||||
RTTYS_WEBUI_PORT=443
|
||||
RTTYS_HTTP_PROXY_PORT=10443
|
||||
|
||||
# TURN
|
||||
TURN_PORT=3478
|
||||
TURN_USER=glkvmcloudwebrtcuser
|
||||
TURN_PASS=AnotherS3cret
|
||||
|
||||
# LDAP Authentication (Optional)
|
||||
LDAP_ENABLED=false
|
||||
LDAP_SERVER=your-ldap-server.com
|
||||
LDAP_PORT=389
|
||||
LDAP_USE_TLS=false
|
||||
LDAP_BIND_DN=cn=service-account,ou=users,dc=company,dc=com
|
||||
LDAP_BIND_PASSWORD=service-password
|
||||
LDAP_BASE_DN=ou=users,dc=company,dc=com
|
||||
|
||||
# User filter examples for different LDAP implementations:
|
||||
# Active Directory: (&(objectClass=person)(sAMAccountName=%s))
|
||||
# OpenLDAP: (&(objectClass=inetOrgPerson)(uid=%s))
|
||||
# FreeIPA: (&(objectClass=person)(uid=%s))
|
||||
# Generic LDAP: (uid=%s)
|
||||
LDAP_USER_FILTER=(uid=%s)
|
||||
|
||||
LDAP_ALLOWED_GROUPS=admins,operators
|
||||
LDAP_ALLOWED_USERS=user1,user2
|
||||
|
||||
# OIDC Authentication (Optional, generic OIDC provider)
|
||||
OIDC_ENABLED=false
|
||||
OIDC_ISSUER=
|
||||
OIDC_CLIENT_ID=
|
||||
OIDC_CLIENT_SECRET=
|
||||
OIDC_AUTH_URL=
|
||||
OIDC_TOKEN_URL=
|
||||
|
||||
# Redirect URL registered in your OIDC provider.
|
||||
# The path part (/auth/oidc/callback) is fixed by GLKVM Cloud and must not be changed.
|
||||
# Example:
|
||||
# OIDC_REDIRECT_URL=https://your-domain.example.com/auth/oidc/callback
|
||||
OIDC_REDIRECT_URL=
|
||||
|
||||
OIDC_SCOPES="openid profile email"
|
||||
|
||||
# Email-based whitelist (exact email or domain like @example.com)
|
||||
OIDC_ALLOWED_USERS=
|
||||
# Subject (sub) whitelist (stable user IDs)
|
||||
OIDC_ALLOWED_SUBS=
|
||||
# Username whitelist (preferred_username or name)
|
||||
OIDC_ALLOWED_USERNAMES=
|
||||
# Groups whitelist (e.g. admin, devops)
|
||||
OIDC_ALLOWED_GROUPS=
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
# 快速开始(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` 中完成,不需要修改 `docker-compose.yml`、模板或脚本。**
|
||||
|
||||
### 3. **启动服务**
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
如果你修改了 `.env` 或模板文件,请重新加载服务:
|
||||
|
||||
```bash
|
||||
docker-compose down && docker-compose up -d
|
||||
```
|
||||
|
||||
### 4. **访问平台**
|
||||
|
||||
安装完成后,通过以下地址访问平台:
|
||||
|
||||
```bash
|
||||
https://<你的服务器公网 IP>
|
||||
```
|
||||
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
# Quick Start
|
||||
|
||||
This guide shows how to deploy **glkvm-cloud** using the provided Docker Compose environment template.
|
||||
|
||||
1. **Clone the repository and prepare the environment template**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/gl-inet/glkvm-cloud.git
|
||||
cd glkvm-cloud/docker-compose/
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. **Configure environment variables**
|
||||
|
||||
Edit `.env` and update the required parameters:
|
||||
|
||||
- `RTTYS_TOKEN`: device connection token (leave empty to use the default)
|
||||
- `RTTYS_PASS`: web management password (leave empty to use the default **StrongP@ssw0rd**)
|
||||
- `TURN_USER` / `TURN_PASS`: coturn authentication credentials (leave empty to use the default)
|
||||
- `GLKVM_ACCESS_IP`: glkvm cloud access address (leave empty to auto-detect at startup)
|
||||
|
||||
**LDAP Authentication (Optional):**
|
||||
|
||||
- `LDAP_ENABLED`: set to `true` to enable LDAP authentication (default: `false`)
|
||||
- `LDAP_SERVER`: LDAP server hostname or IP address
|
||||
- `LDAP_PORT`: LDAP server port (default: `389`, for TLS use `636`)
|
||||
- `LDAP_USE_TLS`: set to `true` to enable TLS encryption (default: `false`)
|
||||
- `LDAP_BIND_DN`: service account distinguished name
|
||||
- `LDAP_BIND_PASSWORD`: service account password
|
||||
- `LDAP_BASE_DN`: search base for user queries
|
||||
- `LDAP_USER_FILTER`: LDAP query filter (default: `(uid=%s)`)
|
||||
- `LDAP_ALLOWED_GROUPS`: comma-separated list of authorized groups (optional)
|
||||
- `LDAP_ALLOWED_USERS`: comma-separated list of authorized users (optional)
|
||||
|
||||
⚠️ **Note:** All configuration should be done in the `.env` file.
|
||||
You don’t need to modify `docker-compose.yml`, templates, or scripts directly.
|
||||
|
||||
**OIDC Authentication (Optional):**
|
||||
|
||||
- `OIDC_ENABLED`: set to `true` to enable OIDC authentication (default: `false`)
|
||||
- `OIDC_ISSUER`: OIDC issuer URL provided by your identity provider
|
||||
(e.g. `https://accounts.google.com`, `https://your-tenant.auth0.com/`)
|
||||
- `OIDC_CLIENT_ID`: client ID issued by your OIDC provider
|
||||
- `OIDC_CLIENT_SECRET`: client secret issued by your OIDC provider
|
||||
- `OIDC_AUTH_URL`: authorization endpoint URL
|
||||
- `OIDC_TOKEN_URL`: token endpoint URL
|
||||
- `OIDC_REDIRECT_URL`: redirect (callback) URL registered in your OIDC provider
|
||||
Domain is user-defined, but the path must be fixed: `/auth/oidc/callback`
|
||||
Example: `https://your-domain.example.com/auth/oidc/callback`
|
||||
- `OIDC_SCOPES`: space-separated list of requested scopes (default: `"openid profile email"`)
|
||||
- `OIDC_ALLOWED_USERS`: comma-separated list of allowed emails or domains (optional)
|
||||
Example: `user@example.com,@example.com`
|
||||
- `OIDC_ALLOWED_SUBS`: comma-separated list of allowed OIDC subject (`sub`) IDs (optional)
|
||||
- `OIDC_ALLOWED_USERNAMES`: comma-separated list of allowed usernames (`preferred_username` or `name`) (optional)
|
||||
- `OIDC_ALLOWED_GROUPS`: comma-separated list of allowed OIDC groups (optional)
|
||||
|
||||
⚠️ **Note:** All configuration should be done in the `.env` file.
|
||||
You don’t need to modify `docker-compose.yml`, templates, or scripts directly.
|
||||
|
||||
3. **Start the services**
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
If you modify `.env` or template files, make sure to apply the updates:
|
||||
|
||||
```bash
|
||||
docker-compose down && docker-compose up -d
|
||||
```
|
||||
|
||||
4. **Platform Access**
|
||||
|
||||
Once the installation is complete, access the platform via:
|
||||
|
||||
```bash
|
||||
https://<your_server_public_ip>
|
||||
```
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
version: "2.0"
|
||||
|
||||
services:
|
||||
rttys:
|
||||
image: ${GLKVM_IMAGE:-glzhitong/glkvm-cloud:latest}
|
||||
container_name: glkvm_cloud
|
||||
restart: always
|
||||
environment:
|
||||
# Preferred: set GLKVM_ACCESS_IP explicitly; if empty, entrypoint will auto-detect once.
|
||||
GLKVM_ACCESS_IP: ${GLKVM_ACCESS_IP:-}
|
||||
|
||||
# ---- rttys ----
|
||||
RTTYS_TOKEN: ${RTTYS_TOKEN:-DeviceTokenYouCanChangeMe}
|
||||
RTTYS_PASS: ${RTTYS_PASS:-StrongP@ssw0rd}
|
||||
|
||||
# Ports inside container (mirrored to host via `ports` below)
|
||||
RTTYS_DEVICE_PORT: ${RTTYS_DEVICE_PORT:-5912} # addr-dev
|
||||
RTTYS_WEBUI_PORT: ${RTTYS_WEBUI_PORT:-443} # addr-user
|
||||
RTTYS_HTTP_PROXY_PORT: ${RTTYS_HTTP_PROXY_PORT:-10443} # addr-http-proxy
|
||||
|
||||
# WebRTC / TURN reference (used by rttys template)
|
||||
TURN_PORT: ${TURN_PORT:-3478}
|
||||
TURN_USER: ${TURN_USER:-glkvmcloudwebrtcuser}
|
||||
TURN_PASS: ${TURN_PASS:-AnotherS3cret}
|
||||
|
||||
# ---- LDAP Configuration ----
|
||||
LDAP_ENABLED: ${LDAP_ENABLED:-false}
|
||||
LDAP_SERVER: ${LDAP_SERVER:-}
|
||||
LDAP_PORT: ${LDAP_PORT:-389}
|
||||
LDAP_USE_TLS: ${LDAP_USE_TLS:-false}
|
||||
LDAP_BIND_DN: ${LDAP_BIND_DN:-}
|
||||
LDAP_BIND_PASSWORD: ${LDAP_BIND_PASSWORD:-}
|
||||
LDAP_BASE_DN: ${LDAP_BASE_DN:-}
|
||||
LDAP_USER_FILTER: ${LDAP_USER_FILTER:-(uid=%s)}
|
||||
LDAP_ALLOWED_GROUPS: ${LDAP_ALLOWED_GROUPS:-}
|
||||
LDAP_ALLOWED_USERS: ${LDAP_ALLOWED_USERS:-}
|
||||
|
||||
# ---- OIDC Authentication ----
|
||||
OIDC_ENABLED: ${OIDC_ENABLED:-false}
|
||||
OIDC_ISSUER: ${OIDC_ISSUER:-}
|
||||
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
|
||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
|
||||
OIDC_AUTH_URL: ${OIDC_AUTH_URL:-}
|
||||
OIDC_TOKEN_URL: ${OIDC_TOKEN_URL:-}
|
||||
OIDC_REDIRECT_URL: ${OIDC_REDIRECT_URL:-}
|
||||
OIDC_SCOPES: ${OIDC_SCOPES:-openid profile email}
|
||||
# Whitelists (optional)
|
||||
OIDC_ALLOWED_USERS: ${OIDC_ALLOWED_USERS:-}
|
||||
OIDC_ALLOWED_SUBS: ${OIDC_ALLOWED_SUBS:-}
|
||||
OIDC_ALLOWED_USERNAMES: ${OIDC_ALLOWED_USERNAMES:-}
|
||||
OIDC_ALLOWED_GROUPS: ${OIDC_ALLOWED_GROUPS:-}
|
||||
volumes:
|
||||
- ./templates/rttys.conf.template:/tpl/rttys.conf.tmpl:ro
|
||||
- ./scripts/docker-entrypoint.sh:/docker-entrypoint.sh:ro
|
||||
- ./certificate/glkvm.cer:/home/certificate/glkvm_cer:ro
|
||||
- ./certificate/glkvm.key:/home/certificate/glkvm_key:ro
|
||||
- ./database:/home/database:rw
|
||||
entrypoint: ["/bin/sh", "/docker-entrypoint.sh"]
|
||||
command: ["rttys"]
|
||||
ports:
|
||||
- "${RTTYS_WEBUI_PORT:-443}:${RTTYS_WEBUI_PORT:-443}"
|
||||
- "${RTTYS_HTTP_PROXY_PORT:-10443}:${RTTYS_HTTP_PROXY_PORT:-10443}"
|
||||
- "${RTTYS_DEVICE_PORT:-5912}:${RTTYS_DEVICE_PORT:-5912}"
|
||||
|
||||
coturn:
|
||||
image: ${COTURN_IMAGE:-coturn/coturn:edge-alpine}
|
||||
container_name: glkvm_coturn
|
||||
restart: always
|
||||
environment:
|
||||
# Same semantics as above: prefer explicit value, else auto-detect
|
||||
GLKVM_ACCESS_IP: ${GLKVM_ACCESS_IP:-}
|
||||
TURN_PORT: ${TURN_PORT:-3478}
|
||||
TURN_USER: ${TURN_USER:-glkvmcloudwebrtcuser}
|
||||
TURN_PASS: ${TURN_PASS:-AnotherS3cret}
|
||||
entrypoint: ["/bin/sh", "/docker-entrypoint.sh"]
|
||||
command: ["coturn"]
|
||||
volumes:
|
||||
- ./templates/turnserver.conf.template:/tpl/turnserver.conf.tmpl:ro
|
||||
- ./scripts/docker-entrypoint.sh:/docker-entrypoint.sh:ro
|
||||
ports:
|
||||
- "${TURN_PORT:-3478}:3478/tcp"
|
||||
- "${TURN_PORT:-3478}:3478/udp"
|
||||
Executable
+89
@@ -0,0 +1,89 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# -------- GLKVM access IP resolver (IPv4) --------
|
||||
resolve_glkvm_access_ip() {
|
||||
# 1) Prefer user-provided env
|
||||
if [ -n "${GLKVM_ACCESS_IP:-}" ]; then
|
||||
echo "${GLKVM_ACCESS_IP}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
is_ipv4() { echo "$1" | grep -Eq '^[0-9]{1,3}(\.[0-9]{1,3}){3}$'; }
|
||||
try_cmd() {
|
||||
val="$("$@" 2>/dev/null | tr -d '\r\n')"
|
||||
if is_ipv4 "$val"; then
|
||||
echo "$val"; return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# 2) Best-effort auto-detection from multiple sources
|
||||
if command -v wget >/dev/null 2>&1; then
|
||||
try_cmd wget -qO- --timeout=3 https://api.ipify.org && return 0
|
||||
try_cmd wget -qO- --timeout=3 https://ifconfig.me && return 0
|
||||
fi
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
try_cmd curl -fsS --max-time 3 https://api.ipify.org && return 0
|
||||
try_cmd curl -fsS --max-time 3 https://ifconfig.me && return 0
|
||||
fi
|
||||
if command -v dig >/dev/null 2>&1; then
|
||||
try_cmd sh -c "dig +short -4 myip.opendns.com @resolver1.opendns.com" && return 0
|
||||
fi
|
||||
|
||||
# 3) Fallback
|
||||
echo "127.0.0.1"
|
||||
}
|
||||
|
||||
# -------- Minimal template renderer ({{KEY}}) --------
|
||||
render() {
|
||||
in="$1"; out="$2"; shift 2
|
||||
cp "$in" "$out"
|
||||
for key in "$@"; do
|
||||
val="$(printenv "$key" || true)"
|
||||
# Special-case: GLKVM_ACCESS_IP prefers env, else auto-detect once
|
||||
if [ "$key" = "GLKVM_ACCESS_IP" ] && [ -z "$val" ]; then
|
||||
val="$(resolve_glkvm_access_ip)"
|
||||
fi
|
||||
# Escape '/' and '&' for sed
|
||||
esc=$(printf '%s' "$val" | sed -e 's/[\/&]/\\&/g')
|
||||
sed -i "s/{{${key}}}/${esc}/g" "$out"
|
||||
done
|
||||
}
|
||||
|
||||
# -------- Dispatch by first arg --------
|
||||
case "$1" in
|
||||
rttys)
|
||||
: "${RTTYS_DEVICE_PORT:=5912}"
|
||||
: "${RTTYS_WEBUI_PORT:=443}"
|
||||
: "${RTTYS_HTTP_PROXY_PORT:=10443}"
|
||||
: "${TURN_PORT:=3478}"
|
||||
|
||||
render /tpl/rttys.conf.tmpl /home/rttys.conf \
|
||||
RTTYS_TOKEN RTTYS_PASS \
|
||||
GLKVM_ACCESS_IP TURN_PORT TURN_USER TURN_PASS \
|
||||
RTTYS_DEVICE_PORT RTTYS_WEBUI_PORT RTTYS_HTTP_PROXY_PORT \
|
||||
LDAP_ENABLED LDAP_SERVER LDAP_PORT LDAP_USE_TLS \
|
||||
LDAP_BIND_DN LDAP_BIND_PASSWORD LDAP_BASE_DN \
|
||||
LDAP_USER_FILTER LDAP_ALLOWED_GROUPS LDAP_ALLOWED_USERS \
|
||||
OIDC_ENABLED OIDC_CLIENT_ID OIDC_AUTH_URL OIDC_TOKEN_URL \
|
||||
OIDC_REDIRECT_URL OIDC_CLIENT_SECRET OIDC_SCOPES OIDC_ALLOWED_USERS OIDC_ISSUER \
|
||||
OIDC_ALLOWED_SUBS OIDC_ALLOWED_USERNAMES OIDC_ALLOWED_GROUPS
|
||||
|
||||
exec rttys -c /home/rttys.conf
|
||||
;;
|
||||
|
||||
coturn)
|
||||
: "${TURN_PORT:=3478}"
|
||||
|
||||
mkdir -p /tmp
|
||||
render /tpl/turnserver.conf.tmpl /tmp/turnserver.conf \
|
||||
GLKVM_ACCESS_IP TURN_PORT TURN_USER TURN_PASS
|
||||
|
||||
exec turnserver -c /tmp/turnserver.conf
|
||||
;;
|
||||
|
||||
*)
|
||||
exec "$@"
|
||||
;;
|
||||
esac
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
# Authentication token for device connections
|
||||
token: {{RTTYS_TOKEN}}
|
||||
|
||||
# Web management password
|
||||
password: {{RTTYS_PASS}}
|
||||
|
||||
# WebRTC
|
||||
webrtc-ip: {{GLKVM_ACCESS_IP}}
|
||||
webrtc-port: {{TURN_PORT}}
|
||||
webrtc-username: {{TURN_USER}}
|
||||
webrtc-password: {{TURN_PASS}}
|
||||
|
||||
# Listen addresses
|
||||
addr-dev: :{{RTTYS_DEVICE_PORT}}
|
||||
addr-user: :{{RTTYS_WEBUI_PORT}}
|
||||
addr-http-proxy: :{{RTTYS_HTTP_PROXY_PORT}}
|
||||
|
||||
# LDAP Authentication
|
||||
ldap-enabled: {{LDAP_ENABLED}}
|
||||
ldap-server: {{LDAP_SERVER}}
|
||||
ldap-port: {{LDAP_PORT}}
|
||||
ldap-use-tls: {{LDAP_USE_TLS}}
|
||||
ldap-bind-dn: {{LDAP_BIND_DN}}
|
||||
# Note: ldap-bind-password is read directly from LDAP_BIND_PASSWORD environment variable
|
||||
ldap-base-dn: {{LDAP_BASE_DN}}
|
||||
ldap-user-filter: {{LDAP_USER_FILTER}}
|
||||
ldap-allowed-groups: {{LDAP_ALLOWED_GROUPS}}
|
||||
ldap-allowed-users: {{LDAP_ALLOWED_USERS}}
|
||||
|
||||
# OIDC Authentication (generic OIDC provider)
|
||||
oidc-enabled: {{OIDC_ENABLED}}
|
||||
|
||||
oidc-generic-issuer: {{OIDC_ISSUER}}
|
||||
oidc-generic-client-id: {{OIDC_CLIENT_ID}}
|
||||
|
||||
# Note: oidc-generic-client-secret is read directly from the OIDC_CLIENT_SECRET
|
||||
# environment variable and is intentionally not parsed from YAML to avoid
|
||||
# leaking secrets in config files.
|
||||
|
||||
oidc-generic-auth-url: {{OIDC_AUTH_URL}}
|
||||
oidc-generic-token-url: {{OIDC_TOKEN_URL}}
|
||||
oidc-generic-redirect-url: {{OIDC_REDIRECT_URL}}
|
||||
oidc-generic-scopes: {{OIDC_SCOPES}}
|
||||
|
||||
# Whitelists (all are optional)
|
||||
oidc-generic-allowed-users: {{OIDC_ALLOWED_USERS}}
|
||||
oidc-generic-allowed-subs: {{OIDC_ALLOWED_SUBS}}
|
||||
oidc-generic-allowed-usernames: {{OIDC_ALLOWED_USERNAMES}}
|
||||
oidc-generic-allowed-groups: {{OIDC_ALLOWED_GROUPS}}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
listening-port={{TURN_PORT}}
|
||||
lt-cred-mech
|
||||
user={{TURN_USER}}:{{TURN_PASS}}
|
||||
realm=glkvm
|
||||
no-multicast-peers
|
||||
allowed-peer-ip=0.0.0.0/0
|
||||
external-ip={{GLKVM_ACCESS_IP}}
|
||||
@@ -3,11 +3,15 @@ module rttys
|
||||
go 1.24.4
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.16.0
|
||||
github.com/dwdcth/consoleEx v0.0.0-20180521133551-f56f6eb78b76
|
||||
github.com/fanjindong/go-cache v0.0.6
|
||||
github.com/gin-contrib/cors v1.7.6
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-ldap/ldap/v3 v3.4.8
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/sessions v1.2.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/json-iterator/go v1.1.12
|
||||
github.com/kylelemons/go-gypsy v1.0.0
|
||||
@@ -16,18 +20,27 @@ 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 (
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||
github.com/bytedance/sonic v1.13.3 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/gorilla/securecookie v1.1.1 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
@@ -35,13 +48,19 @@ require (
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
golang.org/x/arch v0.18.0 // indirect
|
||||
golang.org/x/crypto v0.40.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/oauth2 v0.28.0 // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0=
|
||||
github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
@@ -6,11 +10,15 @@ github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFos
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
|
||||
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/dwdcth/consoleEx v0.0.0-20180521133551-f56f6eb78b76 h1:eObfFy0e/9OQCd5tHy+855jrW7zTihdgIPD7hf2SOQ0=
|
||||
github.com/dwdcth/consoleEx v0.0.0-20180521133551-f56f6eb78b76/go.mod h1:WPzFRpaqRmrZAD1vMpqUGZR24FE1EBoSG9lHKQyZOMM=
|
||||
github.com/fanjindong/go-cache v0.0.6 h1:4xl8MnfW8pFLH9cRjs0uNfVbFNqV342yl/pgX3Ql9gM=
|
||||
@@ -23,6 +31,16 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-ldap/ldap/v3 v3.4.8 h1:loKJyspcRezt2Q3ZRMq2p/0v8iOurlmeXDPw6fikSvQ=
|
||||
github.com/go-ldap/ldap/v3 v3.4.8/go.mod h1:qS3Sjlu76eHfHGpUdWkAXQTw4beih+cHsco2jXlIXrk=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -37,10 +55,35 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
|
||||
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=
|
||||
@@ -72,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=
|
||||
@@ -81,6 +127,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
@@ -95,37 +142,86 @@ github.com/urfave/cli/v3 v3.3.8 h1:BzolUExliMdet9NlJ/u4m5vHSotJ3PzEqSAZ1oPMa/E=
|
||||
github.com/urfave/cli/v3 v3.3.8/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc=
|
||||
golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
|
||||
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
|
||||
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
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=
|
||||
|
||||
@@ -35,6 +35,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -44,7 +45,6 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/valyala/bytebufferpool"
|
||||
"golang.org/x/net/publicsuffix"
|
||||
)
|
||||
|
||||
type HttpProxySession struct {
|
||||
@@ -333,26 +333,18 @@ 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 {
|
||||
// 域名访问,拼接 devid 子域名
|
||||
eTLDPlusOne, err := publicsuffix.EffectiveTLDPlusOne(hostname)
|
||||
if err != nil {
|
||||
log.Info().Msgf("Error parsing domain: %v", err)
|
||||
eTLDPlusOne = hostname // fallback
|
||||
}
|
||||
location = fmt.Sprintf("https://%s.%s%s?sid=%s", devid, eTLDPlusOne, cfg.AddrHttpProxy, sid)
|
||||
redirHost := buildRedirectHost(hostname, devid)
|
||||
location = fmt.Sprintf("https://%s%s?sid=%s", redirHost, cfg.AddrHttpProxy, sid)
|
||||
log.Info().Msgf("Using domain redirect: %s", location)
|
||||
}
|
||||
|
||||
@@ -624,3 +616,40 @@ func Write302WithCookie(conn net.Conn, location, cookieName, cookieValue string)
|
||||
)
|
||||
_, _ = conn.Write([]byte(response))
|
||||
}
|
||||
|
||||
// buildRedirectHost removes the first label of the hostname and prepends devid.
|
||||
// Rules:
|
||||
// - "www.example.com" -> "devid.example.com"
|
||||
// - "www.l1.example.com" -> "devid.l1.example.com"
|
||||
// - "www.l1.l2.example.com" -> "devid.l1.l2.example.com"
|
||||
// - Two-level domain "example.com" -> "devid.example.com"
|
||||
// - Single label / abnormal cases -> "devid." + hostname (fallback)
|
||||
//
|
||||
// The input hostname must be a pure hostname without port.
|
||||
func buildRedirectHost(hostname, devid string) string {
|
||||
// Allow FQDN with trailing dot like "example.com."
|
||||
hostname = strings.TrimSuffix(hostname, ".")
|
||||
|
||||
// Split into labels
|
||||
labels := strings.Split(hostname, ".")
|
||||
// Remove empty labels (in case of consecutive dots)
|
||||
compact := make([]string, 0, len(labels))
|
||||
for _, l := range labels {
|
||||
if l != "" {
|
||||
compact = append(compact, l)
|
||||
}
|
||||
}
|
||||
labels = compact
|
||||
|
||||
switch len(labels) {
|
||||
case 0:
|
||||
return devid // extreme case: just return devid
|
||||
case 1:
|
||||
// Single label (e.g., "localhost") — keep original as suffix
|
||||
return devid + "." + labels[0]
|
||||
default:
|
||||
// >=2: drop the leftmost label
|
||||
suffix := strings.Join(labels[1:], ".")
|
||||
return devid + "." + suffix
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
/*
|
||||
* @Author: CU-Jon
|
||||
* @Date: 2025-09-26 13:28:12 EDT
|
||||
* @LastEditors: CU-Jon
|
||||
* @LastEditTime: 2025-09-26 14:02:57 EDT
|
||||
* @FilePath: \glkvm-cloud\ldap.go
|
||||
* @Description: LDAP认证模块 (LDAP authentication module)
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// LDAP认证器结构体 (LDAP authenticator struct)
|
||||
type LDAPAuthenticator struct {
|
||||
config *Config
|
||||
}
|
||||
|
||||
// 创建新的LDAP认证器 (Create new LDAP authenticator)
|
||||
func NewLDAPAuthenticator(config *Config) *LDAPAuthenticator {
|
||||
return &LDAPAuthenticator{config: config}
|
||||
}
|
||||
|
||||
// 执行用户LDAP认证 (Perform LDAP authentication for a user)
|
||||
func (l *LDAPAuthenticator) Authenticate(username, password string) (bool, error) {
|
||||
if !l.config.LdapEnabled {
|
||||
return false, fmt.Errorf("LDAP authentication is disabled")
|
||||
}
|
||||
|
||||
if username == "" || password == "" {
|
||||
return false, fmt.Errorf("username and password are required")
|
||||
}
|
||||
|
||||
// 连接到LDAP服务器 (Connect to LDAP server)
|
||||
conn, err := l.connect()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to connect to LDAP server: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// 使用服务账户进行绑定和搜索 (Use service account for binding and searching)
|
||||
if l.config.LdapBindDN == "" || l.config.LdapBindPassword == "" {
|
||||
return false, fmt.Errorf("service account credentials are required for LDAP authentication - BindDN empty: %v, BindPassword empty: %v", l.config.LdapBindDN == "", l.config.LdapBindPassword == "")
|
||||
}
|
||||
|
||||
err = conn.Bind(l.config.LdapBindDN, l.config.LdapBindPassword)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("service account bind failed: %v", err)
|
||||
} // 使用服务账户搜索用户 (Use service account to search for user)
|
||||
userDN, err := l.findUserDN(conn, username)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("user search failed: %v", err)
|
||||
}
|
||||
|
||||
// 找到用户,现在用用户凭证验证密码 (Found user, now validate password with user credentials)
|
||||
err = conn.Bind(userDN, password)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("password validation failed: %v", err)
|
||||
}
|
||||
|
||||
// 重新绑定为服务账户以进行授权检查 (Rebind as service account for authorization check)
|
||||
err = conn.Bind(l.config.LdapBindDN, l.config.LdapBindPassword)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to rebind as service account for authorization: %v", err)
|
||||
}
|
||||
|
||||
// 检查用户授权 (Check user authorization)
|
||||
authorized, err := l.checkAuthorization(conn, userDN, username)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("authorization check failed: %v", err)
|
||||
}
|
||||
|
||||
if !authorized {
|
||||
return false, fmt.Errorf("user not authorized")
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// 建立到LDAP服务器的连接 (Establish connection to LDAP server)
|
||||
func (l *LDAPAuthenticator) connect() (*ldap.Conn, error) {
|
||||
address := fmt.Sprintf("%s:%d", l.config.LdapServer, l.config.LdapPort)
|
||||
|
||||
var conn *ldap.Conn
|
||||
var err error
|
||||
|
||||
if l.config.LdapUseTLS {
|
||||
// TLS配置 (TLS configuration)
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: l.config.LdapServer,
|
||||
InsecureSkipVerify: true, // 跳过证书验证以避免自签名证书问题 (Skip certificate verification to avoid self-signed certificate issues)
|
||||
}
|
||||
|
||||
if l.config.LdapPort == 636 {
|
||||
// 使用LDAPS (直接TLS连接) (Use LDAPS - direct TLS connection)
|
||||
conn, err = ldap.DialTLS("tcp", address, tlsConfig)
|
||||
} else {
|
||||
// 使用StartTLS (先连接再升级到TLS) (Use StartTLS - connect first then upgrade to TLS)
|
||||
conn, err = ldap.Dial("tcp", address)
|
||||
if err == nil {
|
||||
err = conn.StartTLS(tlsConfig)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 使用普通连接 (Use plain connection)
|
||||
conn, err = ldap.Dial("tcp", address)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 设置超时时间 (Set timeout)
|
||||
conn.SetTimeout(10 * time.Second)
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// 基于用户名搜索用户DN (Search for user DN based on username)
|
||||
func (l *LDAPAuthenticator) findUserDN(conn *ldap.Conn, username string) (string, error) {
|
||||
// 准备搜索过滤器 (Prepare search filter)
|
||||
filter := fmt.Sprintf(l.config.LdapUserFilter, username)
|
||||
if l.config.LdapUserFilter == "" {
|
||||
filter = fmt.Sprintf("(uid=%s)", username)
|
||||
}
|
||||
|
||||
// 执行搜索 (Perform search)
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
l.config.LdapBaseDN,
|
||||
ldap.ScopeWholeSubtree,
|
||||
ldap.NeverDerefAliases,
|
||||
0, // 无大小限制 (No size limit)
|
||||
0, // 无时间限制 (No time limit)
|
||||
false,
|
||||
filter,
|
||||
[]string{"dn"},
|
||||
nil,
|
||||
)
|
||||
|
||||
sr, err := conn.Search(searchRequest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(sr.Entries) == 0 {
|
||||
return "", fmt.Errorf("user not found")
|
||||
}
|
||||
|
||||
if len(sr.Entries) > 1 {
|
||||
return "", fmt.Errorf("multiple users found")
|
||||
}
|
||||
|
||||
return sr.Entries[0].DN, nil
|
||||
}
|
||||
|
||||
// 基于组或用户列表检查用户是否授权 (Check if user is authorized based on groups or users list)
|
||||
func (l *LDAPAuthenticator) checkAuthorization(conn *ldap.Conn, userDN, username string) (bool, error) {
|
||||
// 如果没有配置限制,则允许所有已认证用户 (If no restrictions are configured, allow all authenticated users)
|
||||
if l.config.LdapAllowedGroups == "" && l.config.LdapAllowedUsers == "" {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// 检查允许的用户列表 (Check allowed users list)
|
||||
if l.config.LdapAllowedUsers != "" {
|
||||
allowedUsers := strings.Split(strings.TrimSpace(l.config.LdapAllowedUsers), ",")
|
||||
for _, allowedUser := range allowedUsers {
|
||||
if strings.TrimSpace(allowedUser) == username {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查允许的组 (Check allowed groups)
|
||||
if l.config.LdapAllowedGroups != "" {
|
||||
return l.checkGroupMembership(conn, userDN, username)
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// 检查用户是否属于任何允许的组 (Check if user belongs to any of the allowed groups)
|
||||
func (l *LDAPAuthenticator) checkGroupMembership(conn *ldap.Conn, userDN, username string) (bool, error) {
|
||||
allowedGroups := strings.Split(strings.TrimSpace(l.config.LdapAllowedGroups), ",")
|
||||
|
||||
for _, group := range allowedGroups {
|
||||
group = strings.TrimSpace(group)
|
||||
if group == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 搜索组成员关系 - 尝试不同的常见LDAP组结构 (Search for group membership - try different common LDAP group structures)
|
||||
isMember, err := l.isGroupMember(conn, userDN, username, group)
|
||||
if err != nil {
|
||||
log.Warn().Msgf("Error checking group membership for %s in %s: %v", username, group, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if isMember {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// 检查用户是否是指定组的成员 (Check if user is a member of the specified group)
|
||||
func (l *LDAPAuthenticator) isGroupMember(conn *ldap.Conn, userDN, username, groupName string) (bool, error) {
|
||||
// 首先查找用户的实际DN,因为我们可能使用了UPN格式进行认证 (First find the user's actual DN, as we may have used UPN format for authentication)
|
||||
actualUserDN, err := l.findActualUserDN(conn, username)
|
||||
if err != nil {
|
||||
actualUserDN = userDN // 回退到原始DN (Fallback to original DN)
|
||||
}
|
||||
|
||||
// 尝试不同的常见组搜索模式 (Try different common group search patterns)
|
||||
|
||||
// 模式1:通过CN搜索组并检查成员属性 (Pattern 1: Search for group by CN and check member attribute)
|
||||
groupFilter := fmt.Sprintf("(cn=%s)", groupName)
|
||||
|
||||
groupSearchRequest := ldap.NewSearchRequest(
|
||||
l.config.LdapBaseDN,
|
||||
ldap.ScopeWholeSubtree,
|
||||
ldap.NeverDerefAliases,
|
||||
0, 0, false,
|
||||
groupFilter,
|
||||
[]string{"member", "memberUid", "uniqueMember"},
|
||||
nil,
|
||||
)
|
||||
|
||||
sr, err := conn.Search(groupSearchRequest)
|
||||
if err != nil {
|
||||
log.Warn().Msgf("Group search failed: %v", err)
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, entry := range sr.Entries {
|
||||
members := entry.GetAttributeValues("member")
|
||||
memberUids := entry.GetAttributeValues("memberUid")
|
||||
uniqueMembers := entry.GetAttributeValues("uniqueMember")
|
||||
|
||||
// 检查member属性(完整DN) (Check member attribute - full DN)
|
||||
for _, member := range members {
|
||||
if member == userDN || member == actualUserDN {
|
||||
return true, nil
|
||||
}
|
||||
// 也检查是否member DN包含用户名 (Also check if member DN contains the username)
|
||||
if strings.Contains(strings.ToLower(member), strings.ToLower("cn="+username)) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 检查memberUid属性(仅用户名) (Check memberUid attribute - username only)
|
||||
for _, memberUid := range memberUids {
|
||||
if memberUid == username {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 检查uniqueMember属性(完整DN) (Check uniqueMember attribute - full DN)
|
||||
for _, uniqueMember := range uniqueMembers {
|
||||
if uniqueMember == userDN {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 模式2:通过用户名搜索用户并检查memberOf属性 (Pattern 2: Search for user by username and check memberOf attribute)
|
||||
|
||||
// 使用配置的用户过滤器或默认的uid过滤器 (Use configured user filter or default uid filter)
|
||||
userFilter := fmt.Sprintf(l.config.LdapUserFilter, username)
|
||||
if l.config.LdapUserFilter == "" {
|
||||
userFilter = fmt.Sprintf("(uid=%s)", username)
|
||||
}
|
||||
|
||||
userSearchRequest := ldap.NewSearchRequest(
|
||||
l.config.LdapBaseDN,
|
||||
ldap.ScopeWholeSubtree,
|
||||
ldap.NeverDerefAliases,
|
||||
0, 0, false,
|
||||
userFilter,
|
||||
[]string{"memberOf", "distinguishedName"},
|
||||
nil,
|
||||
)
|
||||
|
||||
sr, err = conn.Search(userSearchRequest)
|
||||
if err != nil {
|
||||
log.Warn().Msgf("User search for memberOf failed: %v", err)
|
||||
} else {
|
||||
for _, entry := range sr.Entries {
|
||||
memberOfValues := entry.GetAttributeValues("memberOf")
|
||||
|
||||
for _, memberOf := range memberOfValues {
|
||||
if strings.Contains(strings.ToLower(memberOf), strings.ToLower("cn="+groupName)) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// 查找用户的实际DN (Find the user's actual DN)
|
||||
func (l *LDAPAuthenticator) findActualUserDN(conn *ldap.Conn, username string) (string, error) {
|
||||
// 使用配置的用户过滤器搜索用户 (Search for user using configured user filter)
|
||||
userFilter := fmt.Sprintf(l.config.LdapUserFilter, username)
|
||||
if l.config.LdapUserFilter == "" {
|
||||
userFilter = fmt.Sprintf("(uid=%s)", username)
|
||||
}
|
||||
|
||||
userSearchRequest := ldap.NewSearchRequest(
|
||||
l.config.LdapBaseDN,
|
||||
ldap.ScopeWholeSubtree,
|
||||
ldap.NeverDerefAliases,
|
||||
0, 0, false,
|
||||
userFilter,
|
||||
[]string{"distinguishedName"},
|
||||
nil,
|
||||
)
|
||||
|
||||
sr, err := conn.Search(userSearchRequest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(sr.Entries) == 0 {
|
||||
return "", fmt.Errorf("user not found")
|
||||
}
|
||||
|
||||
if len(sr.Entries) > 1 {
|
||||
return "", fmt.Errorf("multiple users found")
|
||||
}
|
||||
|
||||
return sr.Entries[0].DN, nil
|
||||
}
|
||||
|
||||
// 执行用户认证,支持LDAP和传统密码认证 (Perform user authentication with LDAP and legacy password support)
|
||||
func AuthenticateUser(cfg *Config, username, password, authMethod string) bool {
|
||||
success, _ := AuthenticateUserWithError(cfg, username, password, authMethod)
|
||||
return success
|
||||
}
|
||||
|
||||
// 执行用户认证并返回错误类型,支持LDAP和传统密码认证 (Perform user authentication with error type, supporting LDAP and legacy password authentication)
|
||||
func AuthenticateUserWithError(cfg *Config, username, password, authMethod string) (bool, string) {
|
||||
// 处理LDAP认证 (Handle LDAP authentication)
|
||||
if cfg.LdapEnabled && authMethod == "ldap" && username != "" {
|
||||
ldapAuth := NewLDAPAuthenticator(cfg)
|
||||
success, err := ldapAuth.Authenticate(username, password)
|
||||
if err != nil {
|
||||
log.Error().Msgf("LDAP authentication error: %v", err)
|
||||
// 检查错误类型以区分认证和授权错误 (Check error type to distinguish between authentication and authorization errors)
|
||||
if strings.Contains(err.Error(), "user not authorized") {
|
||||
return false, "authorization"
|
||||
}
|
||||
return false, "authentication"
|
||||
}
|
||||
return success, ""
|
||||
}
|
||||
|
||||
// 回退到原始密码认证以保持向后兼容 (Fallback to original password authentication for backward compatibility)
|
||||
if authMethod == "legacy" || authMethod == "" {
|
||||
if cfg.Password == password {
|
||||
return true, ""
|
||||
}
|
||||
return false, "authentication"
|
||||
}
|
||||
|
||||
return false, "authentication"
|
||||
}
|
||||
@@ -25,180 +25,283 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "net/http/pprof"
|
||||
"os"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"context"
|
||||
"encoding/json"
|
||||
_ "net/http/pprof"
|
||||
"os"
|
||||
"rttys/db"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
|
||||
xlog "rttys/log"
|
||||
xlog "rttys/log"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/urfave/cli/v3"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
const RttysVersion = "5.2.0"
|
||||
|
||||
var (
|
||||
GitCommit = ""
|
||||
BuildTime = ""
|
||||
GitCommit = ""
|
||||
BuildTime = ""
|
||||
)
|
||||
|
||||
func main() {
|
||||
defaultLogPath := "/var/log/rttys.log"
|
||||
if runtime.GOOS == "windows" {
|
||||
defaultLogPath = "rttys.log"
|
||||
}
|
||||
defaultLogPath := "/var/log/rttys.log"
|
||||
if runtime.GOOS == "windows" {
|
||||
defaultLogPath = "rttys.log"
|
||||
}
|
||||
|
||||
cmd := &cli.Command{
|
||||
Name: "rttys",
|
||||
Usage: "The server side for rtty",
|
||||
Version: RttysVersion,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "log",
|
||||
Value: defaultLogPath,
|
||||
Usage: "log file path",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "log-level",
|
||||
Value: "info",
|
||||
Usage: "log level(debug, info, warn, error)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "conf",
|
||||
Aliases: []string{"c"},
|
||||
Usage: "config file to load",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "addr-dev",
|
||||
Value: ":5912",
|
||||
Usage: "address to listen device",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "addr-user",
|
||||
Value: ":5913",
|
||||
Usage: "address to listen user",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "addr-http-proxy",
|
||||
Usage: "address to listen for HTTP proxy (default auto)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "http-proxy-redir-url",
|
||||
Usage: "url to redirect for HTTP proxy",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "http-proxy-redir-domain",
|
||||
Usage: "domain for HTTP proxy set cookie",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "token",
|
||||
Aliases: []string{"t"},
|
||||
Usage: "token to use",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "dev-hook-url",
|
||||
Usage: "called when the device is connected",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "user-hook-url",
|
||||
Usage: "called when user accesses /connect/:devid, /cmd/:devid, /web/, or /web2/ APIs",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "local-auth",
|
||||
Value: true,
|
||||
Usage: "need auth for local",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "password",
|
||||
Usage: "web management password",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "allow-origins",
|
||||
Usage: "allow all origins for cross-domain request",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "pprof",
|
||||
Usage: "enable pprof and listen on specified address (e.g. localhost:6060)",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "verbose",
|
||||
Aliases: []string{"V"},
|
||||
Usage: "more detailed output",
|
||||
},
|
||||
},
|
||||
Action: cmdAction,
|
||||
}
|
||||
cmd := &cli.Command{
|
||||
Name: "rttys",
|
||||
Usage: "The server side for rtty",
|
||||
Version: RttysVersion,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "log",
|
||||
Value: defaultLogPath,
|
||||
Usage: "log file path",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "log-level",
|
||||
Value: "info",
|
||||
Usage: "log level(debug, info, warn, error)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "conf",
|
||||
Aliases: []string{"c"},
|
||||
Usage: "config file to load",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "addr-dev",
|
||||
Value: ":5912",
|
||||
Usage: "address to listen device",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "addr-user",
|
||||
Value: ":5913",
|
||||
Usage: "address to listen user",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "addr-http-proxy",
|
||||
Usage: "address to listen for HTTP proxy (default auto)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "http-proxy-redir-url",
|
||||
Usage: "url to redirect for HTTP proxy",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "http-proxy-redir-domain",
|
||||
Usage: "domain for HTTP proxy set cookie",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "token",
|
||||
Aliases: []string{"t"},
|
||||
Usage: "token to use",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "dev-hook-url",
|
||||
Usage: "called when the device is connected",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "user-hook-url",
|
||||
Usage: "called when user accesses /connect/:devid, /cmd/:devid, /web/, or /web2/ APIs",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "local-auth",
|
||||
Value: true,
|
||||
Usage: "need auth for local",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "password",
|
||||
Usage: "web management password",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "allow-origins",
|
||||
Usage: "allow all origins for cross-domain request",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "ldap-enabled",
|
||||
Usage: "enable LDAP authentication",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "ldap-server",
|
||||
Usage: "LDAP server hostname or IP",
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "ldap-port",
|
||||
Value: 389,
|
||||
Usage: "LDAP server port",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "ldap-use-tls",
|
||||
Usage: "use TLS/SSL for LDAP connection",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "ldap-bind-dn",
|
||||
Usage: "LDAP bind DN for service account",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "ldap-bind-password",
|
||||
Usage: "LDAP bind password for service account",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "ldap-base-dn",
|
||||
Usage: "LDAP base DN for user searches",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "ldap-user-filter",
|
||||
Value: "(uid=%s)",
|
||||
Usage: "LDAP user filter",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "ldap-allowed-groups",
|
||||
Usage: "comma-separated list of allowed LDAP groups",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "ldap-allowed-users",
|
||||
Usage: "comma-separated list of allowed LDAP users",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "pprof",
|
||||
Usage: "enable pprof and listen on specified address (e.g. localhost:6060)",
|
||||
},
|
||||
|
||||
err := cmd.Run(context.Background(), os.Args)
|
||||
if err != nil {
|
||||
log.Fatal().Msg(err.Error())
|
||||
}
|
||||
// ---- OIDC Authentication (generic OIDC provider) ----
|
||||
&cli.BoolFlag{
|
||||
Name: "oidc-enabled",
|
||||
Usage: "enable OIDC authentication (OpenID Connect)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "oidc-generic-client-id",
|
||||
Usage: "OIDC client ID (issued by the identity provider)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "oidc-generic-client-secret",
|
||||
Usage: "OIDC client secret (read from OIDC_GENERIC_CLIENT_SECRET env by default)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "oidc-generic-auth-url",
|
||||
Usage: "OIDC authorization endpoint URL",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "oidc-generic-token-url",
|
||||
Usage: "OIDC token endpoint URL",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "oidc-generic-redirect-url",
|
||||
Usage: "OIDC redirect/callback URL (must match one registered in IdP)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "oidc-generic-scopes",
|
||||
Value: "openid profile email",
|
||||
Usage: "space-separated list of OIDC scopes",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "oidc-generic-allowed-users",
|
||||
Usage: "optional email whitelist for OIDC logins (exact emails or @domain, space/comma-separated)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "oidc-generic-allowed-subs",
|
||||
Usage: "optional subject (sub) whitelist for OIDC logins (space/comma-separated)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "oidc-generic-allowed-usernames",
|
||||
Usage: "optional username whitelist for OIDC logins (preferred_username/name, space/comma-separated)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "oidc-generic-allowed-groups",
|
||||
Usage: "optional groups whitelist for OIDC logins (space/comma-separated)",
|
||||
},
|
||||
|
||||
&cli.BoolFlag{
|
||||
Name: "verbose",
|
||||
Aliases: []string{"V"},
|
||||
Usage: "more detailed output",
|
||||
},
|
||||
},
|
||||
Action: cmdAction,
|
||||
}
|
||||
|
||||
err := cmd.Run(context.Background(), os.Args)
|
||||
if err != nil {
|
||||
log.Fatal().Msg(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func cmdAction(c context.Context, cmd *cli.Command) error {
|
||||
defer logPanic()
|
||||
defer logPanic()
|
||||
|
||||
xlog.SetPath(cmd.String("log"))
|
||||
xlog.SetPath(cmd.String("log"))
|
||||
|
||||
switch cmd.String("log-level") {
|
||||
case "debug":
|
||||
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
case "warn":
|
||||
zerolog.SetGlobalLevel(zerolog.WarnLevel)
|
||||
case "error":
|
||||
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
|
||||
default:
|
||||
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
||||
}
|
||||
switch cmd.String("log-level") {
|
||||
case "debug":
|
||||
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
case "warn":
|
||||
zerolog.SetGlobalLevel(zerolog.WarnLevel)
|
||||
case "error":
|
||||
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
|
||||
default:
|
||||
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
||||
}
|
||||
|
||||
if cmd.Bool("verbose") {
|
||||
xlog.Verbose()
|
||||
}
|
||||
if cmd.Bool("verbose") {
|
||||
xlog.Verbose()
|
||||
}
|
||||
|
||||
log.Info().Msg("Go Version: " + runtime.Version())
|
||||
log.Info().Msgf("Go OS/Arch: %s/%s", runtime.GOOS, runtime.GOARCH)
|
||||
log.Info().Msg("Go Version: " + runtime.Version())
|
||||
log.Info().Msgf("Go OS/Arch: %s/%s", runtime.GOOS, runtime.GOARCH)
|
||||
|
||||
log.Info().Msg("Rttys Version: " + RttysVersion)
|
||||
log.Info().Msg("Rttys Version: " + RttysVersion)
|
||||
|
||||
if GitCommit != "" {
|
||||
log.Info().Msg("Git Commit: " + GitCommit)
|
||||
}
|
||||
if GitCommit != "" {
|
||||
log.Info().Msg("Git Commit: " + GitCommit)
|
||||
}
|
||||
|
||||
if BuildTime != "" {
|
||||
log.Info().Msg("Build Time: " + BuildTime)
|
||||
}
|
||||
if BuildTime != "" {
|
||||
log.Info().Msg("Build Time: " + BuildTime)
|
||||
}
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
go signalHandle()
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
go signalHandle()
|
||||
}
|
||||
|
||||
cfg := Config{
|
||||
AddrDev: ":5912",
|
||||
AddrUser: ":5913",
|
||||
LocalAuth: true,
|
||||
}
|
||||
cfg := Config{
|
||||
AddrDev: ":5912",
|
||||
AddrUser: ":5913",
|
||||
LocalAuth: true,
|
||||
}
|
||||
|
||||
err := cfg.Parse(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err := cfg.Parse(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
srv := &RttyServer{cfg: cfg}
|
||||
// ===== 打印完整配置(验证配置是否加载正确) =====
|
||||
{
|
||||
importJSON, _ := json.MarshalIndent(cfg, "", " ")
|
||||
log.Info().Msg("==== Loaded Configuration ====")
|
||||
log.Info().Msg(string(importJSON))
|
||||
log.Info().Msg("==============================")
|
||||
}
|
||||
|
||||
return srv.Run()
|
||||
// Initialize the SQLite database connection
|
||||
db.Init()
|
||||
|
||||
srv := &RttyServer{cfg: cfg}
|
||||
|
||||
return srv.Run()
|
||||
}
|
||||
|
||||
func logPanic() {
|
||||
if r := recover(); r != nil {
|
||||
saveCrashLog(r, debug.Stack())
|
||||
os.Exit(2)
|
||||
}
|
||||
if r := recover(); r != nil {
|
||||
saveCrashLog(r, debug.Stack())
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func saveCrashLog(p any, stack []byte) {
|
||||
log.Error().Msgf("%v", p)
|
||||
log.Error().Msg(string(stack))
|
||||
log.Error().Msgf("%v", p)
|
||||
log.Error().Msg(string(stack))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
oidc "github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/fanjindong/go-cache"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/rs/zerolog/log"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"rttys/utils"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
// Session store for OAuth flow
|
||||
oauthStore *sessions.CookieStore
|
||||
// Global OIDC verifier
|
||||
oidcVerifier *oidc.IDTokenVerifier
|
||||
)
|
||||
|
||||
// Register OIDC routes
|
||||
func RegisterOIDCRoutes(r *gin.Engine, cfg *Config) {
|
||||
if !cfg.OIDCEnabled {
|
||||
return
|
||||
}
|
||||
|
||||
// ===== Initialize OIDC provider & ID token verifier =====
|
||||
issuer := strings.TrimSpace(cfg.OIDCGenericIssuer)
|
||||
if issuer == "" {
|
||||
log.Error().Msg("OIDC is enabled but issuer (OIDCGenericIssuer) is empty")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
//// Normalize issuer to always end with a single '/'
|
||||
//issuer = strings.TrimRight(issuer, "/") + "/"
|
||||
cfg.OIDCGenericIssuer = issuer
|
||||
provider, err := oidc.NewProvider(ctx, issuer)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to initialize OIDC provider")
|
||||
return
|
||||
}
|
||||
|
||||
oidcVerifier = provider.Verifier(&oidc.Config{
|
||||
ClientID: cfg.OIDCGenericClientID,
|
||||
// You can set SkipIssuerCheck, SkipClientIDCheck here if needed, but not recommended.
|
||||
})
|
||||
|
||||
// Initialize session store
|
||||
sessionSecret := generateRandomString(32)
|
||||
oauthStore = sessions.NewCookieStore([]byte(sessionSecret))
|
||||
|
||||
// Configure session options
|
||||
oauthStore.Options = &sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: 300, // 5 minutes, enough to complete the OAuth flow
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode, // Important: allow cookies on cross-site navigation (OIDC redirect)
|
||||
Domain: "", // Empty means current host/domain will be used
|
||||
}
|
||||
|
||||
// OIDC auth routes (public, no existing auth required)
|
||||
r.GET("/auth/oidc/login", oidcLoginHandler(cfg))
|
||||
r.GET("/auth/oidc/callback", oidcCallbackHandler(cfg))
|
||||
}
|
||||
|
||||
// Start OIDC login
|
||||
func oidcLoginHandler(cfg *Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
// Generate state and nonce
|
||||
nonce := generateRandomString(32)
|
||||
state := generateRandomString(32)
|
||||
|
||||
log.Info().Msgf("OIDC login initiated: nonce=%s, state=%s...", nonce[:10], state[:10])
|
||||
|
||||
// Save to session
|
||||
session, _ := oauthStore.Get(c.Request, "oidc-session")
|
||||
session.Values["state"] = state
|
||||
session.Values["nonce"] = nonce
|
||||
|
||||
if err := session.Save(c.Request, c.Writer); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to save OIDC session")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save session"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build authorization URL
|
||||
params := url.Values{}
|
||||
params.Add("client_id", cfg.OIDCGenericClientID)
|
||||
params.Add("redirect_uri", cfg.OIDCGenericRedirectURL)
|
||||
params.Add("response_type", "code")
|
||||
params.Add("scope", strings.Join(cfg.OIDCGenericScopes, " "))
|
||||
params.Add("state", state)
|
||||
params.Add("nonce", nonce)
|
||||
|
||||
authURL := cfg.OIDCGenericAuthURL + "?" + params.Encode()
|
||||
log.Info().Msgf("OIDC login redirect=%s", authURL[:100]+"...")
|
||||
c.Redirect(http.StatusFound, authURL)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle OIDC callback
|
||||
func oidcCallbackHandler(cfg *Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Get session
|
||||
session, err := oauthStore.Get(c.Request, "oidc-session")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to get OIDC session")
|
||||
c.Redirect(http.StatusFound, "/?error=session_error")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate state
|
||||
state := c.Query("state")
|
||||
savedState, ok := session.Values["state"].(string)
|
||||
if !ok || state != savedState {
|
||||
log.Warn().Msg("OIDC state mismatch")
|
||||
c.Redirect(http.StatusFound, "/?error=invalid_state")
|
||||
return
|
||||
}
|
||||
|
||||
// Check OAuth error
|
||||
if errorMsg := c.Query("error"); errorMsg != "" {
|
||||
errorDesc := c.Query("error_description")
|
||||
log.Warn().Msgf("OIDC error: %s - %s", errorMsg, errorDesc)
|
||||
c.Redirect(http.StatusFound, "/?error="+errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
// Read authorization code
|
||||
code := c.Query("code")
|
||||
if code == "" {
|
||||
c.Redirect(http.StatusFound, "/?error=no_code")
|
||||
return
|
||||
}
|
||||
|
||||
// Exchange authorization code for tokens
|
||||
tokens, err := exchangeCodeForTokens(cfg, code)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to exchange code for tokens")
|
||||
c.Redirect(http.StatusFound, "/?error=token_exchange_failed")
|
||||
return
|
||||
}
|
||||
|
||||
// Extract user info
|
||||
var userEmail string
|
||||
var userName string
|
||||
|
||||
// Standard OIDC – verify and parse ID token
|
||||
rawIDToken, ok := tokens["id_token"].(string)
|
||||
if !ok {
|
||||
log.Error().Msg("No ID token in response")
|
||||
c.Redirect(http.StatusFound, "/?error=no_id_token")
|
||||
return
|
||||
}
|
||||
|
||||
if oidcVerifier == nil {
|
||||
log.Error().Msg("OIDC verifier is not initialized")
|
||||
c.Redirect(http.StatusFound, "/?error=server_config")
|
||||
return
|
||||
}
|
||||
|
||||
// ==== Signature + standard claims verification ====
|
||||
idToken, err := oidcVerifier.Verify(c.Request.Context(), rawIDToken)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to verify ID token signature/claims")
|
||||
c.Redirect(http.StatusFound, "/?error=invalid_token")
|
||||
return
|
||||
}
|
||||
|
||||
// Decode claims into a map
|
||||
claims := map[string]interface{}{}
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to parse ID token claims")
|
||||
c.Redirect(http.StatusFound, "/?error=invalid_token")
|
||||
return
|
||||
}
|
||||
|
||||
// Pretty-print all claims as JSON for debugging
|
||||
if claimsJSON, err := json.MarshalIndent(claims, "", " "); err == nil {
|
||||
log.Info().Msg("========== OIDC ID Token Claims ==========")
|
||||
log.Info().Msg(string(claimsJSON))
|
||||
log.Info().Msg("==========================================")
|
||||
} else {
|
||||
log.Warn().Err(err).Msg("Failed to marshal OIDC claims to JSON")
|
||||
}
|
||||
|
||||
// Validate nonce
|
||||
if savedNonce, ok := session.Values["nonce"].(string); ok {
|
||||
if claims["nonce"] != savedNonce {
|
||||
log.Warn().Msg("OIDC nonce mismatch")
|
||||
c.Redirect(http.StatusFound, "/?error=invalid_nonce")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sub, _ := claims["sub"].(string)
|
||||
userEmail, _ = claims["email"].(string)
|
||||
userName, _ = claims["name"].(string)
|
||||
if userName == "" {
|
||||
// Fallback: use email or sub as display name
|
||||
if userEmail != "" {
|
||||
userName = userEmail
|
||||
} else {
|
||||
userName = sub
|
||||
}
|
||||
}
|
||||
|
||||
// sub is required by OIDC spec and should never be empty
|
||||
if sub == "" {
|
||||
log.Error().Msg("OIDC token is missing 'sub' claim")
|
||||
c.Redirect(http.StatusFound, "/?error=user_info_failed")
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().Msgf("OIDC login successful: email=%s", userEmail)
|
||||
|
||||
// ====== OIDC whitelist enforcement ======
|
||||
if !isOIDCUserAllowed(cfg, claims) {
|
||||
log.Warn().Msgf("OIDC user not allowed by whitelist rules, sub=%v, email=%v", claims["sub"], claims["email"])
|
||||
c.Redirect(http.StatusFound, "/?error=authorization")
|
||||
return
|
||||
}
|
||||
|
||||
// Create application session
|
||||
sid := utils.GenUniqueID()
|
||||
httpSessions.Set(sid, gin.H{
|
||||
"email": userEmail,
|
||||
"name": userName,
|
||||
"oidc": true,
|
||||
}, cache.WithEx(httpSessionExpire))
|
||||
|
||||
c.SetCookie("sid", sid, 0, "", "", cfg.SslCert != "", true)
|
||||
|
||||
// Clean up OAuth session
|
||||
session.Options.MaxAge = -1
|
||||
session.Save(c.Request, c.Writer)
|
||||
|
||||
// Redirect to home page
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
}
|
||||
|
||||
// Exchange authorization code for tokens
|
||||
func exchangeCodeForTokens(cfg *Config, code string) (map[string]interface{}, error) {
|
||||
data := url.Values{}
|
||||
data.Set("code", code)
|
||||
data.Set("client_id", cfg.OIDCGenericClientID)
|
||||
data.Set("client_secret", cfg.OIDCGenericClientSecret)
|
||||
data.Set("redirect_uri", cfg.OIDCGenericRedirectURL)
|
||||
data.Set("grant_type", "authorization_code")
|
||||
|
||||
req, err := http.NewRequest("POST", cfg.OIDCGenericTokenURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("token request failed: %s", string(body))
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Generate a random string with given length
|
||||
func generateRandomString(length int) string {
|
||||
b := make([]byte, length)
|
||||
rand.Read(b)
|
||||
return base64.URLEncoding.EncodeToString(b)[:length]
|
||||
}
|
||||
|
||||
func isOIDCUserAllowed(cfg *Config, claims map[string]interface{}) bool {
|
||||
email, _ := claims["email"].(string)
|
||||
sub, _ := claims["sub"].(string)
|
||||
preferredUsername, _ := claims["preferred_username"].(string)
|
||||
name, _ := claims["name"].(string)
|
||||
|
||||
// 1) Email whitelist
|
||||
if len(cfg.OIDCGenericAllowedUsers) > 0 {
|
||||
if !isEmailAllowed(cfg.OIDCGenericAllowedUsers, email) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Sub (subject) whitelist
|
||||
if len(cfg.OIDCGenericAllowedSubs) > 0 {
|
||||
if !contains(cfg.OIDCGenericAllowedSubs, sub) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Username whitelist (preferred_username > name)
|
||||
if len(cfg.OIDCGenericAllowedUsernames) > 0 {
|
||||
u := preferredUsername
|
||||
if u == "" {
|
||||
u = name
|
||||
}
|
||||
if !contains(cfg.OIDCGenericAllowedUsernames, u) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Groups whitelist
|
||||
if len(cfg.OIDCGenericAllowedGroups) > 0 {
|
||||
groups := extractStringSlice(claims["groups"])
|
||||
if !intersects(groups, cfg.OIDCGenericAllowedGroups) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Email whitelist check rules:
|
||||
// - Exact match (case-insensitive)
|
||||
// - Simple domain match: entries starting with "@example.com" or "*@example.com"
|
||||
// mean "allow all users under this domain"
|
||||
func isEmailAllowed(allowed []string, email string) bool {
|
||||
if len(allowed) == 0 {
|
||||
return true
|
||||
}
|
||||
e := strings.ToLower(strings.TrimSpace(email))
|
||||
for _, raw := range allowed {
|
||||
a := strings.ToLower(strings.TrimSpace(raw))
|
||||
if a == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(a, "*@") || strings.HasPrefix(a, "@") {
|
||||
// Domain match
|
||||
dom := strings.TrimPrefix(a, "*@")
|
||||
dom = strings.TrimPrefix(dom, "@")
|
||||
if strings.HasSuffix(e, "@"+dom) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Exact match
|
||||
if e == a {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// contains reports whether slice contains the given value v.
|
||||
// Comparison is done after trimming spaces on both sides.
|
||||
func contains(slice []string, v string) bool {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return false
|
||||
}
|
||||
for _, s := range slice {
|
||||
if strings.TrimSpace(s) == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// extractStringSlice tries to normalize a generic claim value into a []string.
|
||||
//
|
||||
// It supports:
|
||||
// - []string
|
||||
// - []interface{} (only string elements are kept)
|
||||
// - string (split by comma and/or whitespace)
|
||||
// Any other type will result in an empty slice.
|
||||
func extractStringSlice(v interface{}) []string {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch vv := v.(type) {
|
||||
case []string:
|
||||
// Return a shallow copy to avoid accidental modification.
|
||||
out := make([]string, 0, len(vv))
|
||||
for _, s := range vv {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
|
||||
case []interface{}:
|
||||
out := make([]string, 0, len(vv))
|
||||
for _, item := range vv {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
|
||||
case string:
|
||||
// Allow comma- or whitespace-separated group lists.
|
||||
s := strings.TrimSpace(vv)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
// Replace commas with spaces, then split on whitespace.
|
||||
s = strings.ReplaceAll(s, ",", " ")
|
||||
parts := strings.Fields(s)
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// intersects reports whether slice a and b share at least one common element.
|
||||
// Matching is done after trimming spaces on both sides.
|
||||
func intersects(a, b []string) bool {
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
m := make(map[string]struct{}, len(a))
|
||||
for _, s := range a {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
m[s] = struct{}{}
|
||||
}
|
||||
|
||||
for _, s := range b {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := m[s]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Vendored
+1
-1
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import type { BaseButton, BaseText } from '@gl/main/components'
|
||||
import type { BaseButton, BaseText } from 'gl-web-main/components'
|
||||
import type BaseSvg from './src/components/base/baseSvg.vue'
|
||||
|
||||
declare module 'vue' {
|
||||
|
||||
+2
-2
@@ -11,14 +11,13 @@
|
||||
"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",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gl/main": "0.0.50",
|
||||
"@vue/eslint-config-typescript": "12.0.0",
|
||||
"@xterm/addon-fit": "0.10.0",
|
||||
"@xterm/addon-web-links": "0.11.0",
|
||||
@@ -26,6 +25,7 @@
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"axios": "^1.9.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"gl-web-main": "^1.0.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jsencrypt": "^3.3.2",
|
||||
"pinia": "^3.0.2",
|
||||
|
||||
+2
-2
@@ -27,8 +27,8 @@ import { computed } from 'vue'
|
||||
import { useAppStore } from './stores/modules/app'
|
||||
import { RouterView } from 'vue-router'
|
||||
import type { ThemeConfig } from 'ant-design-vue/es/config-provider/context'
|
||||
import { ConfigProvider as GlConfigProvider } from '@gl/main/components'
|
||||
import { Languages } from '@gl/main'
|
||||
import { ConfigProvider as GlConfigProvider } from 'gl-web-main/components'
|
||||
import { Languages } from 'gl-web-main'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
|
||||
+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)
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
/*
|
||||
* @Author: LPY
|
||||
* @Date: 2025-06-03 09:29:03
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2025-08-25 19:23:29
|
||||
* @LastEditors: CU-Jon
|
||||
* @LastEditTime: 2025-09-27 03:16:38 EDT
|
||||
* @FilePath: \glkvm-cloud\web-ui\src\api\request.ts
|
||||
* @Description: 请求统一配置文件
|
||||
*/
|
||||
import { NotNeedHandledRequestErrorCodeList, RequestErrorCodeEnum } from '@/models/request'
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
import { showErrorMessage } from './requestError'
|
||||
import { BaseResponse, HttpService } from '@gl/main'
|
||||
import { BaseResponse, HttpService } from 'gl-web-main'
|
||||
import type { AxiosResponse } from 'axios'
|
||||
|
||||
export const httpService = new HttpService(
|
||||
@@ -43,7 +43,7 @@ export const httpService = new HttpService(
|
||||
},
|
||||
error => {
|
||||
console.log('请求错误', error)
|
||||
Promise.reject(error)
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
+10
-3
@@ -1,14 +1,14 @@
|
||||
/*
|
||||
* @Author: LPY
|
||||
* @Date: 2025-06-03 12:21:21
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2025-08-26 09:06:31
|
||||
* @LastEditors: CU-Jon
|
||||
* @LastEditTime: 2025-09-26 14:02:57 EDT
|
||||
* @FilePath: \glkvm-cloud\web-ui\src\api\user.ts
|
||||
* @Description: 用户相关请求api
|
||||
*/
|
||||
|
||||
import request from './request'
|
||||
import type { LoginParams } from '@/models/user'
|
||||
import type { LoginParams, AuthConfig } from '@/models/user'
|
||||
|
||||
/** 登录 */
|
||||
export function reqLogin (data: LoginParams) {
|
||||
@@ -31,4 +31,11 @@ export function reqCheckLoginStatus () {
|
||||
return request<void>({
|
||||
url: '/alive',
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取认证配置 */
|
||||
export function reqAuthConfig () {
|
||||
return request<AuthConfig>({
|
||||
url: '/auth-config',
|
||||
})
|
||||
}
|
||||
@@ -19,8 +19,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
import type { SelectOptions } from '@gl/main'
|
||||
import { BaseDivider, BaseDropdownSelect } from '@gl/main/components'
|
||||
import type { SelectOptions } from 'gl-web-main'
|
||||
import { BaseDivider, BaseDropdownSelect } from 'gl-web-main/components'
|
||||
import type { Trigger } from 'ant-design-vue/es/dropdown/props'
|
||||
import { computed } from 'vue'
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
|
||||
<script setup lang="ts" generic="T extends AnyObject">
|
||||
import type { DropdownGroupItem } from '@/models/component'
|
||||
import type { AnyObject } from '@gl/main'
|
||||
import { BaseDivider } from '@gl/main/components'
|
||||
import type { AnyObject } from 'gl-web-main'
|
||||
import { BaseDivider } from 'gl-web-main/components'
|
||||
import { Dropdown, Menu, Radio, RadioGroup } from 'ant-design-vue'
|
||||
import type { Trigger } from 'ant-design-vue/es/dropdown/props'
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { debounce } from '@gl/main'
|
||||
import { debounce } from 'gl-web-main'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
<script setup lang="ts" generic="T extends BaseData" >
|
||||
import type { BaseTableProps } from '@/models/component'
|
||||
import { isEmpty, type BaseData } from '@gl/main'
|
||||
import { isEmpty, type BaseData } from 'gl-web-main'
|
||||
import { Table } from 'ant-design-vue'
|
||||
import { useSlots } from 'vue'
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ViewType } from '@gl/main'
|
||||
import { ViewType } from 'gl-web-main'
|
||||
import { Tooltip } from 'ant-design-vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
|
||||
@@ -53,11 +53,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import useLanguage from '@/hooks/useLanguage'
|
||||
import { languageOptions, Languages } from '@gl/main'
|
||||
import { languageOptions, Languages } from 'gl-web-main'
|
||||
import BaseStep from './baseStep.vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
import { BaseDropdownSelect } from '@gl/main/components'
|
||||
import { BaseDropdownSelect } from 'gl-web-main/components'
|
||||
import { isForeignEnv } from '@/utils'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* @FilePath: \kvm-cloud-frontend\src\hooks\useDeviceQueue.ts
|
||||
* @Description: 自动发现设备队列
|
||||
*/
|
||||
import { AnyObject, deepClone, isEmpty } from '@gl/main'
|
||||
import { AnyObject, deepClone, isEmpty } from 'gl-web-main'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
const DEFAULT_QUEUE_INTERVAL = 400
|
||||
|
||||
@@ -11,7 +11,7 @@ import i18n from '@/lang'
|
||||
import { languageLabelMap } from '@/models/setting'
|
||||
import { computed } from 'vue'
|
||||
import { LocalStorageKeys, useLocalStorage } from './useLocalStorage'
|
||||
import { Languages } from '@gl/main'
|
||||
import { Languages } from 'gl-web-main'
|
||||
/** 语言hook */
|
||||
export default function useLanguage () {
|
||||
// @ts-ignore
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* @FilePath: \gl-cloud-frontend\src\hooks\usePageLink.js
|
||||
* @Description: 用于处理分页相关的逻辑
|
||||
*/
|
||||
import type { AnyObject, TableDataResponse } from '@gl/main'
|
||||
import type { AnyObject, TableDataResponse } from 'gl-web-main'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export const GLOBAL_PAGE_SIZE = 'GLOBAL_PAGE_SIZE'
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import useLanguage from './useLanguage'
|
||||
import type { SelectOptions } from '@gl/main'
|
||||
import type { SelectOptions } from 'gl-web-main'
|
||||
|
||||
export const useTranslatedOptions = <T>(options: SelectOptions<T>[]) => {
|
||||
const { t } = useLanguage()
|
||||
|
||||
@@ -10,7 +10,7 @@ import { createI18n } from 'vue-i18n'
|
||||
import zh from './locales/zh.json'
|
||||
import en from './locales/en.json'
|
||||
import useLanguage from '@/hooks/useLanguage'
|
||||
import { Languages } from '@gl/main'
|
||||
import { Languages } from 'gl-web-main'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
|
||||
@@ -14,15 +14,24 @@
|
||||
"cancel": "Cancel",
|
||||
"ok": "OK",
|
||||
"close": "Close",
|
||||
"about": "About"
|
||||
"about": "About",
|
||||
"maxLength": "Maximum length is {length} characters",
|
||||
"more": "More"
|
||||
},
|
||||
"login": {
|
||||
"authorizationRequired": "Authorization Required",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"signIn": "Sign In",
|
||||
"enterPwdTip": "Please enter your password",
|
||||
"incorrectPwd": "Incorrect Password",
|
||||
"signOut": "Sign Out"
|
||||
"notAuthorized": "Not Authorized",
|
||||
"signOut": "Sign Out",
|
||||
"authOptions": "Authentication Options",
|
||||
"ldapAuth": "Enter username and password for LDAP authentication",
|
||||
"webManagementAuth": "Leave username empty and use web management password",
|
||||
"or": "OR",
|
||||
"loginWithOidc": "Log in with OIDC"
|
||||
},
|
||||
"device": {
|
||||
"devices": "Devices",
|
||||
@@ -59,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,15 +14,24 @@
|
||||
"cancel": "取消",
|
||||
"ok": "确定",
|
||||
"close": "关闭",
|
||||
"about": "关于"
|
||||
"about": "关于",
|
||||
"maxLength": "最大长度为{length}个字符",
|
||||
"more": "更多"
|
||||
},
|
||||
"login": {
|
||||
"authorizationRequired": "需要授权",
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"signIn": "登录",
|
||||
"enterPwdTip": "请输入密码",
|
||||
"incorrectPwd": "密码错误",
|
||||
"signOut": "退出"
|
||||
"notAuthorized": "未授权",
|
||||
"signOut": "退出",
|
||||
"authOptions": "认证方式",
|
||||
"ldapAuth": "输入用户名和密码进行LDAP认证",
|
||||
"webManagementAuth": "留空用户名并使用Web管理密码",
|
||||
"or": "或",
|
||||
"loginWithOidc": "使用OIDC登录"
|
||||
},
|
||||
"device": {
|
||||
"devices": "设备数",
|
||||
@@ -59,7 +68,15 @@
|
||||
"errorMessage": "错误信息",
|
||||
"commandResponseDetail": "命令响应详情",
|
||||
"standardOutput": "标准输出",
|
||||
"standardErrorOutput": "标准错误输出"
|
||||
"standardErrorOutput": "标准错误输出",
|
||||
"status": "状态",
|
||||
"online": "在线",
|
||||
"offline": "离线",
|
||||
"editDescription": "编辑描述",
|
||||
"inputDescription": "输入描述",
|
||||
"requiredDescription": "请输入描述",
|
||||
"deleteDevice": "删除设备",
|
||||
"deleteDeviceConfirmTips": "你确定要删除这台设备吗?此操作不可撤销。"
|
||||
},
|
||||
"rtty": {
|
||||
"requestingDeviceToCreateTerminal": "正在请求设备创建终端...",
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import '@gl/main/style.css'
|
||||
import 'gl-web-main/style.css'
|
||||
import '@/styles/index.scss'
|
||||
import App from './App.vue'
|
||||
import projectInitialize from './projectInitialize'
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* @FilePath: \kvm-cloud-frontend\src\models\component.ts
|
||||
* @Description: 组件有关的models
|
||||
*/
|
||||
import type { SelectOptions } from '@gl/main'
|
||||
import type { SelectOptions } from 'gl-web-main'
|
||||
import type { TableProps } from 'ant-design-vue'
|
||||
|
||||
export interface DropdownGroupItem<T = any> {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* @Description: 设置相关类型声明
|
||||
*/
|
||||
|
||||
import { Languages, SelectOptions } from '@gl/main'
|
||||
import { Languages, SelectOptions } from 'gl-web-main'
|
||||
|
||||
/** 语言对应的label映射 */
|
||||
export const languageLabelMap = new Map<Languages, string>([
|
||||
|
||||
+12
-3
@@ -2,12 +2,21 @@
|
||||
* @Author: LPY
|
||||
* @Date: 2025-06-09 16:37:00
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2025-08-22 11:19:48
|
||||
* @FilePath: \glkvm-cloud\web-ui\src\models\user.ts
|
||||
* @LastEditTime: 2025-11-12 16:19:19
|
||||
* @FilePath: \glkvm-cloud\ui\src\models\user.ts
|
||||
* @Description: 用户相关类型声明
|
||||
*/
|
||||
|
||||
/** 登录参数 */
|
||||
/** 登录参数 (Login parameters) */
|
||||
export interface LoginParams {
|
||||
username?: string;
|
||||
password: string;
|
||||
authMethod?: 'ldap' | 'legacy';
|
||||
}
|
||||
|
||||
/** 认证配置 (Authentication configuration) */
|
||||
export interface AuthConfig {
|
||||
ldapEnabled: boolean;
|
||||
legacyPassword: boolean;
|
||||
oidcEnabled: boolean;
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import type { App } from 'vue'
|
||||
|
||||
import '@/assets/iconfont/iconfont.js'
|
||||
import BaseSvg from '@/components/base/baseSvg.vue'
|
||||
import { BaseButton, BaseText } from '@gl/main/components'
|
||||
import { BaseButton, BaseText } from 'gl-web-main/components'
|
||||
|
||||
/** 全局注册自定义组件 */
|
||||
const installComponent = function (app: App) {
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
* @Author: LPY
|
||||
* @Date: 2025-05-30 09:54:42
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2025-08-22 12:09:36
|
||||
* @FilePath: \glkvm-cloud\web-ui\src\router\whiteList.ts
|
||||
* @LastEditTime: 2025-11-12 16:36:23
|
||||
* @FilePath: \glkvm-cloud\ui\src\router\whiteList.ts
|
||||
* @Description: 路由白名单页
|
||||
*/
|
||||
export default [
|
||||
'/login',
|
||||
'/error',
|
||||
'/auth/oidc/login',
|
||||
]
|
||||
@@ -7,7 +7,7 @@
|
||||
* @Description: app相关状态存储
|
||||
*/
|
||||
import { LocalStorageKeys, useLocalStorage } from '@/hooks/useLocalStorage'
|
||||
import { baseTheme, createStyleInsert, darkTheme, replaceAntTheme, ThemeMode } from '@gl/main'
|
||||
import { baseTheme, createStyleInsert, darkTheme, replaceAntTheme, ThemeMode } from 'gl-web-main'
|
||||
import type { ThemeConfig } from 'ant-design-vue/es/config-provider/context'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, reactive } from 'vue'
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { getDeviceListApi } from '@/api/device'
|
||||
import { type DeviceInfo, type DeviceQuery } from '@/models/device'
|
||||
import { PageLink } from '@gl/main'
|
||||
import { PageLink } from 'gl-web-main'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: LPY
|
||||
* @Date: 2025-05-29 18:43:46
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2025-08-26 09:24:09
|
||||
* @LastEditors: CU-Jon
|
||||
* @LastEditTime: 2025-09-26 14:02:57 EDT
|
||||
* @FilePath: \glkvm-cloud\web-ui\src\stores\modules\user.ts
|
||||
* @Description: 用户相关状态存储
|
||||
*/
|
||||
@@ -37,10 +37,19 @@ export const useUserStore = defineStore('user', () => {
|
||||
|
||||
/** 登录 */
|
||||
const login = async (credentials: LoginParams) => {
|
||||
// 加密密码
|
||||
const params = {
|
||||
// 准备登录参数 (Prepare login parameters)
|
||||
const params: LoginParams = {
|
||||
password: credentials.password,
|
||||
}
|
||||
|
||||
// 如果提供了用户名和认证方法则添加 (Add username and auth method if provided)
|
||||
if (credentials.username) {
|
||||
params.username = credentials.username
|
||||
}
|
||||
if (credentials.authMethod) {
|
||||
params.authMethod = credentials.authMethod
|
||||
}
|
||||
|
||||
const data = await reqLogin(params)
|
||||
console.log(data.info)
|
||||
loginStatus.value = true
|
||||
@@ -74,6 +83,8 @@ export const useUserStore = defineStore('user', () => {
|
||||
logout()
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
// 即使退出请求失败也继续本地退出 (Continue local logout even if logout request fails)
|
||||
logout()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { t } from '@/hooks/useLanguage'
|
||||
import { glConfirm, type BaseConfirmProps } from '@gl/main'
|
||||
import { glConfirm, type BaseConfirmProps } from 'gl-web-main'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ValidateErrorEntity } from 'ant-design-vue/es/form/interface'
|
||||
import { AxiosError } from 'axios'
|
||||
|
||||
@@ -31,9 +31,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from 'vue'
|
||||
import { BaseModal } from '@gl/main/components'
|
||||
import { BaseModal } from 'gl-web-main/components'
|
||||
import { getAddDeviceScriptInfoApi } from '@/api/device'
|
||||
import { copyText } from '@gl/main'
|
||||
import { copyText } from 'gl-web-main'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { t } from '@/hooks/useLanguage'
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BaseModal } from '@gl/main/components'
|
||||
import { BaseModal } from 'gl-web-main/components'
|
||||
|
||||
const props = defineProps<{ open: boolean, type: string, res: any }>()
|
||||
|
||||
|
||||
@@ -79,8 +79,8 @@ import { reqExecuteCommand } from '@/api/device'
|
||||
import BaseTable from '@/components/base/baseTable.vue'
|
||||
import { t } from '@/hooks/useLanguage'
|
||||
import { DeviceInfo, ExecuteCommandFormData } from '@/models/device'
|
||||
import { useFakeUpgradeProgress } from '@gl/main'
|
||||
import { BaseModal } from '@gl/main/components'
|
||||
import { useFakeUpgradeProgress } from 'gl-web-main'
|
||||
import { BaseModal } from 'gl-web-main/components'
|
||||
import { TableColumnType } from 'ant-design-vue'
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import CmdResDetailDialog from './cmdResDetailDialog.vue'
|
||||
|
||||
@@ -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>
|
||||
@@ -64,8 +64,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { BaseModal } from '@gl/main/components'
|
||||
import { FormRules, OnBeforeOk, useValidateInfo } from '@gl/main'
|
||||
import { BaseModal } from 'gl-web-main/components'
|
||||
import { FormRules, OnBeforeOk, useValidateInfo } from 'gl-web-main'
|
||||
import { t } from '@/hooks/useLanguage'
|
||||
import { DeviceInfo, ExecuteCommandFormData } from '@/models/device'
|
||||
import { FormInstance } from 'ant-design-vue'
|
||||
|
||||
@@ -44,7 +44,7 @@ import '@xterm/xterm/css/xterm.css'
|
||||
import OverlayAddon from './components/xterm-addon-overlay'
|
||||
import ContextMenu from './components/contextMenu.vue'
|
||||
import RttyKeyboard from './components/rttyKeyboard.vue'
|
||||
import { BaseModal } from '@gl/main/components'
|
||||
import { BaseModal } from 'gl-web-main/components'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { t } from '@/hooks/useLanguage'
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ import NoDevicePage from './components/noDevicePage.vue'
|
||||
import { useDeviceStore } from '@/stores/modules/device'
|
||||
import DeviceListView from './components/deviceListView.vue'
|
||||
import { reactive, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { BaseLoading } from '@gl/main/components'
|
||||
import { BaseLoading } from 'gl-web-main/components'
|
||||
import AddDeviceDialog from './components/addDeviceDialog.vue'
|
||||
|
||||
const deviceStore = useDeviceStore()
|
||||
|
||||
@@ -2,16 +2,49 @@
|
||||
* @Author: LPY
|
||||
* @Date: 2025-05-30 10:48:43
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2025-08-26 17:11:12
|
||||
* @FilePath: \glkvm-cloud\web-ui\src\views\login\loginPage.vue
|
||||
* @LastEditTime: 2025-11-12 17:01:59
|
||||
* @FilePath: \glkvm-cloud\ui\src\views\login\loginPage.vue
|
||||
* @Description: 登录页面
|
||||
-->
|
||||
<template>
|
||||
<BaseWhitePage>
|
||||
<LoginBox>
|
||||
<BaseText type="large-title-m" style="margin: 24px 0 16px;">
|
||||
{{ $t('login.authorizationRequired') }}
|
||||
</BaseText>
|
||||
<div style="display: flex; align-items: center; justify-content: center; margin: 24px 0 16px;">
|
||||
<BaseText type="large-title-m">
|
||||
{{ $t('login.authorizationRequired') }}
|
||||
</BaseText>
|
||||
|
||||
<!-- 认证选项帮助 - 仅在启用LDAP时显示 (Auth options help - only show when LDAP is enabled) -->
|
||||
<div v-if="isLdapEnabled" class="auth-help-container">
|
||||
<div
|
||||
class="help-icon"
|
||||
@mouseenter="showTooltip = true"
|
||||
@mouseleave="showTooltip = false"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
style="margin-left: 8px; color: #656d76; cursor: help;"
|
||||
>
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
||||
<path d="M5.255 5.786a.237.237 0 0 0 .241.247h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343
|
||||
1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0
|
||||
.25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655
|
||||
.59-2.75 2.286zm1.557 5.763c0 .533.425.927 1.01.927.609 0 1.028-.394 1.028-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009
|
||||
.388-1.009.40z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<!-- 提示框 (Tooltip) -->
|
||||
<div v-show="showTooltip" class="auth-tooltip">
|
||||
<div style="font-weight: bold; margin-bottom: 4px;">{{ $t('login.authOptions') }}:</div>
|
||||
<div>• {{ $t('login.ldapAuth') }}</div>
|
||||
<div>• {{ $t('login.webManagementAuth') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AForm
|
||||
class="dense-form"
|
||||
ref="formRef"
|
||||
@@ -21,6 +54,16 @@
|
||||
style="width: 100%;"
|
||||
@validate="handleValidate"
|
||||
>
|
||||
<!-- 用户名字段 - 仅在启用LDAP时显示 (Username field - only show if LDAP is enabled) -->
|
||||
<AFormItem v-if="isLdapEnabled" name="username">
|
||||
<AInput
|
||||
name="username"
|
||||
v-model:value="state.formModel.username"
|
||||
:placeholder="$t('login.username')"
|
||||
@pressEnter="handleLogin"
|
||||
/>
|
||||
</AFormItem>
|
||||
|
||||
<AFormItem name="password">
|
||||
<GlPassword
|
||||
name="password"
|
||||
@@ -34,6 +77,16 @@
|
||||
<BaseButton medium type="primary" style="width: 100%;margin-top: 16px;" :loading="state.loading" @click="handleLogin">
|
||||
{{ $t('login.signIn') }}
|
||||
</BaseButton>
|
||||
|
||||
<div v-if="isOidcEnabled" class="google-login-box">
|
||||
<a-divider style="border-color: var(--gl-color-line-divider1);color: var(--gl-color-text-level3);font-weight: normal;">
|
||||
{{ $t('login.or') }}
|
||||
</a-divider>
|
||||
<BaseButton medium class="google-login-btn" :loading="state.oidcLoading" @click="handleLoginWithOidc">
|
||||
<!-- <BaseSvg name="gl-icon-google" :size="20" style="margin-right: 10px;"/> -->
|
||||
{{ $t('login.loginWithOidc') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</LoginBox>
|
||||
</BaseWhitePage>
|
||||
</template>
|
||||
@@ -41,32 +94,71 @@
|
||||
<script setup lang="ts">
|
||||
import BaseWhitePage from '@/components/base/baseWhitePage.vue'
|
||||
import LoginBox from './components/loginBox.vue'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { computed, reactive, ref, onMounted } from 'vue'
|
||||
import { t } from '@/hooks/useLanguage'
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
import { useValidateInfo, type FormRules } from '@gl/main'
|
||||
import { GlPassword } from '@gl/main/components'
|
||||
import { useValidateInfo, type FormRules } from 'gl-web-main'
|
||||
import { GlPassword } from 'gl-web-main/components'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { LoginParams } from '@/models/user'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { LoginParams, AuthConfig } from '@/models/user'
|
||||
import { message, Input, Form } from 'ant-design-vue'
|
||||
import { reqAuthConfig } from '@/api/user'
|
||||
|
||||
const AInput = Input
|
||||
const AForm = Form
|
||||
const AFormItem = Form.Item
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const { handleValidate } = useValidateInfo<LoginParams>()
|
||||
|
||||
const formRef = ref(null)
|
||||
const authConfig = ref<AuthConfig | null>(null)
|
||||
|
||||
const state = reactive<{formModel: LoginParams, loading: boolean}>({
|
||||
// 计算属性来可靠地检查LDAP是否启用 (Computed property to reliably check if LDAP is enabled)
|
||||
const isLdapEnabled = computed(() => {
|
||||
return authConfig.value?.ldapEnabled === true
|
||||
})
|
||||
|
||||
// 计算是否允许OIDC认证
|
||||
const isOidcEnabled = computed(() => {
|
||||
return authConfig.value?.oidcEnabled === true
|
||||
})
|
||||
|
||||
const state = reactive<{formModel: LoginParams, loading: boolean, oidcLoading: boolean}>({
|
||||
formModel: {
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
loading: false,
|
||||
oidcLoading: false,
|
||||
})
|
||||
|
||||
const showTooltip = ref(false)
|
||||
|
||||
const formRules = computed<FormRules<LoginParams>>(() => {
|
||||
return {
|
||||
const rules: FormRules<LoginParams> = {
|
||||
password: [{ required: true, message: 'login.enterPwdTip'}],
|
||||
}
|
||||
|
||||
// 用户名为可选字段,支持双重认证模式 (Username is optional, supporting dual authentication modes)
|
||||
return rules
|
||||
})
|
||||
|
||||
// 加载认证配置 (Load authentication configuration)
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await reqAuthConfig()
|
||||
|
||||
// 提取配置数据 (Extract config data)
|
||||
const configData = response?.info || response?.data?.info || response?.data || response
|
||||
authConfig.value = configData
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load auth config:', error)
|
||||
// 回退 - 无LDAP可用 (Fallback - no LDAP available)
|
||||
authConfig.value = { ldapEnabled: false, legacyPassword: true, oidcEnabled: false }
|
||||
}
|
||||
})
|
||||
|
||||
// 登录按钮
|
||||
@@ -74,7 +166,14 @@ const handleLogin = () => {
|
||||
formRef.value.validate().then(async () => {
|
||||
state.loading = true
|
||||
try {
|
||||
await useUserStore().login(state.formModel)
|
||||
// 基于用户名自动确定认证方法 (Auto-determine auth method based on username)
|
||||
const loginData: LoginParams = {
|
||||
username: state.formModel.username,
|
||||
password: state.formModel.password,
|
||||
authMethod: (state.formModel.username && authConfig.value?.ldapEnabled) ? 'ldap' : 'legacy',
|
||||
}
|
||||
|
||||
await useUserStore().login(loginData)
|
||||
// 登录成功后跳转到首页或之前尝试访问的页面
|
||||
const redirect = router.currentRoute.value.query.redirect as string || '/'
|
||||
console.log(redirect)
|
||||
@@ -84,11 +183,103 @@ const handleLogin = () => {
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
state.loading = false
|
||||
message.error(t('login.incorrectPwd'))
|
||||
|
||||
// 检查后端返回的错误类型 (Check error type returned by backend)
|
||||
const errorData = error?.response?.data
|
||||
const backendError = errorData?.error || ''
|
||||
|
||||
// 根据后端返回的具体错误类型显示不同消息 (Show different messages based on specific error type from backend)
|
||||
if (backendError === 'user not authorized') {
|
||||
// 授权失败 - 用户存在但权限不足 (Authorization failure - user exists but insufficient permissions)
|
||||
message.error(t('login.notAuthorized'))
|
||||
} else {
|
||||
// 认证失败 - 用户名/密码错误 (Authentication failure - incorrect username/password)
|
||||
message.error(t('login.incorrectPwd'))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// oidc登录按钮
|
||||
const handleLoginWithOidc = () => {
|
||||
state.oidcLoading = true
|
||||
const baseUrl = window.location.origin
|
||||
window.location.href = `${baseUrl}/auth/oidc/login`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.auth-help-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.help-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: #0066cc !important;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-tooltip {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
background-color: #2c3e50;
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
width: 320px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
|
||||
// 添加箭头 (Add arrow)
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-bottom: 6px solid #2c3e50;
|
||||
}
|
||||
|
||||
// 确保多行文本正确显示 (Ensure multi-line text displays correctly)
|
||||
div {
|
||||
white-space: normal;
|
||||
line-height: 1.4;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.google-login-box {
|
||||
width: 100%;
|
||||
|
||||
.google-login-btn {
|
||||
width: 100%;
|
||||
color: var(--gl-color-text-google);
|
||||
background-color: var(--gl-color-bg-google);
|
||||
border-color: var(--gl-color-line-google);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
height: 40px;
|
||||
border-radius: 64px;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+12
-8
@@ -23,35 +23,39 @@ export default defineConfig(({ mode }) => {
|
||||
port: 3011,
|
||||
proxy: {
|
||||
'/devs': {
|
||||
target: 'https://49.7.174.146:1443',
|
||||
target: 'https://107.173.152.173',
|
||||
secure: false,
|
||||
},
|
||||
'/signin': {
|
||||
target: 'https://49.7.174.146:1443/',
|
||||
target: 'https://107.173.152.173',
|
||||
secure: false,
|
||||
},
|
||||
'/signout': {
|
||||
target: 'https://49.7.174.146:1443',
|
||||
target: 'https://107.173.152.173',
|
||||
secure: false,
|
||||
},
|
||||
'/alive': {
|
||||
target: 'https://49.7.174.146:1443',
|
||||
target: 'https://107.173.152.173',
|
||||
secure: false,
|
||||
},
|
||||
'/get': {
|
||||
target: 'https://49.7.174.146:1443',
|
||||
target: 'https://107.173.152.173',
|
||||
secure: false,
|
||||
},
|
||||
'^/cmd/.*': {
|
||||
target: 'https://49.7.174.146:1443',
|
||||
target: 'https://107.173.152.173',
|
||||
secure: false,
|
||||
},
|
||||
'^/connect/.*': {
|
||||
ws: true,
|
||||
target: 'https://49.7.174.146:1443',
|
||||
target: 'https://107.173.152.173',
|
||||
},
|
||||
'^/web/*': {
|
||||
target: 'https://49.7.174.146:1443',
|
||||
target: 'https://107.173.152.173',
|
||||
},
|
||||
'/auth-config': {
|
||||
target: 'https://107.173.152.173',
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+553
-529
File diff suppressed because it is too large
Load Diff
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