mirror of
https://github.com/gl-inet/glkvm-cloud.git
synced 2026-10-15 16:52:38 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cbda42f608 | |||
| 994c2394cf | |||
| 91f6b29147 | |||
| 111ef8ea33 | |||
| b4366176e1 | |||
| be821d46f6 | |||
| 8648908793 | |||
| afe4cb9370 | |||
| ce05341dae |
@@ -80,6 +80,7 @@ services:
|
||||
image: ${COTURN_IMAGE:-coturn/coturn:edge-alpine}
|
||||
container_name: glkvm_coturn
|
||||
restart: always
|
||||
user: "0:0" # run as root so it can read the bind-mounted entrypoint
|
||||
environment:
|
||||
# Same semantics as above: prefer explicit value, else auto-detect
|
||||
GLKVM_ACCESS_IP: ${GLKVM_ACCESS_IP:-}
|
||||
|
||||
@@ -3,5 +3,4 @@ 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}}
|
||||
|
||||
@@ -71,8 +71,8 @@ func RegisterAPIRoutes(r *gin.Engine, d Deps) {
|
||||
api := r.Group("/api")
|
||||
api.Use(middleware.Auth(d.SessionStore, d.UserSvc, d.PermSvc))
|
||||
|
||||
// device script info (requires login)
|
||||
api.GET("/script-info", func(c *gin.Context) {
|
||||
// device script info (admin only: it returns the device token and TURN credentials)
|
||||
api.GET("/script-info", middleware.Require(permission.DeviceWrite), func(c *gin.Context) {
|
||||
traceID := middleware.GetTraceID(c)
|
||||
|
||||
// Get domain info
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"rttys/internal/domain/device"
|
||||
"rttys/internal/domain/devicelog"
|
||||
"rttys/internal/domain/notification"
|
||||
"rttys/internal/domain/permission"
|
||||
"rttys/internal/domain/user"
|
||||
"rttys/internal/http/dto"
|
||||
"rttys/internal/http/middleware"
|
||||
"rttys/internal/store/memory"
|
||||
"rttys/internal/store/sqlite"
|
||||
"rttys/xconfig"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// script-info returns the device token and TURN credentials, so only roles with
|
||||
// device.write (admin) may read it.
|
||||
func TestScriptInfoRequiresDeviceWrite(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := sqlite.Open(ctx, sqlite.Options{DSN: filepath.Join(t.TempDir(), "si.db")})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := sqlite.InitSchema(ctx, db.SQL(), "../../docker-compose/database/schema.sql"); err != nil {
|
||||
t.Fatalf("init schema: %v", err)
|
||||
}
|
||||
g := db.Gorm()
|
||||
if err := g.Exec(`INSERT INTO users (id, username, password_hash, role, status) VALUES
|
||||
(1, 'admin', 'x', 'admin', 'active'), (2, 'alice', 'x', 'user', 'active')`).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
store := memory.NewSessionStore(time.Hour)
|
||||
store.Create("sid-admin", 1)
|
||||
store.Create("sid-alice", 2)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(middleware.Trace())
|
||||
groupRepo := sqlite.NewGroupRepo(g)
|
||||
RegisterAPIRoutes(r, Deps{
|
||||
UserSvc: user.NewService(sqlite.NewUserRepo(g)),
|
||||
PermSvc: permission.NewService(memory.NewPermissionRepo()),
|
||||
DevSvc: device.NewService(sqlite.NewDeviceRepo(g), groupRepo),
|
||||
GroupRepo: groupRepo,
|
||||
SessionStore: store,
|
||||
RelationsRepo: sqlite.NewRelationsRepo(g),
|
||||
TrustedDeviceRepo: sqlite.NewTrustedDeviceRepo(g),
|
||||
DeviceLogSvc: devicelog.NewService(sqlite.NewDeviceLogRepo(g)),
|
||||
NotificationSvc: notification.NewService(sqlite.NewNotificationRepo(g)),
|
||||
Cfg: &xconfig.Config{Token: "device-token"},
|
||||
})
|
||||
|
||||
get := func(sid string) (string, map[string]any) {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/script-info", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "sid", Value: sid})
|
||||
r.ServeHTTP(w, req)
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode %q: %v", w.Body.String(), err)
|
||||
}
|
||||
return body.Code, body.Data
|
||||
}
|
||||
|
||||
if code, data := get("sid-alice"); code != dto.CodeForbidden || data["token"] != nil {
|
||||
t.Errorf("regular user: code=%q token=%v, want %q and no token", code, data["token"], dto.CodeForbidden)
|
||||
}
|
||||
if code, data := get("sid-admin"); code != dto.CodeOK || data["token"] != "device-token" {
|
||||
t.Errorf("admin: code=%q token=%v, want %q and the device token", code, data["token"], dto.CodeOK)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package qa
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Device CONTROL plane authorization (/connect, /cmd, /web) and credential
|
||||
// exposure via /api/script-info.
|
||||
//
|
||||
// A logged-in session alone must NOT be enough to drive a device: the caller
|
||||
// also has to be allowed to see that specific device. The device LIST already
|
||||
// enforced this (covered by TestE2E_GroupsRBAC); these endpoints did not, so a
|
||||
// regular user could reach any device — including ungrouped ones — by id, and
|
||||
// any logged-in user could read the device token and TURN credentials. This
|
||||
// suite pins both boundaries so they cannot regress.
|
||||
//
|
||||
// Run (same env vars as the rest of the qa suite):
|
||||
//
|
||||
// QA_TARGET=https://<host> QA_USER=admin QA_PASS='<password>' \
|
||||
// QA_DEV_ADDR=<host>:5912 QA_DEV_TOKEN='<rtty token>' \
|
||||
// go test ./internal/qa -run TestE2E_DeviceControlAuthz -v
|
||||
|
||||
// ctrl issues a request to a device control endpoint and returns the status
|
||||
// code. These routes live outside /api and authenticate via the `sid` cookie
|
||||
// (not the Bearer header), and they answer with redirects on success, so
|
||||
// redirects are left unfollowed and the status code is the assertion.
|
||||
func (c *client) ctrl(t *testing.T, method, path string, body any) int {
|
||||
t.Helper()
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
rdr = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequest(method, c.cfg.target+path, rdr)
|
||||
if err != nil {
|
||||
t.Fatalf("ctrl %s %s: %v", method, path, err)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if c.token != "" {
|
||||
req.AddCookie(&http.Cookie{Name: "sid", Value: c.token})
|
||||
}
|
||||
hc := &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||
}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("ctrl %s %s: %v", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
// controlProbes returns the three control endpoints for devid. The /cmd probe
|
||||
// deliberately carries an empty body: authorization is checked before the body
|
||||
// is parsed, so an allowed caller gets "invalid format" back and no command is
|
||||
// ever dispatched to the device.
|
||||
func controlProbes(devid string) []struct{ name, method, path string } {
|
||||
return []struct{ name, method, path string }{
|
||||
{"connect", http.MethodGet, "/connect/" + devid},
|
||||
{"cmd", http.MethodPost, "/cmd/" + devid},
|
||||
{"web", http.MethodGet, "/web/" + devid + "/http/127.0.0.1:80/"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_DeviceControlAuthz(t *testing.T) {
|
||||
cfg := loadConfig(t)
|
||||
if cfg.pass == "" {
|
||||
t.Skip("QA_PASS required")
|
||||
}
|
||||
if cfg.devAddr == "" {
|
||||
t.Skip("QA_DEV_ADDR required to register fixture devices")
|
||||
}
|
||||
|
||||
admin := newClient(cfg)
|
||||
admin.login(t)
|
||||
|
||||
admin.cleanupQADevices(t)
|
||||
defer admin.cleanupQADevices(t)
|
||||
|
||||
dgID := admin.createDeviceGroup(t, "qae2e_authz_dg")
|
||||
ugID := admin.createUserGroup(t, "qae2e_authz_ug")
|
||||
admin.linkUGtoDG(t, ugID, []int64{dgID})
|
||||
uname, upass := "qae2e_authz_user", "Qae2ePass123!"
|
||||
admin.createNormalUser(t, uname, upass, []int64{ugID})
|
||||
defer func() {
|
||||
if uid := admin.findUserID(t, uname); uid > 0 {
|
||||
admin.del(t, fmt.Sprintf("/api/users/%d", uid))
|
||||
}
|
||||
admin.del(t, fmt.Sprintf("/api/user-groups/%d", ugID))
|
||||
admin.del(t, fmt.Sprintf("/api/device-groups/%d", dgID))
|
||||
}()
|
||||
|
||||
// Two throwaway devices: one the user is entitled to, one they are not.
|
||||
// The out-of-scope device is left ungrouped, which is the case the device
|
||||
// list never showed them and the control endpoints used to allow anyway.
|
||||
inConn, inID := admin.registerQAOnline(t, "qae2e_authz_in", "02ffqaeauz01")
|
||||
defer inConn.Close()
|
||||
outConn, _ := admin.registerQAOnline(t, "qae2e_authz_out", "02ffqaeauz02")
|
||||
defer outConn.Close()
|
||||
admin.assignDevicesToGroup(t, dgID, []int64{inID})
|
||||
|
||||
user := loginClient(t, cfg, uname, upass)
|
||||
if user == nil {
|
||||
t.Fatal("normal user login failed")
|
||||
}
|
||||
|
||||
// Sanity: the fixture really is in/out of scope on the management plane.
|
||||
vl := user.listDevices(t, "pageSize=200")
|
||||
if findItem(vl.Items, "qae2e_authz_in") == nil {
|
||||
t.Fatal("fixture broken: user cannot see their own group's device")
|
||||
}
|
||||
if findItem(vl.Items, "qae2e_authz_out") != nil {
|
||||
t.Fatal("fixture broken: user can see the out-of-scope device")
|
||||
}
|
||||
|
||||
t.Run("in_scope_device_allowed", func(t *testing.T) {
|
||||
for _, p := range controlProbes("qae2e_authz_in") {
|
||||
if got := user.ctrl(t, p.method, p.path, nil); got == http.StatusForbidden {
|
||||
t.Errorf("%s: user was denied their OWN group's device (403) — fix is too strict", p.name)
|
||||
} else {
|
||||
t.Logf("%s: http=%d (allowed)", p.name, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("out_of_scope_device_denied", func(t *testing.T) {
|
||||
for _, p := range controlProbes("qae2e_authz_out") {
|
||||
got := user.ctrl(t, p.method, p.path, nil)
|
||||
if got != http.StatusForbidden {
|
||||
t.Errorf("%s: out-of-scope device returned http=%d, want 403", p.name, got)
|
||||
} else {
|
||||
t.Logf("%s: http=403 (denied)", p.name)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown_device_denied", func(t *testing.T) {
|
||||
for _, p := range controlProbes("qae2e_authz_nosuchdev") {
|
||||
if got := user.ctrl(t, p.method, p.path, nil); got != http.StatusForbidden {
|
||||
t.Errorf("%s: unknown device returned http=%d, want 403", p.name, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("admin_reaches_both", func(t *testing.T) {
|
||||
for _, devid := range []string{"qae2e_authz_in", "qae2e_authz_out"} {
|
||||
for _, p := range controlProbes(devid) {
|
||||
if got := admin.ctrl(t, p.method, p.path, nil); got == http.StatusForbidden {
|
||||
t.Errorf("%s on %s: admin was denied (403) — fix broke admin access", p.name, devid)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no_session_denied", func(t *testing.T) {
|
||||
anon := newClient(cfg) // no token => no sid cookie
|
||||
for _, p := range controlProbes("qae2e_authz_in") {
|
||||
if got := anon.ctrl(t, p.method, p.path, nil); got != http.StatusUnauthorized {
|
||||
t.Errorf("%s: anonymous request returned http=%d, want 401", p.name, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// /api/script-info hands out the device registration token and the TURN
|
||||
// credentials, so it must be restricted to roles that may add devices.
|
||||
t.Run("script_info_admin_only", func(t *testing.T) {
|
||||
_, env, err := user.do("GET", "/api/script-info", nil, true)
|
||||
if err != nil {
|
||||
t.Fatalf("script-info as user: %v", err)
|
||||
}
|
||||
if env.OK {
|
||||
t.Errorf("script-info: regular user got code=%s, want a denial", env.Code)
|
||||
} else {
|
||||
t.Logf("script-info as user: code=%s (denied)", env.Code)
|
||||
}
|
||||
|
||||
_, env, err = admin.do("GET", "/api/script-info", nil, true)
|
||||
if err != nil {
|
||||
t.Fatalf("script-info as admin: %v", err)
|
||||
}
|
||||
if !env.OK {
|
||||
t.Errorf("script-info: admin got code=%s, want it to still work", env.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
+112
-36
@@ -34,6 +34,7 @@ import (
|
||||
"path"
|
||||
"rttys/internal/domain/device"
|
||||
"rttys/internal/domain/devicelog"
|
||||
"rttys/internal/domain/identity"
|
||||
"rttys/internal/domain/notification"
|
||||
"rttys/internal/domain/permission"
|
||||
"rttys/internal/domain/user"
|
||||
@@ -200,11 +201,13 @@ func (srv *RttyServer) ListenAPI() error {
|
||||
r.Use(cors.Default())
|
||||
}
|
||||
|
||||
authorized := r.Group("/", func(c *gin.Context) {
|
||||
if !cfg.LocalAuth && isLocalRequest(c) {
|
||||
return
|
||||
}
|
||||
// The device control plane below always requires a login session; local-auth
|
||||
// used to open it without one for loopback callers, and no longer does.
|
||||
if !cfg.LocalAuth {
|
||||
log.Warn().Msg("local-auth=false is ignored: local requests also require a login session")
|
||||
}
|
||||
|
||||
authorized := r.Group("/", func(c *gin.Context) {
|
||||
if !httpAuth(cfg, c) {
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
return
|
||||
@@ -212,6 +215,11 @@ func (srv *RttyServer) ListenAPI() error {
|
||||
})
|
||||
|
||||
authorized.GET("/connect/:devid", func(c *gin.Context) {
|
||||
if !canAccessDevice(c, c.Param("devid")) {
|
||||
forbidDeviceAccess(c)
|
||||
return
|
||||
}
|
||||
|
||||
if !callUserHookUrl(cfg, c) {
|
||||
c.Status(http.StatusForbidden)
|
||||
return
|
||||
@@ -238,6 +246,14 @@ func (srv *RttyServer) ListenAPI() error {
|
||||
})
|
||||
|
||||
authorized.POST("/cmd/:devid", func(c *gin.Context) {
|
||||
if !canAccessDevice(c, c.Param("devid")) {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"err": rttyCmdErrForbidden,
|
||||
"msg": cmdErrMsg[rttyCmdErrForbidden],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !callUserHookUrl(cfg, c) {
|
||||
c.Status(http.StatusForbidden)
|
||||
return
|
||||
@@ -261,6 +277,11 @@ func (srv *RttyServer) ListenAPI() error {
|
||||
})
|
||||
|
||||
authorized.Any("/web/:devid/:proto/:addr/*path", func(c *gin.Context) {
|
||||
if !canAccessDevice(c, c.Param("devid")) {
|
||||
forbidDeviceAccess(c)
|
||||
return
|
||||
}
|
||||
|
||||
httpProxyRedirect(srv, c, "")
|
||||
})
|
||||
|
||||
@@ -400,21 +421,10 @@ func callUserHookUrl(cfg *xconfig.Config, c *gin.Context) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func isLocalRequest(c *gin.Context) bool {
|
||||
addr, _ := net.ResolveTCPAddr("tcp", c.Request.RemoteAddr)
|
||||
return addr.IP.IsLoopback()
|
||||
}
|
||||
|
||||
func httpAuth(cfg *xconfig.Config, c *gin.Context) bool {
|
||||
if !cfg.LocalAuth && isLocalRequest(c) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Keep legacy behavior: if password is not set, no auth required
|
||||
if cfg.Password == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// httpAuth gates the device control plane (/connect, /cmd, /web): a valid login
|
||||
// session is always required. The legacy "empty password" and "local-auth off +
|
||||
// loopback" shortcuts were removed -- they skipped authentication entirely.
|
||||
func httpAuth(_ *xconfig.Config, c *gin.Context) bool {
|
||||
sid, err := c.Cookie("sid")
|
||||
if err != nil || strings.TrimSpace(sid) == "" {
|
||||
return false
|
||||
@@ -426,32 +436,98 @@ func httpAuth(cfg *xconfig.Config, c *gin.Context) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
// userIDFromCtx returns the logged-in user's id from the session cookie, or 0
|
||||
// when no session can be resolved. Session state is in memory, so no DB lookup.
|
||||
func userIDFromCtx(c *gin.Context) int64 {
|
||||
if c == nil || c.Request == nil || sessionStore == nil {
|
||||
return 0
|
||||
}
|
||||
sid, err := c.Cookie("sid")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
sid = strings.TrimSpace(sid)
|
||||
if sid == "" {
|
||||
return 0
|
||||
}
|
||||
sess, ok := sessionStore.Get(sid)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return sess.UserID
|
||||
}
|
||||
|
||||
// principalFromCtx best-effort extracts the logged-in user (id + username)
|
||||
// from the request, used for tagging device-event logs. Returns (0, "")
|
||||
// when no session can be resolved. Never blocks the calling path.
|
||||
func principalFromCtx(c *gin.Context) (int64, string) {
|
||||
if c == nil || c.Request == nil || sessionStore == nil {
|
||||
return 0, ""
|
||||
}
|
||||
sid, err := c.Cookie("sid")
|
||||
if err != nil {
|
||||
return 0, ""
|
||||
}
|
||||
sid = strings.TrimSpace(sid)
|
||||
if sid == "" {
|
||||
return 0, ""
|
||||
}
|
||||
sess, ok := sessionStore.Get(sid)
|
||||
if !ok {
|
||||
uid := userIDFromCtx(c)
|
||||
if uid == 0 {
|
||||
return 0, ""
|
||||
}
|
||||
cont := sqlite.TryContainer()
|
||||
if cont == nil || cont.UserSvc == nil {
|
||||
return sess.UserID, ""
|
||||
return uid, ""
|
||||
}
|
||||
u, err := cont.UserSvc.FindByID(c.Request.Context(), sess.UserID)
|
||||
u, err := cont.UserSvc.FindByID(c.Request.Context(), uid)
|
||||
if err != nil || u == nil {
|
||||
return sess.UserID, ""
|
||||
return uid, ""
|
||||
}
|
||||
return sess.UserID, u.Username
|
||||
return uid, u.Username
|
||||
}
|
||||
|
||||
// canAccessDevice reports whether the logged-in user may use the device control
|
||||
// endpoints (/connect, /cmd, /web) on devid. httpAuth only proves a session
|
||||
// exists; this applies the same visibility rule as the device list:
|
||||
// - admin -> every device
|
||||
// - regular user -> only devices in a device group linked to one of its user
|
||||
// groups (user_group_members -> user_group_device_group_links -> devices)
|
||||
//
|
||||
// Ungrouped devices therefore stay admin-only. Fails closed on any error.
|
||||
func canAccessDevice(c *gin.Context, devid string) bool {
|
||||
devid = strings.TrimSpace(devid)
|
||||
if devid == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
uid := userIDFromCtx(c)
|
||||
if uid == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
cont := sqlite.TryContainer()
|
||||
if cont == nil || cont.Gorm == nil || cont.UserSvc == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// GetByID rejects disabled accounts, same as the /api auth middleware.
|
||||
u, err := cont.UserSvc.GetByID(c.Request.Context(), uid)
|
||||
if err != nil || u == nil {
|
||||
return false
|
||||
}
|
||||
if u.Role == identity.RoleAdmin {
|
||||
return true
|
||||
}
|
||||
|
||||
var n int64
|
||||
err = cont.Gorm.WithContext(c.Request.Context()).Raw(
|
||||
`SELECT COUNT(1)
|
||||
FROM devices d
|
||||
JOIN user_group_device_group_links l ON l.device_group_id = d.device_group_id
|
||||
JOIN user_group_members m ON m.group_id = l.user_group_id
|
||||
WHERE d.ddns = ? AND m.user_id = ?`,
|
||||
devid, uid,
|
||||
).Scan(&n).Error
|
||||
return err == nil && n > 0
|
||||
}
|
||||
|
||||
// forbidDeviceAccess rejects a control request for a device the caller may not
|
||||
// access: browser navigations get the HTML error page, WebSocket upgrades a bare 403.
|
||||
func forbidDeviceAccess(c *gin.Context) {
|
||||
if c.GetHeader("Upgrade") == "websocket" {
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusForbidden, "text/html; charset=utf-8", []byte(generateErrorHTML("unauthorized")))
|
||||
c.Abort()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
@@ -41,6 +42,12 @@ func RunFromEnv() error {
|
||||
|
||||
xlog.SetPath(cfg.LogPath)
|
||||
|
||||
// Logging is set up first: the file hook has no path before SetPath, so an
|
||||
// earlier Fatal would be reported as a failed log write instead of this.
|
||||
if cfg.Password == "" {
|
||||
return fmt.Errorf("password is not set: set 'password' in the config file or RTTYS_PASS")
|
||||
}
|
||||
|
||||
switch cfg.LogLevel {
|
||||
case "debug":
|
||||
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"net/http"
|
||||
"rttys/utils"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -41,6 +42,11 @@ type CommandReq struct {
|
||||
cancel context.CancelFunc
|
||||
acked bool
|
||||
c *gin.Context
|
||||
// respondOnce guarantees the HTTP response body is written exactly once.
|
||||
// Both the device-response path (handleCmdMsg) and the timeout/offline paths
|
||||
// here race to respond; without this a timeout used to write the error twice
|
||||
// (timeout + offline), producing a malformed, concatenated JSON body.
|
||||
respondOnce sync.Once
|
||||
}
|
||||
|
||||
type CommandReqInfo struct {
|
||||
@@ -55,15 +61,17 @@ type CommandRespInfo struct {
|
||||
}
|
||||
|
||||
const (
|
||||
rttyCmdErrInvalid = 1001
|
||||
rttyCmdErrOffline = 1002
|
||||
rttyCmdErrTimeout = 1003
|
||||
rttyCmdErrInvalid = 1001
|
||||
rttyCmdErrOffline = 1002
|
||||
rttyCmdErrTimeout = 1003
|
||||
rttyCmdErrForbidden = 1004
|
||||
)
|
||||
|
||||
var cmdErrMsg = map[int]string{
|
||||
rttyCmdErrInvalid: "invalid format",
|
||||
rttyCmdErrOffline: "device offline",
|
||||
rttyCmdErrTimeout: "timeout",
|
||||
rttyCmdErrInvalid: "invalid format",
|
||||
rttyCmdErrOffline: "device offline",
|
||||
rttyCmdErrTimeout: "timeout",
|
||||
rttyCmdErrForbidden: "permission denied",
|
||||
}
|
||||
|
||||
func (dev *Device) handleCmdReq(c *gin.Context, info *CommandReqInfo) {
|
||||
@@ -122,15 +130,19 @@ func (dev *Device) handleCmdReq(c *gin.Context, info *CommandReqInfo) {
|
||||
|
||||
select {
|
||||
case <-tmr.C:
|
||||
cmdErrResp(c, rttyCmdErrTimeout)
|
||||
req.respondOnce.Do(func() {
|
||||
cmdErrResp(c, rttyCmdErrTimeout)
|
||||
})
|
||||
case <-ctx.Done():
|
||||
}
|
||||
|
||||
dev.commands.Delete(token)
|
||||
|
||||
if !req.acked {
|
||||
// Only writes when neither the device response nor the timeout has responded
|
||||
// yet (i.e. the device/connection went away before acking).
|
||||
req.respondOnce.Do(func() {
|
||||
cmdErrResp(c, rttyCmdErrOffline)
|
||||
}
|
||||
})
|
||||
|
||||
log.Debug().Msgf("handle cmd request for device '%s', token '%s' done", dev.id, token)
|
||||
}
|
||||
|
||||
@@ -761,10 +761,12 @@ func handleCmdMsg(dev *Device, data []byte) error {
|
||||
|
||||
attrs["devid"] = dev.id
|
||||
|
||||
if val, ok := dev.commands.Load(info.Token); ok {
|
||||
if val, ok := dev.commands.LoadAndDelete(info.Token); ok {
|
||||
req := val.(*CommandReq)
|
||||
req.acked = true
|
||||
req.c.JSON(http.StatusOK, attrs)
|
||||
req.respondOnce.Do(func() {
|
||||
req.acked = true
|
||||
req.c.JSON(http.StatusOK, attrs)
|
||||
})
|
||||
req.cancel()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"rttys/internal/domain/user"
|
||||
"rttys/internal/store/memory"
|
||||
"rttys/internal/store/sqlite"
|
||||
"rttys/xconfig"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// setupAccessFixture installs a temp DB as the global container plus a fresh
|
||||
// session store, with one session per user (sid "sid-<username>"):
|
||||
//
|
||||
// admin (admin) alice (user, ug1) bob (user, no group) carol (user, ug1, disabled)
|
||||
// ug1 -> dg1 dev-g1 in dg1 dev-g2 in dg2 (not linked) dev-none ungrouped
|
||||
func setupAccessFixture(t *testing.T) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
db, err := sqlite.Open(ctx, sqlite.Options{DSN: filepath.Join(t.TempDir(), "access.db")})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if err := sqlite.InitSchema(ctx, db.SQL(), "../../docker-compose/database/schema.sql"); err != nil {
|
||||
t.Fatalf("init schema: %v", err)
|
||||
}
|
||||
g := db.Gorm()
|
||||
|
||||
stmts := []string{
|
||||
`INSERT INTO users (id, username, password_hash, role, status) VALUES
|
||||
(1, 'admin', 'x', 'admin', 'active'),
|
||||
(2, 'alice', 'x', 'user', 'active'),
|
||||
(3, 'bob', 'x', 'user', 'active'),
|
||||
(4, 'carol', 'x', 'user', 'disabled')`,
|
||||
`INSERT INTO user_groups (id, name) VALUES (1, 'ug1')`,
|
||||
`INSERT INTO user_group_members (user_id, group_id) VALUES (2, 1), (4, 1)`,
|
||||
`INSERT INTO device_groups (id, name) VALUES (1, 'dg1'), (2, 'dg2')`,
|
||||
`INSERT INTO user_group_device_group_links (user_group_id, device_group_id) VALUES (1, 1)`,
|
||||
`INSERT INTO devices (ddns, mac, device_group_id) VALUES
|
||||
('dev-g1', 'aa0000000001', 1),
|
||||
('dev-g2', 'aa0000000002', 2),
|
||||
('dev-none', 'aa0000000003', NULL)`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if err := g.Exec(s).Error; err != nil {
|
||||
t.Fatalf("seed: %v\n%s", err, s)
|
||||
}
|
||||
}
|
||||
|
||||
prevStore, prevCont := sessionStore, sqlite.TryContainer()
|
||||
sessionStore = memory.NewSessionStore(time.Hour)
|
||||
for id, name := range map[int64]string{1: "admin", 2: "alice", 3: "bob", 4: "carol"} {
|
||||
sessionStore.Create("sid-"+name, id)
|
||||
}
|
||||
sqlite.SetContainer(&sqlite.Container{Gorm: g, UserSvc: user.NewService(sqlite.NewUserRepo(g))})
|
||||
|
||||
t.Cleanup(func() {
|
||||
sessionStore = prevStore
|
||||
sqlite.SetContainer(prevCont)
|
||||
db.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func ctxWithSid(sid string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
req := httptest.NewRequest(http.MethodGet, "/connect/x", nil)
|
||||
if sid != "" {
|
||||
req.AddCookie(&http.Cookie{Name: "sid", Value: sid})
|
||||
}
|
||||
c.Request = req
|
||||
return c, w
|
||||
}
|
||||
|
||||
func TestCanAccessDevice(t *testing.T) {
|
||||
setupAccessFixture(t)
|
||||
|
||||
cases := []struct {
|
||||
sid, devid string
|
||||
want bool
|
||||
}{
|
||||
{"sid-admin", "dev-g1", true},
|
||||
{"sid-admin", "dev-g2", true},
|
||||
{"sid-admin", "dev-none", true}, // admin also reaches ungrouped devices
|
||||
{"sid-alice", "dev-g1", true}, // in a device group linked to her user group
|
||||
{"sid-alice", "dev-g2", false}, // device group not linked to her
|
||||
{"sid-alice", "dev-none", false},
|
||||
{"sid-alice", "dev-unknown", false},
|
||||
{"sid-alice", " ", false},
|
||||
{"sid-bob", "dev-g1", false}, // no user group at all
|
||||
{"sid-carol", "dev-g1", false}, // disabled account
|
||||
{"sid-bogus", "dev-g1", false}, // unknown session
|
||||
{"", "dev-g1", false}, // no session
|
||||
}
|
||||
for _, tc := range cases {
|
||||
c, _ := ctxWithSid(tc.sid)
|
||||
if got := canAccessDevice(c, tc.devid); got != tc.want {
|
||||
t.Errorf("canAccessDevice(sid=%q, devid=%q) = %v, want %v", tc.sid, tc.devid, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHttpAuthAlwaysRequiresSession(t *testing.T) {
|
||||
setupAccessFixture(t)
|
||||
|
||||
// Settings that used to skip authentication must no longer do so.
|
||||
cfg := &xconfig.Config{Password: "", LocalAuth: false}
|
||||
|
||||
c, _ := ctxWithSid("")
|
||||
c.Request.RemoteAddr = "127.0.0.1:50000"
|
||||
if httpAuth(cfg, c) {
|
||||
t.Error("httpAuth allowed a loopback request without a session")
|
||||
}
|
||||
|
||||
c, _ = ctxWithSid("sid-bogus")
|
||||
if httpAuth(cfg, c) {
|
||||
t.Error("httpAuth allowed an unknown session")
|
||||
}
|
||||
|
||||
c, _ = ctxWithSid("sid-alice")
|
||||
if !httpAuth(cfg, c) {
|
||||
t.Error("httpAuth rejected a valid session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForbidDeviceAccess(t *testing.T) {
|
||||
c, w := ctxWithSid("")
|
||||
forbidDeviceAccess(c)
|
||||
if w.Code != http.StatusForbidden || !strings.Contains(w.Body.String(), "<html") {
|
||||
t.Errorf("page request: code=%d, want 403 with the HTML error page", w.Code)
|
||||
}
|
||||
|
||||
c, w = ctxWithSid("")
|
||||
c.Request.Header.Set("Upgrade", "websocket")
|
||||
forbidDeviceAccess(c)
|
||||
if w.Code != http.StatusForbidden || w.Body.Len() != 0 {
|
||||
t.Errorf("websocket request: code=%d body=%d bytes, want bare 403", w.Code, w.Body.Len())
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package server
|
||||
|
||||
const RttysVersion = "5.2.0"
|
||||
const KVMCloudVersion = "v2.7.0"
|
||||
const KVMCloudVersion = "v2.9.0"
|
||||
|
||||
var (
|
||||
GitCommit = ""
|
||||
|
||||
+10
-2
@@ -7,7 +7,8 @@
|
||||
* @Description: 设备相关API
|
||||
*/
|
||||
import { ExecuteCommandParams, type DeviceInfo } from '@/models/device'
|
||||
import request, { httpService } from './request'
|
||||
import request from './request'
|
||||
import axios from 'axios'
|
||||
|
||||
/** 获取设备列表(服务端分页:page/pageSize;q 为搜索词;unassigned 仅未分配) */
|
||||
export const getDeviceListApi = (params?: {
|
||||
@@ -34,8 +35,15 @@ export const getAddDeviceScriptInfoApi = () => {
|
||||
}
|
||||
|
||||
/** 执行命令 */
|
||||
// The /cmd endpoint is served directly by rttys and does NOT use the cloud
|
||||
// `{code:"OK",...}` envelope: success is {code:0,stdout,stderr,devid}, an rtty error is
|
||||
// {err:1xxx,msg}, and wait=0 returns an empty body. Routing it through httpService's
|
||||
// interceptor would (a) reject even a successful command (code 0 !== "OK") and (b) pop a
|
||||
// "Server Error Message" toast via showErrorMessage. Use a bare axios call to bypass the
|
||||
// interceptor entirely; it is same-origin, so the `sid` auth cookie is still sent, and the
|
||||
// caller reads the native body from res.data.
|
||||
export const reqExecuteCommand = (data: ExecuteCommandParams) => {
|
||||
return httpService.post(`/cmd/${data.id}?group=${data.group}&wait=${data.wait}`, data)
|
||||
return axios.post(`/cmd/${data.id}?group=${data.group}&wait=${data.wait}`, data)
|
||||
}
|
||||
|
||||
/** 修改描述 */
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
"commandResponseDetail": "Details zur Befehlsantwort",
|
||||
"standardOutput": "Standardausgabe",
|
||||
"standardErrorOutput": "Standardfehlerausgabe",
|
||||
"commandResponseError": "Ungültige Antwort vom Gerät erhalten",
|
||||
"commandUsageWarning": "Nur für einfache, schnell antwortende Befehle. Lang laufende, Echtzeit-/Streaming- oder interaktive Befehle führen innerhalb der Wartezeit (max. 30 Sek.) zu einer Zeitüberschreitung ohne Ergebnis; nutzen Sie dafür das Remote-Terminal.",
|
||||
"status": "Status",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
"commandResponseDetail": "Command Response Detail",
|
||||
"standardOutput": "Standard output",
|
||||
"standardErrorOutput": "Standard error output",
|
||||
"commandResponseError": "Invalid response returned by the device",
|
||||
"commandUsageWarning": "Only for simple, quick-returning commands. Long-running, real-time/streaming, or interactive commands will time out within the wait time (up to 30s) and return no result; use the remote terminal for those.",
|
||||
"status": "Status",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
"commandResponseDetail": "Detalle de respuesta del comando",
|
||||
"standardOutput": "Salida estándar",
|
||||
"standardErrorOutput": "Salida de error estándar",
|
||||
"commandResponseError": "Respuesta no válida devuelta por el dispositivo",
|
||||
"commandUsageWarning": "Solo para comandos simples de respuesta rápida. Los comandos de larga duración, en tiempo real/streaming o interactivos agotarán el tiempo de espera (máx. 30 s) sin devolver resultado; use el terminal remoto para ellos.",
|
||||
"status": "Estado",
|
||||
"online": "En línea",
|
||||
"offline": "Fuera de línea",
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
"commandResponseDetail": "Détail de la réponse de la commande",
|
||||
"standardOutput": "Sortie standard",
|
||||
"standardErrorOutput": "Sortie d'erreur standard",
|
||||
"commandResponseError": "Réponse non valide renvoyée par l'appareil",
|
||||
"commandUsageWarning": "Uniquement pour des commandes simples à réponse rapide. Les commandes longues, en temps réel/streaming ou interactives expireront dans le délai d'attente (max. 30 s) sans renvoyer de résultat ; utilisez le terminal distant pour cela.",
|
||||
"status": "Statut",
|
||||
"online": "En ligne",
|
||||
"offline": "Hors ligne",
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
"commandResponseDetail": "コマンド応答の詳細",
|
||||
"standardOutput": "標準出力",
|
||||
"standardErrorOutput": "標準エラー出力",
|
||||
"commandResponseError": "デバイスから無効な応答が返されました",
|
||||
"commandUsageWarning": "すぐに応答する簡単なコマンド専用です。長時間実行、リアルタイム/ストリーミング、または対話型のコマンドは待機時間(最大30秒)内にタイムアウトし、結果を取得できません。その場合はリモートターミナルをご利用ください。",
|
||||
"status": "ステータス",
|
||||
"online": "オンライン",
|
||||
"offline": "オフライン",
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
"commandResponseDetail": "명령 응답 상세",
|
||||
"standardOutput": "표준 출력",
|
||||
"standardErrorOutput": "표준 오류 출력",
|
||||
"commandResponseError": "기기에서 잘못된 응답이 반환되었습니다",
|
||||
"commandUsageWarning": "빠르게 응답하는 간단한 명령에만 사용하세요. 장시간 실행, 실시간/스트리밍 또는 대화형 명령은 대기 시간(최대 30초) 내에 시간 초과되어 결과를 가져올 수 없습니다. 이러한 작업에는 원격 터미널을 사용하세요.",
|
||||
"status": "상태",
|
||||
"online": "온라인",
|
||||
"offline": "오프라인",
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
"commandResponseDetail": "命令响应详情",
|
||||
"standardOutput": "标准输出",
|
||||
"standardErrorOutput": "标准错误输出",
|
||||
"commandResponseError": "设备返回数据异常",
|
||||
"commandUsageWarning": "仅适用于快速返回的简单命令。长时间运行、实时/流式输出或交互式命令会在等待时间(最长 30 秒)内超时且无法获取结果;此类操作请使用远程终端。",
|
||||
"status": "状态",
|
||||
"online": "在线",
|
||||
"offline": "离线",
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface DeviceQuery {
|
||||
|
||||
/** 执行命令参数 */
|
||||
export interface ExecuteCommandParams {
|
||||
id: number
|
||||
id: string
|
||||
group: string
|
||||
wait: number
|
||||
cmd: string
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
Popover,
|
||||
Popconfirm,
|
||||
DatePicker,
|
||||
Alert,
|
||||
} from 'ant-design-vue'
|
||||
|
||||
export default function (app: any) {
|
||||
@@ -52,4 +53,5 @@ export default function (app: any) {
|
||||
app.use(Popover)
|
||||
app.use(Popconfirm)
|
||||
app.use(DatePicker)
|
||||
app.use(Alert)
|
||||
}
|
||||
@@ -20,8 +20,22 @@
|
||||
<BaseText>{{ props.res.id }}</BaseText>
|
||||
</div>
|
||||
|
||||
<BaseText style="margin: 20px 0 6px;">{{ props.type === 'success' ? $t('device.standardOutput') : $t('device.standardErrorOutput') }}</BaseText>
|
||||
<ATextarea :value="props.type === 'success' ? props.res.stdout : props.res.stderr" readonly :autoSize="{minRows:1, maxRows: 20}"></ATextarea>
|
||||
<!-- 成功页:命令已执行,展示标准输出(以及有内容时的标准错误输出) -->
|
||||
<template v-if="props.type === 'success'">
|
||||
<BaseText style="margin: 20px 0 6px;">{{ $t('device.standardOutput') }}</BaseText>
|
||||
<ATextarea :value="props.res.stdout" readonly :autoSize="{minRows:1, maxRows: 20}"></ATextarea>
|
||||
|
||||
<template v-if="props.res.stderr">
|
||||
<BaseText style="margin: 20px 0 6px;">{{ $t('device.standardErrorOutput') }}</BaseText>
|
||||
<ATextarea :value="props.res.stderr" readonly :autoSize="{minRows:1, maxRows: 20}"></ATextarea>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- 失败页:平台层未执行成功(离线/超时/格式错),只展示错误信息 -->
|
||||
<template v-else>
|
||||
<BaseText style="margin: 20px 0 6px;">{{ $t('device.errorMessage') }}</BaseText>
|
||||
<ATextarea :value="props.res.msg" readonly :autoSize="{minRows:1, maxRows: 20}"></ATextarea>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* @Author: LPY
|
||||
* @Date: 2025-08-26 11:15:08
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2025-08-26 16:51:00
|
||||
* @FilePath: \glkvm-cloud\web-ui\src\views\device\components\commandResponseDialog.vue
|
||||
* @LastEditTime: 2026-08-20 11:32:44
|
||||
* @FilePath: \glkvm-cloud-open-code\ui\src\views\device\components\commandResponseDialog.vue
|
||||
* @Description:
|
||||
-->
|
||||
<template>
|
||||
@@ -149,11 +149,11 @@ const handleOpenDetail = (record: any) => {
|
||||
|
||||
const init = () => {
|
||||
// 调用接口进行下发配置
|
||||
const selections = props.selection.filter(item => item.proto > 4)
|
||||
const selections = props.selection
|
||||
state.total = selections.length
|
||||
selections.map(device => {
|
||||
const data = {
|
||||
id: device.id,
|
||||
id: device.ddns,
|
||||
group: '',
|
||||
wait: props.formData.wait,
|
||||
cmd: props.formData.cmd,
|
||||
@@ -166,13 +166,30 @@ const init = () => {
|
||||
state.responses.push({
|
||||
err: 0,
|
||||
msg: '',
|
||||
id: device.id,
|
||||
id: device.ddns,
|
||||
code: 0,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
})
|
||||
} else {
|
||||
const resp = res.info
|
||||
const resp = res.data
|
||||
|
||||
// Defensive: a well-formed /cmd response is always a JSON object. If the
|
||||
// body was malformed (e.g. a corrupted/concatenated payload that axios could
|
||||
// not parse), res.data is a raw string — treat it as a failure instead of
|
||||
// throwing "Cannot create property 'stdout' on string".
|
||||
if (!resp || typeof resp !== 'object') {
|
||||
state.fail++
|
||||
state.responses.push({
|
||||
err: -1,
|
||||
msg: t('device.commandResponseError'),
|
||||
id: device.ddns,
|
||||
code: 0,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (resp.err && resp.err !== 0) {
|
||||
state.fail++
|
||||
@@ -183,9 +200,21 @@ const init = () => {
|
||||
resp.stderr = window.atob(resp.stderr || '')
|
||||
}
|
||||
|
||||
resp.id = device.id
|
||||
resp.id = device.ddns
|
||||
state.responses.push(resp)
|
||||
}
|
||||
}).catch(error => {
|
||||
const response = error?.response?.data || error
|
||||
|
||||
state.fail++
|
||||
state.responses.push({
|
||||
err: response?.err || response?.code || -1,
|
||||
msg: response?.msg || response?.message || '',
|
||||
id: device.ddns,
|
||||
code: response?.code || 0,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -202,4 +231,4 @@ init()
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
:beforeOk="handleApply"
|
||||
@close="emits('update:open', false)"
|
||||
>
|
||||
<AAlert
|
||||
class="usage-tip"
|
||||
type="warning"
|
||||
show-icon
|
||||
:message="$t('device.commandUsageWarning')"
|
||||
/>
|
||||
<AForm
|
||||
class="dense-form"
|
||||
:colon="false"
|
||||
@@ -53,7 +59,7 @@
|
||||
name="wait"
|
||||
:addonAfter="$t('common.s')"
|
||||
:placeholder="$t('device.inputWaitTime')"
|
||||
:min="0"
|
||||
:min="1"
|
||||
:max="30"
|
||||
style="width: 200px;" />
|
||||
</div>
|
||||
@@ -83,7 +89,7 @@ const formRef = ref<FormInstance>()
|
||||
|
||||
const state = reactive<{formData: ExecuteCommandFormData}>({
|
||||
formData: {
|
||||
username: '',
|
||||
username: 'root',
|
||||
cmd: '',
|
||||
params: [],
|
||||
wait: 30,
|
||||
@@ -114,7 +120,7 @@ watch(() => props.open, (newVal) => {
|
||||
})
|
||||
|
||||
const init = () => {
|
||||
state.formData.username = ''
|
||||
state.formData.username = 'root'
|
||||
state.formData.cmd = ''
|
||||
state.formData.params = []
|
||||
state.formData.wait = 30
|
||||
@@ -126,5 +132,8 @@ const init = () => {
|
||||
.base-modal-content {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
.usage-tip {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because one or more lines are too long
+58
-93
@@ -236,16 +236,16 @@
|
||||
resolved "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz"
|
||||
integrity sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==
|
||||
|
||||
"@esbuild/android-arm@0.25.5":
|
||||
version "0.25.5"
|
||||
resolved "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.5.tgz"
|
||||
integrity sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==
|
||||
|
||||
"@esbuild/android-arm64@0.25.5":
|
||||
version "0.25.5"
|
||||
resolved "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz"
|
||||
integrity sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==
|
||||
|
||||
"@esbuild/android-arm@0.25.5":
|
||||
version "0.25.5"
|
||||
resolved "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.5.tgz"
|
||||
integrity sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==
|
||||
|
||||
"@esbuild/android-x64@0.25.5":
|
||||
version "0.25.5"
|
||||
resolved "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.5.tgz"
|
||||
@@ -271,16 +271,16 @@
|
||||
resolved "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz"
|
||||
integrity sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==
|
||||
|
||||
"@esbuild/linux-arm@0.25.5":
|
||||
version "0.25.5"
|
||||
resolved "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz"
|
||||
integrity sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==
|
||||
|
||||
"@esbuild/linux-arm64@0.25.5":
|
||||
version "0.25.5"
|
||||
resolved "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz"
|
||||
integrity sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==
|
||||
|
||||
"@esbuild/linux-arm@0.25.5":
|
||||
version "0.25.5"
|
||||
resolved "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz"
|
||||
integrity sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==
|
||||
|
||||
"@esbuild/linux-ia32@0.25.5":
|
||||
version "0.25.5"
|
||||
resolved "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz"
|
||||
@@ -469,7 +469,7 @@
|
||||
"@nodelib/fs.stat" "2.0.5"
|
||||
run-parallel "^1.1.9"
|
||||
|
||||
"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5":
|
||||
"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2":
|
||||
version "2.0.5"
|
||||
resolved "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
|
||||
integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
|
||||
@@ -711,7 +711,7 @@
|
||||
resolved "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz"
|
||||
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
|
||||
|
||||
"@types/node@*", "@types/node@^18.0.0 || ^20.0.0 || >=22.0.0", "@types/node@^22.15.24":
|
||||
"@types/node@*", "@types/node@^22.15.24":
|
||||
version "22.15.32"
|
||||
resolved "https://registry.npmmirror.com/@types/node/-/node-22.15.32.tgz"
|
||||
integrity sha512-3jigKqgSjsH6gYZv2nEsqdXfZqIFGAV36XYYjf9KGZ3PSG+IhLecqPnI310RvjutyMwifE2hhhNEklOUrvx/wA==
|
||||
@@ -747,7 +747,7 @@
|
||||
semver "^7.5.4"
|
||||
ts-api-utils "^1.0.1"
|
||||
|
||||
"@typescript-eslint/parser@^6.0.0 || ^6.0.0-alpha", "@typescript-eslint/parser@^6.7.0":
|
||||
"@typescript-eslint/parser@^6.7.0":
|
||||
version "6.21.0"
|
||||
resolved "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-6.21.0.tgz"
|
||||
integrity sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==
|
||||
@@ -821,7 +821,7 @@
|
||||
resolved "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz"
|
||||
integrity sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==
|
||||
|
||||
"@volar/language-core@~2.4.11", "@volar/language-core@2.4.14":
|
||||
"@volar/language-core@2.4.14", "@volar/language-core@~2.4.11":
|
||||
version "2.4.14"
|
||||
resolved "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.14.tgz"
|
||||
integrity sha512-X6beusV0DvuVseaOEy7GoagS4rYHgDHnTrdOj5jeUb49fW5ceQyP9Ej5rBhqgz2wJggl+2fDbbojq1XKaxDi6w==
|
||||
@@ -853,7 +853,7 @@
|
||||
estree-walker "^2.0.2"
|
||||
source-map-js "^1.2.1"
|
||||
|
||||
"@vue/compiler-dom@^3.5.0", "@vue/compiler-dom@3.5.16":
|
||||
"@vue/compiler-dom@3.5.16", "@vue/compiler-dom@^3.5.0":
|
||||
version "3.5.16"
|
||||
resolved "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.16.tgz"
|
||||
integrity sha512-SSJIhBr/teipXiXjmWOVWLnxjNGo65Oj/8wTEQz0nqwQeP75jWZ0n4sF24Zxoht1cuJoWopwj0J0exYwCJ0dCQ==
|
||||
@@ -892,12 +892,7 @@
|
||||
de-indent "^1.0.2"
|
||||
he "^1.2.0"
|
||||
|
||||
"@vue/devtools-api@^6.5.0", "@vue/devtools-api@^6.6.3":
|
||||
version "6.6.4"
|
||||
resolved "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz"
|
||||
integrity sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==
|
||||
|
||||
"@vue/devtools-api@^6.6.4":
|
||||
"@vue/devtools-api@^6.5.0", "@vue/devtools-api@^6.6.3", "@vue/devtools-api@^6.6.4":
|
||||
version "6.6.4"
|
||||
resolved "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz"
|
||||
integrity sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==
|
||||
@@ -985,7 +980,7 @@
|
||||
"@vue/compiler-ssr" "3.5.16"
|
||||
"@vue/shared" "3.5.16"
|
||||
|
||||
"@vue/shared@^3.5.0", "@vue/shared@3.5.16":
|
||||
"@vue/shared@3.5.16", "@vue/shared@^3.5.0":
|
||||
version "3.5.16"
|
||||
resolved "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.16.tgz"
|
||||
integrity sha512-c/0fWy3Jw6Z8L9FmTyYfkpM5zklnqqa9+a6dz3DvONRKW2NEbh46BP0FHuLFSWi2TnQEtp91Z6zOWNrU6QiyPg==
|
||||
@@ -1005,17 +1000,25 @@
|
||||
resolved "https://registry.npmmirror.com/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz"
|
||||
integrity sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==
|
||||
|
||||
"@xterm/xterm@^5.0.0", "@xterm/xterm@5.5.0":
|
||||
"@xterm/xterm@5.5.0":
|
||||
version "5.5.0"
|
||||
resolved "https://registry.npmmirror.com/@xterm/xterm/-/xterm-5.5.0.tgz"
|
||||
integrity sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==
|
||||
|
||||
JSONStream@^1.3.5:
|
||||
version "1.3.5"
|
||||
resolved "https://registry.npmmirror.com/JSONStream/-/JSONStream-1.3.5.tgz"
|
||||
integrity sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==
|
||||
dependencies:
|
||||
jsonparse "^1.2.0"
|
||||
through ">=2.2.7 <3"
|
||||
|
||||
acorn-jsx@^5.3.2:
|
||||
version "5.3.2"
|
||||
resolved "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz"
|
||||
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
|
||||
|
||||
"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.9.0:
|
||||
acorn@^8.9.0:
|
||||
version "8.15.0"
|
||||
resolved "https://registry.npmmirror.com/acorn/-/acorn-8.15.0.tgz"
|
||||
integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==
|
||||
@@ -1062,14 +1065,7 @@ ansi-regex@^6.0.1:
|
||||
resolved "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.1.0.tgz"
|
||||
integrity sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==
|
||||
|
||||
ansi-styles@^4.0.0:
|
||||
version "4.3.0"
|
||||
resolved "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz"
|
||||
integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
|
||||
dependencies:
|
||||
color-convert "^2.0.1"
|
||||
|
||||
ansi-styles@^4.1.0:
|
||||
ansi-styles@^4.0.0, ansi-styles@^4.1.0:
|
||||
version "4.3.0"
|
||||
resolved "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz"
|
||||
integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
|
||||
@@ -1368,8 +1364,8 @@ conventional-commits-parser@^5.0.0:
|
||||
resolved "https://registry.npmmirror.com/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz"
|
||||
integrity sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==
|
||||
dependencies:
|
||||
is-text-path "^2.0.0"
|
||||
JSONStream "^1.3.5"
|
||||
is-text-path "^2.0.0"
|
||||
meow "^12.0.1"
|
||||
split2 "^4.0.0"
|
||||
|
||||
@@ -1392,7 +1388,7 @@ cosmiconfig-typescript-loader@^6.1.0:
|
||||
dependencies:
|
||||
jiti "^2.4.1"
|
||||
|
||||
cosmiconfig@^9.0.0, cosmiconfig@>=9:
|
||||
cosmiconfig@^9.0.0:
|
||||
version "9.0.0"
|
||||
resolved "https://registry.npmmirror.com/cosmiconfig/-/cosmiconfig-9.0.0.tgz"
|
||||
integrity sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==
|
||||
@@ -1630,7 +1626,7 @@ escape-string-regexp@^4.0.0:
|
||||
resolved "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz"
|
||||
integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
|
||||
|
||||
eslint-plugin-vue@^9.0.0, eslint-plugin-vue@9.17.0:
|
||||
eslint-plugin-vue@9.17.0:
|
||||
version "9.17.0"
|
||||
resolved "https://registry.npmmirror.com/eslint-plugin-vue/-/eslint-plugin-vue-9.17.0.tgz"
|
||||
integrity sha512-r7Bp79pxQk9I5XDP0k2dpUC7Ots3OSWgvGZNu3BxmKK6Zg7NgVtcOB6OCna5Kb9oQwJPl5hq183WD0SY5tZtIQ==
|
||||
@@ -1656,7 +1652,7 @@ eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4
|
||||
resolved "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz"
|
||||
integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
|
||||
|
||||
"eslint@^6.0.0 || ^7.0.0 || >=8.0.0", "eslint@^6.2.0 || ^7.0.0 || ^8.0.0", "eslint@^7.0.0 || ^8.0.0", eslint@>=6.0.0, eslint@8.49.0:
|
||||
eslint@8.49.0:
|
||||
version "8.49.0"
|
||||
resolved "https://registry.npmmirror.com/eslint/-/eslint-8.49.0.tgz"
|
||||
integrity sha512-jw03ENfm6VJI0jA9U+8H5zfl5b+FvuU3YYvZRdZHOlU2ggJkxrlkJH4HcDrZpj6YwD8kuYqvQM8LyesoazrSOQ==
|
||||
@@ -2194,7 +2190,7 @@ isexe@^2.0.0:
|
||||
resolved "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz"
|
||||
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
|
||||
|
||||
jiti@^2.4.1, jiti@>=1.21.0:
|
||||
jiti@^2.4.1:
|
||||
version "2.4.2"
|
||||
resolved "https://registry.npmmirror.com/jiti/-/jiti-2.4.2.tgz"
|
||||
integrity sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==
|
||||
@@ -2251,14 +2247,6 @@ jsonparse@^1.2.0:
|
||||
resolved "https://registry.npmmirror.com/jsonparse/-/jsonparse-1.3.1.tgz"
|
||||
integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==
|
||||
|
||||
JSONStream@^1.3.5:
|
||||
version "1.3.5"
|
||||
resolved "https://registry.npmmirror.com/JSONStream/-/JSONStream-1.3.5.tgz"
|
||||
integrity sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==
|
||||
dependencies:
|
||||
jsonparse "^1.2.0"
|
||||
through ">=2.2.7 <3"
|
||||
|
||||
keyv@^4.5.3:
|
||||
version "4.5.4"
|
||||
resolved "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz"
|
||||
@@ -2463,34 +2451,20 @@ mimic-function@^5.0.0:
|
||||
resolved "https://registry.npmmirror.com/mimic-function/-/mimic-function-5.0.1.tgz"
|
||||
integrity sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==
|
||||
|
||||
minimatch@^3.0.5:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz"
|
||||
integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
|
||||
dependencies:
|
||||
brace-expansion "^1.1.7"
|
||||
|
||||
minimatch@^3.1.1:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz"
|
||||
integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
|
||||
dependencies:
|
||||
brace-expansion "^1.1.7"
|
||||
|
||||
minimatch@^3.1.2:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz"
|
||||
integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
|
||||
dependencies:
|
||||
brace-expansion "^1.1.7"
|
||||
|
||||
minimatch@^9.0.3, minimatch@9.0.3:
|
||||
minimatch@9.0.3, minimatch@^9.0.3:
|
||||
version "9.0.3"
|
||||
resolved "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.3.tgz"
|
||||
integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==
|
||||
dependencies:
|
||||
brace-expansion "^2.0.1"
|
||||
|
||||
minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz"
|
||||
integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
|
||||
dependencies:
|
||||
brace-expansion "^1.1.7"
|
||||
|
||||
minimist@^1.2.8:
|
||||
version "1.2.8"
|
||||
resolved "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz"
|
||||
@@ -2692,7 +2666,7 @@ picomatch@^2.3.1:
|
||||
resolved "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz"
|
||||
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
|
||||
|
||||
"picomatch@^3 || ^4", picomatch@^4.0.2:
|
||||
picomatch@^4.0.2:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.2.tgz"
|
||||
integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==
|
||||
@@ -2864,7 +2838,7 @@ run-parallel@^1.1.9:
|
||||
dependencies:
|
||||
queue-microtask "^1.2.2"
|
||||
|
||||
sass@*, sass@^1.69.7, sass@^1.89.0:
|
||||
sass@^1.69.7, sass@^1.89.0:
|
||||
version "1.89.2"
|
||||
resolved "https://registry.npmmirror.com/sass/-/sass-1.89.2.tgz"
|
||||
integrity sha512-xCmtksBKd/jdJ9Bt9p7nPKiuqrlBMBuuGkQlkhZjjQk3Ty48lv93k5Dq6OPkKt4XwxDJ7tvlfrTa1MPA9bf+QA==
|
||||
@@ -2950,7 +2924,7 @@ sortablejs@^1.15.2, sortablejs@^1.15.7:
|
||||
resolved "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.15.7.tgz"
|
||||
integrity sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==
|
||||
|
||||
source-map-js@^1.0.2, source-map-js@^1.2.1, "source-map-js@>=0.6.2 <2.0.0":
|
||||
"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2, source-map-js@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz"
|
||||
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
|
||||
@@ -2970,16 +2944,7 @@ string-argv@~0.3.2:
|
||||
resolved "https://registry.npmmirror.com/string-argv/-/string-argv-0.3.2.tgz"
|
||||
integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==
|
||||
|
||||
string-width@^4.1.0, string-width@^4.2.0:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
dependencies:
|
||||
emoji-regex "^8.0.0"
|
||||
is-fullwidth-code-point "^3.0.0"
|
||||
strip-ansi "^6.0.1"
|
||||
|
||||
string-width@^4.2.3:
|
||||
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
@@ -3107,7 +3072,7 @@ type-fest@^0.20.2:
|
||||
resolved "https://registry.npmmirror.com/type-fest/-/type-fest-0.20.2.tgz"
|
||||
integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
|
||||
|
||||
typescript@*, typescript@>=4.2.0, typescript@>=4.4.4, typescript@>=4.9.5, typescript@>=5, typescript@>=5.0.0, typescript@5.3, typescript@5.x:
|
||||
typescript@5.3:
|
||||
version "5.3.3"
|
||||
resolved "https://registry.npmmirror.com/typescript/-/typescript-5.3.3.tgz"
|
||||
integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==
|
||||
@@ -3139,7 +3104,7 @@ vite-plugin-remove-console@^2.2.0:
|
||||
resolved "https://registry.npmmirror.com/vite-plugin-remove-console/-/vite-plugin-remove-console-2.2.0.tgz"
|
||||
integrity sha512-qgjh5pz75MdE9Kzs8J0kBwaCfifHV0ezRbB9rpGsIOxam+ilcGV7WOk91vFJXquzRmiKrFh3Hxlh0JJWAmXTbQ==
|
||||
|
||||
"vite@^5.0.0 || ^6.0.0", vite@^6.3.5:
|
||||
vite@^6.3.5:
|
||||
version "6.3.5"
|
||||
resolved "https://registry.npmmirror.com/vite/-/vite-6.3.5.tgz"
|
||||
integrity sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==
|
||||
@@ -3183,15 +3148,6 @@ vue-eslint-parser@^9.3.1:
|
||||
lodash "^4.17.21"
|
||||
semver "^7.3.6"
|
||||
|
||||
vue-i18n@^11.1.5:
|
||||
version "11.1.6"
|
||||
resolved "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-11.1.6.tgz"
|
||||
integrity sha512-+IbsW/sTZHj7U1w0rPOYJbuSB0/7DeO1nvUo3BxvO20OQgHs+ukJ3QeLqvoUA6DiLk+8SA9+djRmKC9+FC6cAg==
|
||||
dependencies:
|
||||
"@intlify/core-base" "11.1.6"
|
||||
"@intlify/shared" "11.1.6"
|
||||
"@vue/devtools-api" "^6.5.0"
|
||||
|
||||
vue-i18n@9:
|
||||
version "9.14.5"
|
||||
resolved "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-9.14.5.tgz"
|
||||
@@ -3201,6 +3157,15 @@ vue-i18n@9:
|
||||
"@intlify/shared" "9.14.5"
|
||||
"@vue/devtools-api" "^6.5.0"
|
||||
|
||||
vue-i18n@^11.1.5:
|
||||
version "11.1.6"
|
||||
resolved "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-11.1.6.tgz"
|
||||
integrity sha512-+IbsW/sTZHj7U1w0rPOYJbuSB0/7DeO1nvUo3BxvO20OQgHs+ukJ3QeLqvoUA6DiLk+8SA9+djRmKC9+FC6cAg==
|
||||
dependencies:
|
||||
"@intlify/core-base" "11.1.6"
|
||||
"@intlify/shared" "11.1.6"
|
||||
"@vue/devtools-api" "^6.5.0"
|
||||
|
||||
vue-router@^4.2.5, vue-router@^4.5.1:
|
||||
version "4.5.1"
|
||||
resolved "https://registry.npmmirror.com/vue-router/-/vue-router-4.5.1.tgz"
|
||||
@@ -3223,7 +3188,7 @@ vue-types@^3.0.0:
|
||||
dependencies:
|
||||
is-plain-object "3.0.1"
|
||||
|
||||
"vue@^2.7.0 || ^3.5.11", vue@^3.0.0, "vue@^3.0.0-0 || ^2.6.0", vue@^3.2.0, vue@^3.2.25, vue@^3.3.11, vue@^3.4.0, vue@^3.5.13, vue@>=3.0.3, vue@>=3.2.0, vue@3.5.16:
|
||||
vue@^3.3.11, vue@^3.5.13:
|
||||
version "3.5.16"
|
||||
resolved "https://registry.npmmirror.com/vue/-/vue-3.5.16.tgz"
|
||||
integrity sha512-rjOV2ecxMd5SiAmof2xzh2WxntRcigkX/He4YFJ6WdRvVUrbt6DxC1Iujh10XLl8xCDRDtGKMeO3D+pRQ1PP9w==
|
||||
@@ -3313,7 +3278,7 @@ y18n@^5.0.5:
|
||||
resolved "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz"
|
||||
integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==
|
||||
|
||||
yaml@^2.4.2, yaml@~2.5.0:
|
||||
yaml@~2.5.0:
|
||||
version "2.5.1"
|
||||
resolved "https://registry.npmmirror.com/yaml/-/yaml-2.5.1.tgz"
|
||||
integrity sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==
|
||||
|
||||
Reference in New Issue
Block a user