新增后台管理
This commit is contained in:
@@ -59,12 +59,17 @@ web:
|
||||
host: 0.0.0.0
|
||||
port: 8080
|
||||
static_dir: ./dist
|
||||
admin:
|
||||
username: admin
|
||||
password: admin
|
||||
session_secret: ""
|
||||
session_secure: false
|
||||
```
|
||||
|
||||
配置优先级:
|
||||
|
||||
```text
|
||||
内置默认值 < 配置文件 < 命令行参数
|
||||
内置默认值 < 配置文件 < 环境变量 < 命令行参数
|
||||
```
|
||||
|
||||
也可以用命令行临时覆盖监听地址、PSK 和 TLS 设置:
|
||||
@@ -112,10 +117,19 @@ go run .
|
||||
|
||||
构建后的文件位于项目根目录 `dist/`,Gin 会提供静态文件服务;`/api` 路径保留给后端接口。
|
||||
|
||||
管理页面位于 `/admin`,默认管理员账号为 `admin` / `admin`。生产环境请修改 `web.admin.password` 或设置 `MESH_ADMIN_PASSWORD`,并配置固定的 `web.admin.session_secret` 或 `MESH_ADMIN_SESSION_SECRET`;如果 `session_secret` 为空,程序会在启动时生成临时签名密钥,重启后需要重新登录。后台支持新增管理员用户和修改用户密码;密码使用 bcrypt hash 保存,API 不会返回密码 hash。修改密码不会立即使已签发 Session 失效,当前 Session 到期或退出登录后才需要使用新密码。
|
||||
|
||||
常用 API:
|
||||
|
||||
```text
|
||||
GET /api/health
|
||||
POST /api/admin/login
|
||||
POST /api/admin/logout
|
||||
GET /api/admin/me
|
||||
GET /api/admin/mqtt/status
|
||||
GET /api/admin/users
|
||||
POST /api/admin/users
|
||||
PUT /api/admin/users/:id/password
|
||||
GET /api/nodeinfo
|
||||
GET /api/nodeinfo/:id
|
||||
GET /api/map-reports
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
adminRole = "admin"
|
||||
adminSessionCookie = "mesh_admin_session"
|
||||
)
|
||||
|
||||
type adminUserDTO struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type sessionClaims struct {
|
||||
UserID uint64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
Expires int64 `json:"expires"`
|
||||
}
|
||||
|
||||
type sessionManager struct {
|
||||
secret []byte
|
||||
secure bool
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func newSessionManager(cfg webAdminConfig) (*sessionManager, error) {
|
||||
secret := strings.TrimSpace(cfg.SessionSecret)
|
||||
if secret == "" {
|
||||
generated := make([]byte, 32)
|
||||
if _, err := rand.Read(generated); err != nil {
|
||||
return nil, fmt.Errorf("generate admin session secret: %w", err)
|
||||
}
|
||||
return &sessionManager{secret: generated, secure: cfg.SessionSecure, ttl: 24 * time.Hour}, nil
|
||||
}
|
||||
return &sessionManager{secret: []byte(secret), secure: cfg.SessionSecure, ttl: 24 * time.Hour}, nil
|
||||
}
|
||||
|
||||
func hashPassword(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
func verifyPassword(hash, password string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
func adminUserResponse(user userRecord) adminUserDTO {
|
||||
return adminUserDTO{Username: user.Username, Role: user.Role}
|
||||
}
|
||||
|
||||
func (sm *sessionManager) newCookie(user userRecord) (*http.Cookie, error) {
|
||||
claims := sessionClaims{UserID: user.ID, Username: user.Username, Role: user.Role, Expires: time.Now().Add(sm.ttl).Unix()}
|
||||
data, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload := base64.RawURLEncoding.EncodeToString(data)
|
||||
signature := sm.sign(payload)
|
||||
return &http.Cookie{
|
||||
Name: adminSessionCookie,
|
||||
Value: payload + "." + signature,
|
||||
Path: "/",
|
||||
MaxAge: int(sm.ttl.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: sm.secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (sm *sessionManager) clearCookie() *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: adminSessionCookie,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: sm.secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
}
|
||||
|
||||
func (sm *sessionManager) claimsFromRequest(c *gin.Context) (*sessionClaims, error) {
|
||||
cookie, err := c.Cookie(adminSessionCookie)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts := strings.Split(cookie, ".")
|
||||
if len(parts) != 2 {
|
||||
return nil, errors.New("invalid session")
|
||||
}
|
||||
if !hmac.Equal([]byte(parts[1]), []byte(sm.sign(parts[0]))) {
|
||||
return nil, errors.New("invalid session signature")
|
||||
}
|
||||
data, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var claims sessionClaims
|
||||
if err := json.Unmarshal(data, &claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if claims.Expires <= time.Now().Unix() {
|
||||
return nil, errors.New("session expired")
|
||||
}
|
||||
if claims.Role != adminRole {
|
||||
return nil, errors.New("admin required")
|
||||
}
|
||||
return &claims, nil
|
||||
}
|
||||
|
||||
func (sm *sessionManager) sign(payload string) string {
|
||||
mac := hmac.New(sha256.New, sm.secret)
|
||||
mac.Write([]byte(payload))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func requireAdmin(sm *sessionManager) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
claims, err := sm.claimsFromRequest(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "admin login required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set("admin_claims", claims)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,14 @@ type webConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
StaticDir string `yaml:"static_dir"`
|
||||
Admin webAdminConfig `yaml:"admin"`
|
||||
}
|
||||
|
||||
type webAdminConfig struct {
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
SessionSecret string `yaml:"session_secret"`
|
||||
SessionSecure bool `yaml:"session_secure"`
|
||||
}
|
||||
|
||||
type rawConfig struct {
|
||||
@@ -99,6 +107,14 @@ type rawWebConfig struct {
|
||||
Host *string `yaml:"host"`
|
||||
Port *int `yaml:"port"`
|
||||
StaticDir *string `yaml:"static_dir"`
|
||||
Admin *rawWebAdminConfig `yaml:"admin"`
|
||||
}
|
||||
|
||||
type rawWebAdminConfig struct {
|
||||
Username *string `yaml:"username"`
|
||||
Password *string `yaml:"password"`
|
||||
SessionSecret *string `yaml:"session_secret"`
|
||||
SessionSecure *bool `yaml:"session_secure"`
|
||||
}
|
||||
|
||||
// defaultConfig 返回内置默认配置。
|
||||
@@ -126,6 +142,12 @@ func defaultConfig() *config {
|
||||
Host: "0.0.0.0",
|
||||
Port: 8080,
|
||||
StaticDir: "./dist",
|
||||
Admin: webAdminConfig{
|
||||
Username: "admin",
|
||||
Password: "admin",
|
||||
SessionSecret: "",
|
||||
SessionSecure: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -290,6 +312,30 @@ func normalizeConfig(raw rawConfig) (*config, bool) {
|
||||
} else {
|
||||
cfg.Web.StaticDir = *raw.Web.StaticDir
|
||||
}
|
||||
if raw.Web.Admin == nil {
|
||||
changed = true
|
||||
} else {
|
||||
if raw.Web.Admin.Username == nil {
|
||||
changed = true
|
||||
} else {
|
||||
cfg.Web.Admin.Username = *raw.Web.Admin.Username
|
||||
}
|
||||
if raw.Web.Admin.Password == nil {
|
||||
changed = true
|
||||
} else {
|
||||
cfg.Web.Admin.Password = *raw.Web.Admin.Password
|
||||
}
|
||||
if raw.Web.Admin.SessionSecret == nil {
|
||||
changed = true
|
||||
} else {
|
||||
cfg.Web.Admin.SessionSecret = *raw.Web.Admin.SessionSecret
|
||||
}
|
||||
if raw.Web.Admin.SessionSecure == nil {
|
||||
changed = true
|
||||
} else {
|
||||
cfg.Web.Admin.SessionSecure = *raw.Web.Admin.SessionSecure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, changed
|
||||
@@ -318,6 +364,12 @@ func validateConfig(cfg *config) error {
|
||||
if cfg.Web.StaticDir == "" {
|
||||
return fmt.Errorf("web.static_dir is required when web is enabled")
|
||||
}
|
||||
if cfg.Web.Admin.Username == "" {
|
||||
return fmt.Errorf("web.admin.username is required when web is enabled")
|
||||
}
|
||||
if cfg.Web.Admin.Password == "" {
|
||||
return fmt.Errorf("web.admin.password is required when web is enabled")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -62,6 +62,19 @@ type MQTTClientRecordFields struct {
|
||||
MQTTRemotePort *string `gorm:"column:mqtt_remote_port"`
|
||||
}
|
||||
|
||||
type userRecord struct {
|
||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
Username string `gorm:"column:username;not null;uniqueIndex"`
|
||||
PasswordHash string `gorm:"column:password_hash;not null"`
|
||||
Role string `gorm:"column:role;not null;index"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"`
|
||||
}
|
||||
|
||||
func (userRecord) TableName() string {
|
||||
return "users"
|
||||
}
|
||||
|
||||
type nodeInfoRecord struct {
|
||||
NodeID string `gorm:"column:node_id;primaryKey;not null"`
|
||||
NodeNum int64 `gorm:"column:node_num;not null;index"`
|
||||
@@ -254,6 +267,7 @@ func (s *store) migrate() error {
|
||||
label string
|
||||
model any
|
||||
}{
|
||||
{label: "users", model: &userRecord{}},
|
||||
{label: "nodeinfo", model: &nodeInfoRecord{}},
|
||||
{label: "map_report", model: &mapReportRecord{}},
|
||||
{label: "text_message", model: &textMessageRecord{}},
|
||||
|
||||
+104
-1
@@ -2,16 +2,19 @@ package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestOpenStoreCreatesTables(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
for _, table := range []string{"nodeinfo", "map_report", "text_message", "position", "telemetry", "routing", "traceroute"} {
|
||||
for _, table := range []string{"users", "nodeinfo", "map_report", "text_message", "position", "telemetry", "routing", "traceroute"} {
|
||||
var name string
|
||||
if err := rawTestDB(t, st).QueryRow("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", table).Scan(&name); err != nil {
|
||||
t.Fatalf("%s table missing: %v", table, err)
|
||||
@@ -187,6 +190,106 @@ func TestNodeInfoNullablePublicKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDefaultAdminCreatesAdminUser(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
if err := st.EnsureDefaultAdmin("admin", "admin"); err != nil {
|
||||
t.Fatalf("EnsureDefaultAdmin() error = %v", err)
|
||||
}
|
||||
|
||||
user, err := st.GetUserByUsername("admin")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByUsername() error = %v", err)
|
||||
}
|
||||
if user.Role != adminRole {
|
||||
t.Fatalf("role = %q, want admin", user.Role)
|
||||
}
|
||||
if user.PasswordHash == "admin" || user.PasswordHash == "" {
|
||||
t.Fatalf("password hash = %q, want bcrypt hash", user.PasswordHash)
|
||||
}
|
||||
if !verifyPassword(user.PasswordHash, "admin") {
|
||||
t.Fatalf("admin password did not verify")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDefaultAdminDoesNotOverwriteExistingUser(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
if err := st.EnsureDefaultAdmin("admin", "first"); err != nil {
|
||||
t.Fatalf("first EnsureDefaultAdmin() error = %v", err)
|
||||
}
|
||||
if err := st.EnsureDefaultAdmin("admin", "second"); err != nil {
|
||||
t.Fatalf("second EnsureDefaultAdmin() error = %v", err)
|
||||
}
|
||||
user, err := st.GetUserByUsername("admin")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByUsername() error = %v", err)
|
||||
}
|
||||
if !verifyPassword(user.PasswordHash, "first") || verifyPassword(user.PasswordHash, "second") {
|
||||
t.Fatalf("admin password was overwritten")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAdminUserCreatesHashedAdmin(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
user, err := st.CreateAdminUser("new-admin", "secret")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAdminUser() error = %v", err)
|
||||
}
|
||||
if user.Username != "new-admin" || user.Role != adminRole {
|
||||
t.Fatalf("user = %#v, want new-admin admin", user)
|
||||
}
|
||||
if user.PasswordHash == "secret" || !verifyPassword(user.PasswordHash, "secret") {
|
||||
t.Fatalf("password hash did not verify")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAdminUserRejectsDuplicateUsername(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
if _, err := st.CreateAdminUser("new-admin", "secret"); err != nil {
|
||||
t.Fatalf("first CreateAdminUser() error = %v", err)
|
||||
}
|
||||
if _, err := st.CreateAdminUser("new-admin", "secret"); !errors.Is(err, errUserAlreadyExists) {
|
||||
t.Fatalf("duplicate CreateAdminUser() error = %v, want errUserAlreadyExists", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserPasswordChangesHash(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
user, err := st.CreateAdminUser("new-admin", "old-secret")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAdminUser() error = %v", err)
|
||||
}
|
||||
oldHash := user.PasswordHash
|
||||
updated, err := st.UpdateUserPassword(user.ID, "new-secret")
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateUserPassword() error = %v", err)
|
||||
}
|
||||
if updated.PasswordHash == oldHash {
|
||||
t.Fatalf("password hash did not change")
|
||||
}
|
||||
if verifyPassword(updated.PasswordHash, "old-secret") || !verifyPassword(updated.PasswordHash, "new-secret") {
|
||||
t.Fatalf("updated password verification mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserPasswordMissingUser(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
if _, err := st.UpdateUserPassword(999, "new-secret"); !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatalf("UpdateUserPassword() error = %v, want record not found", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertTextMessageAppendsRows(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
@@ -161,8 +161,16 @@ func parseArgs() (*config, error) {
|
||||
flag.StringVar(&cfg.Web.Host, "web-host", cfg.Web.Host, "Web server listen host")
|
||||
flag.IntVar(&cfg.Web.Port, "web-port", cfg.Web.Port, "Web server listen port")
|
||||
flag.StringVar(&cfg.Web.StaticDir, "web-static-dir", cfg.Web.StaticDir, "Web frontend static files directory")
|
||||
flag.StringVar(&cfg.Web.Admin.Username, "admin-username", cfg.Web.Admin.Username, "Web admin username")
|
||||
flag.Parse()
|
||||
|
||||
if value := os.Getenv("MESH_ADMIN_PASSWORD"); value != "" {
|
||||
cfg.Web.Admin.Password = value
|
||||
}
|
||||
if value := os.Getenv("MESH_ADMIN_SESSION_SECRET"); value != "" {
|
||||
cfg.Web.Admin.SessionSecret = value
|
||||
}
|
||||
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -181,8 +189,11 @@ func run(cfg *config) error {
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
if err := store.EnsureDefaultAdmin(cfg.Web.Admin.Username, cfg.Web.Admin.Password); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
server, _, err := startMQTTServer(cfg, store)
|
||||
server, mqttAddr, err := startMQTTServer(cfg, store)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -190,7 +201,12 @@ func run(cfg *config) error {
|
||||
var httpServer *http.Server
|
||||
errCh := make(chan error, 1)
|
||||
if cfg.Web.Enabled {
|
||||
httpServer = newHTTPServer(cfg.Web, store)
|
||||
sessions, err := newSessionManager(cfg.Web.Admin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mqttStatus := mqttRuntimeStatus{server: server, address: mqttAddr, tls: cfg.MQTT.TLS.Enabled}
|
||||
httpServer = newHTTPServer(cfg.Web, store, sessions, mqttStatus)
|
||||
go func() {
|
||||
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errCh <- err
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { getHealth, getMapReports, getNodeInfo, getPositions, getTextMessages } from './api'
|
||||
import { getAdminMe, getHealth, getMapReports, getNodeInfo, getPositions, getTextMessages } from './api'
|
||||
import AdminDashboard from './components/AdminDashboard.vue'
|
||||
import AdminLogin from './components/AdminLogin.vue'
|
||||
import ChatPanel from './components/ChatPanel.vue'
|
||||
import MeshMap from './components/MeshMap.vue'
|
||||
import NodeListPanel from './components/NodeListPanel.vue'
|
||||
import type { HealthStatus, MapNode, MapReport, NodeInfo, NodeInfoById, PositionRecord, TextMessage } from './types'
|
||||
import type { AdminUser, HealthStatus, MapNode, MapReport, NodeInfo, NodeInfoById, PositionRecord, TextMessage } from './types'
|
||||
|
||||
const isAdminPage = window.location.pathname === '/admin'
|
||||
const adminUser = ref<AdminUser | null>(null)
|
||||
const adminChecking = ref(false)
|
||||
|
||||
const loading = ref(true)
|
||||
const nodePageLoading = ref(false)
|
||||
@@ -153,7 +159,23 @@ async function refresh(showLoading = true) {
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAdminSession() {
|
||||
adminChecking.value = true
|
||||
try {
|
||||
const response = await getAdminMe()
|
||||
adminUser.value = response.user
|
||||
} catch {
|
||||
adminUser.value = null
|
||||
} finally {
|
||||
adminChecking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isAdminPage) {
|
||||
checkAdminSession()
|
||||
return
|
||||
}
|
||||
refresh()
|
||||
refreshTimer = window.setInterval(() => refresh(false), 5000)
|
||||
})
|
||||
@@ -170,17 +192,30 @@ onBeforeUnmount(() => {
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">Meshtastic MQTT Server</p>
|
||||
<h1>MeshMap</h1>
|
||||
<h1>{{ isAdminPage ? 'Admin' : 'MeshMap' }}</h1>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<template v-if="isAdminPage">
|
||||
<a class="topbar-link" href="/">返回地图</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="status-pill" :class="{ ok: health?.status === 'ok' }">
|
||||
{{ health?.status ?? 'unknown' }} / db {{ health?.database ?? 'unknown' }}
|
||||
</span>
|
||||
<span class="counter">节点 {{ nodeTotal }} · 已加载消息 {{ messages.length }} · 坐标 {{ mapNodes.length }}</span>
|
||||
<a class="topbar-link" href="/admin">管理</a>
|
||||
<button @click="() => refresh()" :disabled="loading">{{ loading ? '刷新中...' : '刷新' }}</button>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<template v-if="isAdminPage">
|
||||
<div v-if="adminChecking" class="panel admin-loading">正在检查登录状态...</div>
|
||||
<AdminDashboard v-else-if="adminUser" :user="adminUser" @logout="adminUser = null" />
|
||||
<AdminLogin v-else @login="adminUser = $event" />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<section class="workspace">
|
||||
@@ -211,5 +246,6 @@ onBeforeUnmount(() => {
|
||||
@select-node="selectedNodeId = $event"
|
||||
@page-change="loadNodePage"
|
||||
/>
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -1,13 +1,53 @@
|
||||
import type { HealthStatus, ListResponse, MapReport, NodeInfo, PositionRecord, TextMessage } from './types'
|
||||
import type {
|
||||
AdminLoginResponse,
|
||||
AdminManagedUserResponse,
|
||||
AdminMqttStatus,
|
||||
AdminUsersResponse,
|
||||
HealthStatus,
|
||||
ListResponse,
|
||||
MapReport,
|
||||
NodeInfo,
|
||||
PositionRecord,
|
||||
TextMessage,
|
||||
} from './types'
|
||||
|
||||
async function getJSON<T>(path: string): Promise<T> {
|
||||
const response = await fetch(path)
|
||||
async function requestJSON<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(path, { credentials: 'same-origin', ...init })
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status} ${response.statusText}`)
|
||||
let message = `${response.status} ${response.statusText}`
|
||||
try {
|
||||
const data = (await response.json()) as { error?: string }
|
||||
if (data.error) {
|
||||
message = data.error
|
||||
}
|
||||
} catch {
|
||||
// Keep the HTTP status message when the response is not JSON.
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
function getJSON<T>(path: string): Promise<T> {
|
||||
return requestJSON<T>(path)
|
||||
}
|
||||
|
||||
function postJSON<T>(path: string, body?: unknown): Promise<T> {
|
||||
return requestJSON<T>(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body == null ? undefined : JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
function putJSON<T>(path: string, body?: unknown): Promise<T> {
|
||||
return requestJSON<T>(path, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body == null ? undefined : JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export function getHealth(): Promise<HealthStatus> {
|
||||
return getJSON<HealthStatus>('/api/health')
|
||||
}
|
||||
@@ -27,3 +67,31 @@ export function getTextMessages(limit = 100, offset = 0): Promise<ListResponse<T
|
||||
export function getPositions(limit = 500): Promise<ListResponse<PositionRecord>> {
|
||||
return getJSON<ListResponse<PositionRecord>>(`/api/positions?limit=${limit}`)
|
||||
}
|
||||
|
||||
export function adminLogin(username: string, password: string): Promise<AdminLoginResponse> {
|
||||
return postJSON<AdminLoginResponse>('/api/admin/login', { username, password })
|
||||
}
|
||||
|
||||
export function adminLogout(): Promise<{ status: string }> {
|
||||
return postJSON<{ status: string }>('/api/admin/logout')
|
||||
}
|
||||
|
||||
export function getAdminMe(): Promise<AdminLoginResponse> {
|
||||
return getJSON<AdminLoginResponse>('/api/admin/me')
|
||||
}
|
||||
|
||||
export function getAdminMqttStatus(): Promise<AdminMqttStatus> {
|
||||
return getJSON<AdminMqttStatus>('/api/admin/mqtt/status')
|
||||
}
|
||||
|
||||
export function getAdminUsers(): Promise<AdminUsersResponse> {
|
||||
return getJSON<AdminUsersResponse>('/api/admin/users')
|
||||
}
|
||||
|
||||
export function createAdminUser(username: string, password: string): Promise<AdminManagedUserResponse> {
|
||||
return postJSON<AdminManagedUserResponse>('/api/admin/users', { username, password })
|
||||
}
|
||||
|
||||
export function updateAdminUserPassword(id: number, password: string): Promise<AdminManagedUserResponse> {
|
||||
return putJSON<AdminManagedUserResponse>(`/api/admin/users/${id}/password`, { password })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { adminLogout, createAdminUser, getAdminMqttStatus, getAdminUsers, updateAdminUserPassword } from '../api'
|
||||
import type { AdminManagedUser, AdminMqttStatus, AdminUser } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
user: AdminUser
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
logout: []
|
||||
}>()
|
||||
|
||||
const status = ref<AdminMqttStatus | null>(null)
|
||||
const users = ref<AdminManagedUser[]>([])
|
||||
const loading = ref(false)
|
||||
const usersLoading = ref(false)
|
||||
const error = ref('')
|
||||
const userError = ref('')
|
||||
const userMessage = ref('')
|
||||
const newUsername = ref('')
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const passwordEdits = ref<Record<number, string>>({})
|
||||
const passwordSaving = ref<Record<number, boolean>>({})
|
||||
let timer: number | undefined
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = seconds % 60
|
||||
return `${hours}h ${minutes}m ${secs}s`
|
||||
}
|
||||
|
||||
function formatTime(value: string): string {
|
||||
return new Date(value).toLocaleString()
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
status.value = await getAdminMqttStatus()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshUsers() {
|
||||
usersLoading.value = true
|
||||
userError.value = ''
|
||||
try {
|
||||
const response = await getAdminUsers()
|
||||
users.value = response.items
|
||||
} catch (err) {
|
||||
userError.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
usersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createUser() {
|
||||
userError.value = ''
|
||||
userMessage.value = ''
|
||||
if (!newUsername.value.trim()) {
|
||||
userError.value = '用户名不能为空'
|
||||
return
|
||||
}
|
||||
if (!newPassword.value) {
|
||||
userError.value = '密码不能为空'
|
||||
return
|
||||
}
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
userError.value = '两次输入的密码不一致'
|
||||
return
|
||||
}
|
||||
|
||||
usersLoading.value = true
|
||||
try {
|
||||
await createAdminUser(newUsername.value.trim(), newPassword.value)
|
||||
newUsername.value = ''
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
userMessage.value = '用户已创建'
|
||||
await refreshUsers()
|
||||
} catch (err) {
|
||||
userError.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
usersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePassword(user: AdminManagedUser) {
|
||||
const password = passwordEdits.value[user.id] || ''
|
||||
userError.value = ''
|
||||
userMessage.value = ''
|
||||
if (!password) {
|
||||
userError.value = '新密码不能为空'
|
||||
return
|
||||
}
|
||||
|
||||
passwordSaving.value = { ...passwordSaving.value, [user.id]: true }
|
||||
try {
|
||||
await updateAdminUserPassword(user.id, password)
|
||||
passwordEdits.value = { ...passwordEdits.value, [user.id]: '' }
|
||||
userMessage.value = `${user.username} 的密码已修改`
|
||||
await refreshUsers()
|
||||
} catch (err) {
|
||||
userError.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
passwordSaving.value = { ...passwordSaving.value, [user.id]: false }
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await adminLogout()
|
||||
} finally {
|
||||
emit('logout')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshStatus()
|
||||
refreshUsers()
|
||||
timer = window.setInterval(refreshStatus, 5000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer !== undefined) {
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-dashboard">
|
||||
<div class="panel admin-status-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">Admin</p>
|
||||
<h2>MQTT 服务状态</h2>
|
||||
</div>
|
||||
<div class="admin-actions">
|
||||
<span class="badge">{{ props.user.username }}</span>
|
||||
<button @click="refreshStatus" :disabled="loading">{{ loading ? '刷新中...' : '刷新' }}</button>
|
||||
<button @click="logout">退出</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<div v-if="!status" class="empty">正在加载 MQTT 状态...</div>
|
||||
<div v-else class="admin-status-grid">
|
||||
<div><span>运行状态</span><strong>{{ status.running ? '运行中' : '未运行' }}</strong></div>
|
||||
<div><span>监听地址</span><strong>{{ status.address || '-' }}</strong></div>
|
||||
<div><span>TLS</span><strong>{{ status.tls ? '启用' : '未启用' }}</strong></div>
|
||||
<div><span>Uptime</span><strong>{{ formatUptime(status.uptime || 0) }}</strong></div>
|
||||
<div><span>当前连接</span><strong>{{ status.clients_connected }}</strong></div>
|
||||
<div><span>订阅数</span><strong>{{ status.subscriptions }}</strong></div>
|
||||
<div><span>收到消息</span><strong>{{ status.messages_received }}</strong></div>
|
||||
<div><span>发送消息</span><strong>{{ status.messages_sent }}</strong></div>
|
||||
<div><span>收到包</span><strong>{{ status.packets_received }}</strong></div>
|
||||
<div><span>发送包</span><strong>{{ status.packets_sent }}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel admin-status-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">Users</p>
|
||||
<h2>用户管理</h2>
|
||||
</div>
|
||||
<button class="admin-button" @click="refreshUsers" :disabled="usersLoading">{{ usersLoading ? '刷新中...' : '刷新用户' }}</button>
|
||||
</div>
|
||||
|
||||
<form class="admin-form admin-user-form" @submit.prevent="createUser">
|
||||
<label>
|
||||
<span>用户名</span>
|
||||
<input v-model="newUsername" autocomplete="off" placeholder="new-admin" />
|
||||
</label>
|
||||
<label>
|
||||
<span>密码</span>
|
||||
<input v-model="newPassword" type="password" autocomplete="new-password" />
|
||||
</label>
|
||||
<label>
|
||||
<span>确认密码</span>
|
||||
<input v-model="confirmPassword" type="password" autocomplete="new-password" />
|
||||
</label>
|
||||
<button class="admin-button" :disabled="usersLoading" type="submit">新增用户</button>
|
||||
</form>
|
||||
<p v-if="userError" class="error">{{ userError }}</p>
|
||||
<p v-if="userMessage" class="success">{{ userMessage }}</p>
|
||||
|
||||
<div class="node-table-wrap">
|
||||
<table class="node-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>用户名</th>
|
||||
<th>角色</th>
|
||||
<th>创建时间</th>
|
||||
<th>更新时间</th>
|
||||
<th>新密码</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in users" :key="user.id">
|
||||
<td>{{ user.id }}</td>
|
||||
<td>{{ user.username }} <span v-if="user.username === props.user.username" class="badge">当前</span></td>
|
||||
<td>{{ user.role }}</td>
|
||||
<td>{{ formatTime(user.created_at) }}</td>
|
||||
<td>{{ formatTime(user.updated_at) }}</td>
|
||||
<td>
|
||||
<input
|
||||
v-model="passwordEdits[user.id]"
|
||||
class="admin-table-input"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
placeholder="输入新密码"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button class="admin-button" :disabled="passwordSaving[user.id]" @click="updatePassword(user)">
|
||||
{{ passwordSaving[user.id] ? '保存中...' : '修改密码' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="users.length === 0" class="empty">暂无用户</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel admin-status-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">Clients</p>
|
||||
<h2>MQTT 客户端</h2>
|
||||
</div>
|
||||
<span class="badge">{{ status?.clients?.length ?? 0 }}</span>
|
||||
</div>
|
||||
<div class="node-table-wrap">
|
||||
<table class="node-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client ID</th>
|
||||
<th>Username</th>
|
||||
<th>Listener</th>
|
||||
<th>Remote Addr</th>
|
||||
<th>Remote Host</th>
|
||||
<th>Remote Port</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="client in status?.clients || []" :key="client.client_id">
|
||||
<td>{{ client.client_id || '-' }}</td>
|
||||
<td>{{ client.username || '-' }}</td>
|
||||
<td>{{ client.listener || '-' }}</td>
|
||||
<td>{{ client.remote_addr || '-' }}</td>
|
||||
<td>{{ client.remote_host || '-' }}</td>
|
||||
<td>{{ client.remote_port || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="!status?.clients?.length" class="empty">暂无客户端连接</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { adminLogin } from '../api'
|
||||
import type { AdminUser } from '../types'
|
||||
|
||||
const emit = defineEmits<{
|
||||
login: [user: AdminUser]
|
||||
}>()
|
||||
|
||||
const username = ref('admin')
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function submitLogin() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const response = await adminLogin(username.value, password.value)
|
||||
emit('login', response.user)
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-login panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">Admin</p>
|
||||
<h2>管理员登录</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="admin-form" @submit.prevent="submitLogin">
|
||||
<label>
|
||||
<span>用户名</span>
|
||||
<input v-model="username" autocomplete="username" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>密码</span>
|
||||
<input v-model="password" type="password" autocomplete="current-password" required />
|
||||
</label>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<button :disabled="loading" type="submit">{{ loading ? '登录中...' : '登录' }}</button>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
@@ -78,7 +78,8 @@ h3 {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.topbar button {
|
||||
.topbar button,
|
||||
.topbar-link {
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
padding: 9px 16px;
|
||||
@@ -87,6 +88,10 @@ h3 {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.topbar-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.topbar button:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
@@ -337,6 +342,110 @@ h3 {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.admin-loading {
|
||||
padding: 24px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.admin-login,
|
||||
.admin-dashboard {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-form {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.admin-user-form {
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.admin-form label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: #334155;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-form input {
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.admin-form button,
|
||||
.admin-actions button,
|
||||
.admin-button {
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
padding: 9px 16px;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.admin-table-input {
|
||||
min-width: 160px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.success {
|
||||
margin: 0 16px 12px;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
color: #166534;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.admin-dashboard {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.admin-status-grid div {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.admin-status-grid span {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-status-grid strong {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -80,3 +80,62 @@ export interface MapNode {
|
||||
}
|
||||
|
||||
export type NodeInfoById = Record<string, NodeInfo>
|
||||
|
||||
export interface AdminUser {
|
||||
username: string
|
||||
role: string
|
||||
}
|
||||
|
||||
export interface AdminLoginResponse {
|
||||
user: AdminUser
|
||||
}
|
||||
|
||||
export interface AdminManagedUser {
|
||||
id: number
|
||||
username: string
|
||||
role: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface AdminUsersResponse {
|
||||
items: AdminManagedUser[]
|
||||
}
|
||||
|
||||
export interface AdminManagedUserResponse {
|
||||
user: AdminManagedUser
|
||||
}
|
||||
|
||||
export interface AdminMqttClient {
|
||||
client_id: string
|
||||
username: string
|
||||
listener: string
|
||||
remote_addr: string
|
||||
remote_host: string
|
||||
remote_port: string
|
||||
}
|
||||
|
||||
export interface AdminMqttStatus {
|
||||
running: boolean
|
||||
address: string
|
||||
tls: boolean
|
||||
version: string
|
||||
started: number
|
||||
uptime: number
|
||||
bytes_received: number
|
||||
bytes_sent: number
|
||||
clients_connected: number
|
||||
clients_disconnected: number
|
||||
clients_maximum: number
|
||||
clients_total: number
|
||||
messages_received: number
|
||||
messages_sent: number
|
||||
messages_dropped: number
|
||||
retained: number
|
||||
inflight: number
|
||||
inflight_dropped: number
|
||||
subscriptions: number
|
||||
packets_received: number
|
||||
packets_sent: number
|
||||
clients: AdminMqttClient[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
mqtt "github.com/mochi-mqtt/server/v2"
|
||||
)
|
||||
|
||||
type mqttStatusProvider interface {
|
||||
Status() adminMqttStatus
|
||||
}
|
||||
|
||||
type mqttRuntimeStatus struct {
|
||||
server *mqtt.Server
|
||||
address string
|
||||
tls bool
|
||||
}
|
||||
|
||||
type adminMqttStatus struct {
|
||||
Running bool `json:"running"`
|
||||
Address string `json:"address"`
|
||||
TLS bool `json:"tls"`
|
||||
Version string `json:"version"`
|
||||
Started int64 `json:"started"`
|
||||
Uptime int64 `json:"uptime"`
|
||||
BytesReceived int64 `json:"bytes_received"`
|
||||
BytesSent int64 `json:"bytes_sent"`
|
||||
ClientsConnected int64 `json:"clients_connected"`
|
||||
ClientsDisconnected int64 `json:"clients_disconnected"`
|
||||
ClientsMaximum int64 `json:"clients_maximum"`
|
||||
ClientsTotal int64 `json:"clients_total"`
|
||||
MessagesReceived int64 `json:"messages_received"`
|
||||
MessagesSent int64 `json:"messages_sent"`
|
||||
MessagesDropped int64 `json:"messages_dropped"`
|
||||
Retained int64 `json:"retained"`
|
||||
Inflight int64 `json:"inflight"`
|
||||
InflightDropped int64 `json:"inflight_dropped"`
|
||||
Subscriptions int64 `json:"subscriptions"`
|
||||
PacketsReceived int64 `json:"packets_received"`
|
||||
PacketsSent int64 `json:"packets_sent"`
|
||||
Clients []adminMqttClient `json:"clients"`
|
||||
}
|
||||
|
||||
type adminMqttClient struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Username string `json:"username"`
|
||||
Listener string `json:"listener"`
|
||||
RemoteAddr string `json:"remote_addr"`
|
||||
RemoteHost string `json:"remote_host"`
|
||||
RemotePort string `json:"remote_port"`
|
||||
}
|
||||
|
||||
func (m mqttRuntimeStatus) Status() adminMqttStatus {
|
||||
if m.server == nil || m.server.Info == nil {
|
||||
return adminMqttStatus{Running: false, Address: m.address, TLS: m.tls}
|
||||
}
|
||||
info := m.server.Info.Clone()
|
||||
status := adminMqttStatus{
|
||||
Running: true,
|
||||
Address: m.address,
|
||||
TLS: m.tls,
|
||||
Version: info.Version,
|
||||
Started: info.Started,
|
||||
Uptime: info.Uptime,
|
||||
BytesReceived: info.BytesReceived,
|
||||
BytesSent: info.BytesSent,
|
||||
ClientsConnected: info.ClientsConnected,
|
||||
ClientsDisconnected: info.ClientsDisconnected,
|
||||
ClientsMaximum: info.ClientsMaximum,
|
||||
ClientsTotal: info.ClientsTotal,
|
||||
MessagesReceived: info.MessagesReceived,
|
||||
MessagesSent: info.MessagesSent,
|
||||
MessagesDropped: info.MessagesDropped,
|
||||
Retained: info.Retained,
|
||||
Inflight: info.Inflight,
|
||||
InflightDropped: info.InflightDropped,
|
||||
Subscriptions: info.Subscriptions,
|
||||
PacketsReceived: info.PacketsReceived,
|
||||
PacketsSent: info.PacketsSent,
|
||||
}
|
||||
for _, client := range m.server.Clients.GetAll() {
|
||||
if client == nil || client.Closed() {
|
||||
continue
|
||||
}
|
||||
clientInfo := mqttClientInfoFromClient(client)
|
||||
status.Clients = append(status.Clients, adminMqttClient{
|
||||
ClientID: clientInfo.ClientID,
|
||||
Username: clientInfo.Username,
|
||||
Listener: clientInfo.Listener,
|
||||
RemoteAddr: clientInfo.RemoteAddr,
|
||||
RemoteHost: clientInfo.RemoteHost,
|
||||
RemotePort: clientInfo.RemotePort,
|
||||
})
|
||||
}
|
||||
return status
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var errUserAlreadyExists = errors.New("user already exists")
|
||||
|
||||
func (s *store) GetUserByUsername(username string) (*userRecord, error) {
|
||||
var user userRecord
|
||||
if err := s.db.Where("username = ?", username).Take(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (s *store) GetUserByID(id uint64) (*userRecord, error) {
|
||||
var user userRecord
|
||||
if err := s.db.Where("id = ?", id).Take(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (s *store) ListUsers() ([]userRecord, error) {
|
||||
var users []userRecord
|
||||
return users, s.db.Order("id ASC").Find(&users).Error
|
||||
}
|
||||
|
||||
func (s *store) CreateAdminUser(username, password string) (*userRecord, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
return nil, fmt.Errorf("username is required")
|
||||
}
|
||||
if password == "" {
|
||||
return nil, fmt.Errorf("password is required")
|
||||
}
|
||||
if _, err := s.GetUserByUsername(username); err == nil {
|
||||
return nil, errUserAlreadyExists
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
hash, err := hashPassword(password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hash user password: %w", err)
|
||||
}
|
||||
user := userRecord{Username: username, PasswordHash: hash, Role: adminRole}
|
||||
if err := s.db.Create(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (s *store) UpdateUserPassword(id uint64, password string) (*userRecord, error) {
|
||||
if id == 0 {
|
||||
return nil, fmt.Errorf("user id is required")
|
||||
}
|
||||
if password == "" {
|
||||
return nil, fmt.Errorf("password is required")
|
||||
}
|
||||
user, err := s.GetUserByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hash, err := hashPassword(password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hash user password: %w", err)
|
||||
}
|
||||
if err := s.db.Model(&userRecord{}).Where("id = ?", id).Updates(map[string]any{"password_hash": hash, "updated_at": time.Now()}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.PasswordHash = hash
|
||||
return s.GetUserByID(id)
|
||||
}
|
||||
|
||||
func (s *store) EnsureDefaultAdmin(username, password string) error {
|
||||
var existing userRecord
|
||||
err := s.db.Where("username = ?", username).Take(&existing).Error
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
hash, err := hashPassword(password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash admin password: %w", err)
|
||||
}
|
||||
user := userRecord{Username: username, PasswordHash: hash, Role: adminRole}
|
||||
if err := s.db.Create(&user).Error; err != nil {
|
||||
return fmt.Errorf("create default admin user: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -14,17 +14,19 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newHTTPServer(cfg webConfig, store *store) *http.Server {
|
||||
func newHTTPServer(cfg webConfig, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider) *http.Server {
|
||||
return &http.Server{
|
||||
Addr: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)),
|
||||
Handler: newRouter(cfg, store),
|
||||
Handler: newRouter(cfg, store, sessions, mqttStatus),
|
||||
}
|
||||
}
|
||||
|
||||
func newRouter(cfg webConfig, store *store) *gin.Engine {
|
||||
func newRouter(cfg webConfig, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider) *gin.Engine {
|
||||
r := gin.New()
|
||||
r.Use(gin.Logger(), gin.Recovery())
|
||||
registerAPIRoutes(r.Group("/api"), store)
|
||||
api := r.Group("/api")
|
||||
registerAPIRoutes(api, store)
|
||||
registerAdminRoutes(api.Group("/admin"), store, sessions, mqttStatus)
|
||||
registerStaticRoutes(r, cfg.StaticDir)
|
||||
return r
|
||||
}
|
||||
@@ -86,6 +88,112 @@ func registerAPIRoutes(r gin.IRouter, store *store) {
|
||||
})
|
||||
}
|
||||
|
||||
func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider) {
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
type createUserRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
type updatePasswordRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
userDTO := func(user userRecord) gin.H {
|
||||
return gin.H{"id": user.ID, "username": user.Username, "role": user.Role, "created_at": user.CreatedAt, "updated_at": user.UpdatedAt}
|
||||
}
|
||||
|
||||
r.POST("/login", func(c *gin.Context) {
|
||||
var req loginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid login request"})
|
||||
return
|
||||
}
|
||||
user, err := store.GetUserByUsername(req.Username)
|
||||
if err != nil || user.Role != adminRole || !verifyPassword(user.PasswordHash, req.Password) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
|
||||
return
|
||||
}
|
||||
cookie, err := sessions.newCookie(*user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
http.SetCookie(c.Writer, cookie)
|
||||
c.JSON(http.StatusOK, gin.H{"user": adminUserResponse(*user)})
|
||||
})
|
||||
r.POST("/logout", func(c *gin.Context) {
|
||||
http.SetCookie(c.Writer, sessions.clearCookie())
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
protected := r.Group("")
|
||||
protected.Use(requireAdmin(sessions))
|
||||
protected.GET("/me", func(c *gin.Context) {
|
||||
claims := c.MustGet("admin_claims").(*sessionClaims)
|
||||
c.JSON(http.StatusOK, gin.H{"user": adminUserDTO{Username: claims.Username, Role: claims.Role}})
|
||||
})
|
||||
protected.GET("/mqtt/status", func(c *gin.Context) {
|
||||
if mqttStatus == nil {
|
||||
c.JSON(http.StatusOK, adminMqttStatus{Running: false})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, mqttStatus.Status())
|
||||
})
|
||||
protected.GET("/users", func(c *gin.Context) {
|
||||
users, err := store.ListUsers()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
items := make([]gin.H, 0, len(users))
|
||||
for _, user := range users {
|
||||
items = append(items, userDTO(user))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
})
|
||||
protected.POST("/users", func(c *gin.Context) {
|
||||
var req createUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid create user request"})
|
||||
return
|
||||
}
|
||||
user, err := store.CreateAdminUser(req.Username, req.Password)
|
||||
if errors.Is(err, errUserAlreadyExists) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "username already exists"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"user": userDTO(*user)})
|
||||
})
|
||||
protected.PUT("/users/:id/password", func(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
|
||||
return
|
||||
}
|
||||
var req updatePasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid password request"})
|
||||
return
|
||||
}
|
||||
user, err := store.UpdateUserPassword(id, req.Password)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"user": userDTO(*user)})
|
||||
})
|
||||
}
|
||||
|
||||
func registerNodeInfoRoutes(r gin.IRouter, store *store, path string) {
|
||||
r.GET(path, func(c *gin.Context) {
|
||||
opts, ok := parseListOptions(c)
|
||||
|
||||
Reference in New Issue
Block a user