Improve Go Server to support remote desktop and command control #1
1
.gitignore
vendored
1
.gitignore
vendored
@@ -91,3 +91,4 @@ YAMA.code-workspace
|
|||||||
Bin/*
|
Bin/*
|
||||||
nul
|
nul
|
||||||
server/go/web/assets/index.html
|
server/go/web/assets/index.html
|
||||||
|
server/go/users.json
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ VSCode F5 调试时由 `sync-web-assets` preLaunchTask 自动同步。
|
|||||||
| `YAMA_PWD` | 超级密码,用于 HMAC 签名验证;也作为 Web admin 密码的默认来源 | `your_super_password` |
|
| `YAMA_PWD` | 超级密码,用于 HMAC 签名验证;也作为 Web admin 密码的默认来源 | `your_super_password` |
|
||||||
| `YAMA_WEB_ADMIN_PASS` | Web UI 的 admin 密码(明文);优先于 `YAMA_PWD`。两者都未设置时 Web 登录禁用 | `your_admin_password` |
|
| `YAMA_WEB_ADMIN_PASS` | Web UI 的 admin 密码(明文);优先于 `YAMA_PWD`。两者都未设置时 Web 登录禁用 | `your_admin_password` |
|
||||||
| `YAMA_SIGN_PASSWORD` | HMAC-SHA256 key used to sign CMD_MASTERSETTING replies; must match the client's expected value. Provision out-of-band. Unset → client refuses screen/file ops. | `<deployment-shared-secret>` |
|
| `YAMA_SIGN_PASSWORD` | HMAC-SHA256 key used to sign CMD_MASTERSETTING replies; must match the client's expected value. Provision out-of-band. Unset → client refuses screen/file ops. | `<deployment-shared-secret>` |
|
||||||
|
| `YAMA_USERS_FILE` | Path to the JSON file that persists non-admin web users (allowed_groups, password hash, salt). Default is `users.json` in the working directory. | `users.json` |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Linux/macOS
|
# Linux/macOS
|
||||||
|
|||||||
@@ -286,6 +286,13 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
|
|||||||
if len(reserved) > protocol.ResFieldClientLoc {
|
if len(reserved) > protocol.ResFieldClientLoc {
|
||||||
location = info.GetReservedField(protocol.ResFieldClientLoc)
|
location = info.GetReservedField(protocol.ResFieldClientLoc)
|
||||||
}
|
}
|
||||||
|
// RES_RESOLUTION is already formatted by the client as "N:W*H"
|
||||||
|
// (see client/LoginServer.cpp:414). Pass through unchanged so the web
|
||||||
|
// UI's device card renders it next to the version label.
|
||||||
|
resolution := ""
|
||||||
|
if len(reserved) > protocol.ResFieldResolution {
|
||||||
|
resolution = info.GetReservedField(protocol.ResFieldResolution)
|
||||||
|
}
|
||||||
|
|
||||||
// Register with hub so the web side can list this device. Sub-connections
|
// Register with hub so the web side can list this device. Sub-connections
|
||||||
// (screen / terminal etc.) reuse the MasterID and will overwrite this entry
|
// (screen / terminal etc.) reuse the MasterID and will overwrite this entry
|
||||||
@@ -301,6 +308,7 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
|
|||||||
FilePath: clientInfo.FilePath,
|
FilePath: clientInfo.FilePath,
|
||||||
InstallTime: info.StartTime,
|
InstallTime: info.StartTime,
|
||||||
Location: location,
|
Location: location,
|
||||||
|
Resolution: resolution,
|
||||||
PeerIP: ctx.GetPeerIP(),
|
PeerIP: ctx.GetPeerIP(),
|
||||||
PublicIP: clientInfo.IP,
|
PublicIP: clientInfo.IP,
|
||||||
ConnectedAt: time.Now(),
|
ConnectedAt: time.Now(),
|
||||||
@@ -399,9 +407,18 @@ func (h *MyHandler) handleHeartbeat(ctx *connection.Context, data []byte) {
|
|||||||
if info := ctx.GetInfo(); info.ClientID != "" {
|
if info := ctx.GetInfo(); info.ClientID != "" {
|
||||||
var rtt int32
|
var rtt int32
|
||||||
var activeWindow string
|
var activeWindow string
|
||||||
// ActiveWnd at data[9..521] is a 512-byte GBK-encoded string.
|
// ActiveWnd at data[9..521] is a 512-byte NUL-padded string. Encoding
|
||||||
|
// depends on the client: new clients advertise CLIENT_CAP_UTF8 (bit
|
||||||
|
// 0x0002 in the moduleVersion hex tail) and ship UTF-8 directly;
|
||||||
|
// legacy Windows clients still use CP936 (GBK). Decoding UTF-8 bytes
|
||||||
|
// as GBK turns Chinese characters into mojibake — see the matching
|
||||||
|
// C++ guard at server/2015Remote/WebService.cpp:1530.
|
||||||
if len(data) >= 9+512 {
|
if len(data) >= 9+512 {
|
||||||
activeWindow = protocol.GbkToUTF8(data[9 : 9+512])
|
activeWindow = protocol.DecodeClientString(
|
||||||
|
data[9:9+512],
|
||||||
|
h.hub.Capability(info.ClientID),
|
||||||
|
info.ClientType,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
// Ping at data[521..525] is a little-endian int32.
|
// Ping at data[521..525] is a little-endian int32.
|
||||||
if len(data) >= 525 {
|
if len(data) >= 525 {
|
||||||
@@ -551,6 +568,17 @@ func main() {
|
|||||||
log.Warn("Neither YAMA_WEB_ADMIN_PASS nor YAMA_PWD is set; web login will be unavailable")
|
log.Warn("Neither YAMA_WEB_ADMIN_PASS nor YAMA_PWD is set; web login will be unavailable")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persistent users live in users.json next to the binary's working dir
|
||||||
|
// — same default the C++ WebService uses (m_ConfigDir + "users.json").
|
||||||
|
// Loading is best-effort: a missing file means "no extra users yet".
|
||||||
|
usersFile := os.Getenv("YAMA_USERS_FILE")
|
||||||
|
if usersFile == "" {
|
||||||
|
usersFile = "users.json"
|
||||||
|
}
|
||||||
|
if err := webAuth.SetUsersFile(usersFile); err != nil {
|
||||||
|
log.Warn("Failed to load %s: %v (continuing with admin only)", usersFile, err)
|
||||||
|
}
|
||||||
|
|
||||||
// Create servers for each port
|
// Create servers for each port
|
||||||
var servers []*server.Server
|
var servers []*server.Server
|
||||||
for _, port := range ports {
|
for _, port := range ports {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ type Device struct {
|
|||||||
Location string // client-reported geo string (reserved field 10)
|
Location string // client-reported geo string (reserved field 10)
|
||||||
PeerIP string // network-level remote address as seen by the server
|
PeerIP string // network-level remote address as seen by the server
|
||||||
PublicIP string // client-reported public IP (reserved field 11)
|
PublicIP string // client-reported public IP (reserved field 11)
|
||||||
|
Resolution string // client-formatted screen geometry "N:W*H" (reserved field 15)
|
||||||
ConnectedAt time.Time
|
ConnectedAt time.Time
|
||||||
|
|
||||||
// Live fields refreshed on every heartbeat. Protected by hub.mu.
|
// Live fields refreshed on every heartbeat. Protected by hub.mu.
|
||||||
@@ -115,6 +116,18 @@ func (h *Hub) MainConn(id string) *connection.Context {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capability returns the device's reported capability hex string
|
||||||
|
// (LOGIN_INFOR.moduleVersion tail). Empty for unknown devices — callers
|
||||||
|
// should treat that as "no caps" (legacy Windows GBK default).
|
||||||
|
func (h *Hub) Capability(id string) string {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
if d, ok := h.devices[id]; ok {
|
||||||
|
return d.Capability
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// DeviceInfo is the JSON-safe projection of Device for the /api/devices
|
// DeviceInfo is the JSON-safe projection of Device for the /api/devices
|
||||||
// endpoint and the WS device_list message. Field names match what the
|
// endpoint and the WS device_list message. Field names match what the
|
||||||
// existing browser front-end expects.
|
// existing browser front-end expects.
|
||||||
@@ -131,6 +144,7 @@ type DeviceInfo struct {
|
|||||||
Location string `json:"location,omitempty"`
|
Location string `json:"location,omitempty"`
|
||||||
IP string `json:"ip"` // client-reported public IP (matches C++ key)
|
IP string `json:"ip"` // client-reported public IP (matches C++ key)
|
||||||
PeerIP string `json:"peer_ip,omitempty"`
|
PeerIP string `json:"peer_ip,omitempty"`
|
||||||
|
Screen string `json:"screen,omitempty"` // "N:W*H" — matches C++ DeviceInfo.screen key
|
||||||
RTT int `json:"rtt"`
|
RTT int `json:"rtt"`
|
||||||
ActiveWindow string `json:"activeWindow,omitempty"`
|
ActiveWindow string `json:"activeWindow,omitempty"`
|
||||||
ConnectedAt int64 `json:"connected_at"`
|
ConnectedAt int64 `json:"connected_at"`
|
||||||
@@ -205,6 +219,30 @@ func (h *Hub) SendToDevice(id string, data []byte) error {
|
|||||||
return h.sender(d.conn, data)
|
return h.sender(d.conn, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendToScreen routes a payload to the device's currently-bound screen
|
||||||
|
// sub-connection. Input events (COMMAND_SCREEN_CONTROL) MUST go through the
|
||||||
|
// screen sub-conn rather than the main conn — the C++ client only dispatches
|
||||||
|
// these commands from CScreenManager::OnReceive, which reads exclusively from
|
||||||
|
// the sub-conn (see client/ScreenManager.cpp:1065). Returns ErrDeviceOffline
|
||||||
|
// when the device is unknown OR has no active screen session, so callers can
|
||||||
|
// quietly drop input from browsers that haven't called connect yet.
|
||||||
|
func (h *Hub) SendToScreen(id string, data []byte) error {
|
||||||
|
h.mu.RLock()
|
||||||
|
d, ok := h.devices[id]
|
||||||
|
var sc *connection.Context
|
||||||
|
if ok {
|
||||||
|
sc = d.screenConn
|
||||||
|
}
|
||||||
|
h.mu.RUnlock()
|
||||||
|
if !ok || sc == nil {
|
||||||
|
return ErrDeviceOffline
|
||||||
|
}
|
||||||
|
if h.sender == nil {
|
||||||
|
return ErrNoSender
|
||||||
|
}
|
||||||
|
return h.sender(sc, data)
|
||||||
|
}
|
||||||
|
|
||||||
// BindScreenConn associates a freshly-arrived sub-connection (the one that
|
// BindScreenConn associates a freshly-arrived sub-connection (the one that
|
||||||
// just sent TOKEN_BITMAPINFO) with the device identified by clientID.
|
// just sent TOKEN_BITMAPINFO) with the device identified by clientID.
|
||||||
// Returns false if the device is not registered — callers should drop the
|
// Returns false if the device is not registered — callers should drop the
|
||||||
@@ -350,6 +388,7 @@ func deviceToInfo(d *Device) DeviceInfo {
|
|||||||
Location: d.Location,
|
Location: d.Location,
|
||||||
IP: d.PublicIP,
|
IP: d.PublicIP,
|
||||||
PeerIP: d.PeerIP,
|
PeerIP: d.PeerIP,
|
||||||
|
Screen: d.Resolution,
|
||||||
RTT: d.RTT,
|
RTT: d.RTT,
|
||||||
ActiveWindow: d.ActiveWindow,
|
ActiveWindow: d.ActiveWindow,
|
||||||
ConnectedAt: d.ConnectedAt.Unix(),
|
ConnectedAt: d.ConnectedAt.Unix(),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"golang.org/x/text/encoding/simplifiedchinese"
|
"golang.org/x/text/encoding/simplifiedchinese"
|
||||||
@@ -36,6 +37,21 @@ func GbkToUTF8(data []byte) string {
|
|||||||
return cleanString(buf.String())
|
return cleanString(buf.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Utf8CleanString trims at the first NUL and strips non-printables — the
|
||||||
|
// UTF-8 counterpart of GbkToUTF8 for clients that have the CLIENT_CAP_UTF8
|
||||||
|
// capability bit. Decoding as GBK in that case would mangle multi-byte
|
||||||
|
// sequences (the C++ comment at WebService.cpp:1530 calls out this exact
|
||||||
|
// "double-encoding" footgun).
|
||||||
|
func Utf8CleanString(data []byte) string {
|
||||||
|
if idx := bytes.IndexByte(data, 0); idx >= 0 {
|
||||||
|
data = data[:idx]
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return cleanString(string(data))
|
||||||
|
}
|
||||||
|
|
||||||
// cleanString removes non-printable characters except common whitespace
|
// cleanString removes non-printable characters except common whitespace
|
||||||
func cleanString(s string) string {
|
func cleanString(s string) string {
|
||||||
var result strings.Builder
|
var result strings.Builder
|
||||||
@@ -47,11 +63,49 @@ func cleanString(s string) string {
|
|||||||
return strings.TrimSpace(result.String())
|
return strings.TrimSpace(result.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Client capability bitmask values, matching common/commands.h CLIENT_CAP_*.
|
||||||
|
// Reported in the hex tail of LOGIN_INFOR.moduleVersion (after the '-').
|
||||||
|
const (
|
||||||
|
ClientCapV2 uint32 = 0x0001 // CLIENT_CAP_V2 — V2 file transfer
|
||||||
|
ClientCapUTF8 uint32 = 0x0002 // CLIENT_CAP_UTF8 — UTF-8 protocol strings (activeWindow, key-log titles, ...)
|
||||||
|
ClientCapScreenPreview uint32 = 0x0004 // CLIENT_CAP_SCREEN_PREVIEW
|
||||||
|
)
|
||||||
|
|
||||||
|
// SupportsCap returns true when the client's reported capability hex string
|
||||||
|
// has the given bit set. An empty / unparseable string means "no caps" and
|
||||||
|
// matches the legacy GBK-Windows convention.
|
||||||
|
func SupportsCap(capability string, bit uint32) bool {
|
||||||
|
if capability == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
caps, err := strconv.ParseUint(strings.TrimSpace(capability), 16, 32)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return uint32(caps)&bit != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeClientString decodes a fixed-length, NUL-padded buffer the client
|
||||||
|
// sent as part of a binary protocol field (typically ActiveWnd). If the
|
||||||
|
// client signals UTF-8 capability or is known to ship UTF-8 by default
|
||||||
|
// (Linux / macOS), the bytes are treated as UTF-8; otherwise they're
|
||||||
|
// decoded from GBK (CP936 — the legacy Windows default).
|
||||||
|
//
|
||||||
|
// clientType comes from LOGIN_INFOR reserved field 0 (RES_CLIENT_TYPE) and
|
||||||
|
// capability from the hex tail of moduleVersion. Both can be empty.
|
||||||
|
func DecodeClientString(data []byte, capability, clientType string) string {
|
||||||
|
if SupportsCap(capability, ClientCapUTF8) || clientType == "LNX" || clientType == "MAC" {
|
||||||
|
return Utf8CleanString(data)
|
||||||
|
}
|
||||||
|
return GbkToUTF8(data)
|
||||||
|
}
|
||||||
|
|
||||||
// Command tokens - matching the C++ definitions (common/commands.h).
|
// Command tokens - matching the C++ definitions (common/commands.h).
|
||||||
const (
|
const (
|
||||||
// Server -> Client commands
|
// Server -> Client commands
|
||||||
CommandActived byte = 0 // COMMAND_ACTIVED
|
CommandActived byte = 0 // COMMAND_ACTIVED
|
||||||
CommandScreenSpy byte = 16 // COMMAND_SCREEN_SPY - start screen capture
|
CommandScreenSpy byte = 16 // COMMAND_SCREEN_SPY - start screen capture
|
||||||
|
CommandScreenControl byte = 20 // COMMAND_SCREEN_CONTROL - mouse/keyboard input (MSG64 batches)
|
||||||
CommandNext byte = 30 // COMMAND_NEXT - "control-side dialog is open, you may stream"
|
CommandNext byte = 30 // COMMAND_NEXT - "control-side dialog is open, you may stream"
|
||||||
CommandBye byte = 204 // COMMAND_BYE - disconnect
|
CommandBye byte = 204 // COMMAND_BYE - disconnect
|
||||||
CommandHeartbeat byte = 216 // CMD_HEARTBEAT_ACK
|
CommandHeartbeat byte = 216 // CMD_HEARTBEAT_ACK
|
||||||
@@ -116,6 +170,101 @@ const (
|
|||||||
AlgorithmH264 byte = 2 // ALGORITHM_H264 — H264 encoding (the algorithm web uses)
|
AlgorithmH264 byte = 2 // ALGORITHM_H264 — H264 encoding (the algorithm web uses)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Windows message constants used inside MSG64.message. The client dispatches
|
||||||
|
// on these values verbatim (CScreenManager::ProcessCommand at
|
||||||
|
// client/ScreenManager.cpp:1617), so these MUST stay bit-identical to the
|
||||||
|
// WinUser.h definitions even though this Go server is cross-platform.
|
||||||
|
const (
|
||||||
|
WMKeyDown uint64 = 0x0100
|
||||||
|
WMKeyUp uint64 = 0x0101
|
||||||
|
WMSysKeyDown uint64 = 0x0104
|
||||||
|
WMSysKeyUp uint64 = 0x0105
|
||||||
|
WMMouseMove uint64 = 0x0200
|
||||||
|
WMLButtonDown uint64 = 0x0201
|
||||||
|
WMLButtonUp uint64 = 0x0202
|
||||||
|
WMLButtonDblClk uint64 = 0x0203
|
||||||
|
WMRButtonDown uint64 = 0x0204
|
||||||
|
WMRButtonUp uint64 = 0x0205
|
||||||
|
WMRButtonDblClk uint64 = 0x0206
|
||||||
|
WMMButtonDown uint64 = 0x0207
|
||||||
|
WMMButtonUp uint64 = 0x0208
|
||||||
|
WMMouseWheel uint64 = 0x020A
|
||||||
|
)
|
||||||
|
|
||||||
|
// Virtual-key codes referenced from the input mapping. Same numeric values
|
||||||
|
// as the Win32 VK_* constants.
|
||||||
|
const (
|
||||||
|
VKLWin = 0x5B // VK_LWIN — filtered: never forwarded
|
||||||
|
VKRWin = 0x5C // VK_RWIN — filtered: never forwarded
|
||||||
|
VKPrior = 0x21 // VK_PRIOR (Page Up) — extended-key range start
|
||||||
|
VKDown = 0x28 // VK_DOWN — extended-key range end
|
||||||
|
VKInsert = 0x2D
|
||||||
|
VKDelete = 0x2E
|
||||||
|
VKNumLock = 0x90
|
||||||
|
VKRControl = 0xA3
|
||||||
|
VKRMenu = 0xA5
|
||||||
|
VKApps = 0x5D
|
||||||
|
)
|
||||||
|
|
||||||
|
// MK_* wParam bitflags for mouse-button messages.
|
||||||
|
const (
|
||||||
|
MKLButton uint64 = 0x0001
|
||||||
|
MKRButton uint64 = 0x0002
|
||||||
|
MKMButton uint64 = 0x0010
|
||||||
|
)
|
||||||
|
|
||||||
|
// MSG64 is the 48-byte fixed layout the client expects inside a
|
||||||
|
// COMMAND_SCREEN_CONTROL packet (common/commands.h class MSG64).
|
||||||
|
//
|
||||||
|
// [hwnd:8][message:8][wParam:8][lParam:8][time:8][pt.x:4][pt.y:4]
|
||||||
|
//
|
||||||
|
// All uint64 fields are little-endian; pt is two int32 LE. The client's
|
||||||
|
// ProcessCommand validates `ulLength % 48 == 0` and treats each 48-byte
|
||||||
|
// block as one MSG64.
|
||||||
|
const Msg64Size = 48
|
||||||
|
|
||||||
|
// BuildScreenControlPacket encodes one COMMAND_SCREEN_CONTROL packet
|
||||||
|
// carrying a single MSG64 record. The cmd byte is prepended.
|
||||||
|
//
|
||||||
|
// Wire layout:
|
||||||
|
//
|
||||||
|
// [CMD:1][hwnd:8 LE][message:8 LE][wParam:8 LE][lParam:8 LE][time:8 LE][pt.x:4 LE][pt.y:4 LE]
|
||||||
|
//
|
||||||
|
// time is filled with a monotonic-ish ms value (ms since Unix epoch trimmed
|
||||||
|
// to 32 bits) so the client's GetTickCount() comparisons stay reasonable.
|
||||||
|
func BuildScreenControlPacket(message, wParam, lParam uint64, ptX, ptY int32, timeMs uint32) []byte {
|
||||||
|
buf := make([]byte, 1+Msg64Size)
|
||||||
|
buf[0] = CommandScreenControl
|
||||||
|
// hwnd left zero — the client recomputes hWnd via WindowFromPoint.
|
||||||
|
binary.LittleEndian.PutUint64(buf[1+8:1+16], message)
|
||||||
|
binary.LittleEndian.PutUint64(buf[1+16:1+24], wParam)
|
||||||
|
binary.LittleEndian.PutUint64(buf[1+24:1+32], lParam)
|
||||||
|
binary.LittleEndian.PutUint64(buf[1+32:1+40], uint64(timeMs))
|
||||||
|
binary.LittleEndian.PutUint32(buf[1+40:1+44], uint32(ptX))
|
||||||
|
binary.LittleEndian.PutUint32(buf[1+44:1+48], uint32(ptY))
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
// MakeLParam packs x into the low word and y into the high word — the
|
||||||
|
// Windows MAKELPARAM macro the client expects in mouse-message lParams.
|
||||||
|
func MakeLParam(x, y int32) uint64 {
|
||||||
|
return uint64(uint32(x)&0xFFFF) | (uint64(uint32(y)&0xFFFF) << 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsExtendedKey returns true when the given Win32 VK code should set the
|
||||||
|
// extended-key bit (bit 24) in a keyboard lParam. Matches the C++
|
||||||
|
// HandleKey logic (server/2015Remote/WebService.cpp:944).
|
||||||
|
func IsExtendedKey(vk int) bool {
|
||||||
|
if vk >= VKPrior && vk <= VKDown {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch vk {
|
||||||
|
case VKInsert, VKDelete, VKNumLock, VKRControl, VKRMenu, VKApps:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Reserved-field indices we care about (see common/commands.h RES_* enum).
|
// Reserved-field indices we care about (see common/commands.h RES_* enum).
|
||||||
// LOGIN_INFOR.szReserved is a '|'-separated list; clients fill known slots
|
// LOGIN_INFOR.szReserved is a '|'-separated list; clients fill known slots
|
||||||
// even when leaving others blank ("?").
|
// even when leaving others blank ("?").
|
||||||
@@ -125,6 +274,7 @@ const (
|
|||||||
ResFieldInstallTime = 6 // RES_INSTALL_TIME
|
ResFieldInstallTime = 6 // RES_INSTALL_TIME
|
||||||
ResFieldClientLoc = 10 // RES_CLIENT_LOC — geo string
|
ResFieldClientLoc = 10 // RES_CLIENT_LOC — geo string
|
||||||
ResFieldClientPubIP = 11 // RES_CLIENT_PUBIP — public IP
|
ResFieldClientPubIP = 11 // RES_CLIENT_PUBIP — public IP
|
||||||
|
ResFieldResolution = 15 // RES_RESOLUTION — client-formatted screen geometry: "N:W*H"
|
||||||
ResFieldClientID = 16 // RES_CLIENT_ID — uint64 decimal, matches TOKEN_BITMAPINFO clientID
|
ResFieldClientID = 16 // RES_CLIENT_ID — uint64 decimal, matches TOKEN_BITMAPINFO clientID
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
|
||||||
@@ -11,8 +13,10 @@ import (
|
|||||||
// passed through so handlers can re-parse to their own shape.
|
// passed through so handlers can re-parse to their own shape.
|
||||||
//
|
//
|
||||||
// Phase 3 implements: get_salt, login, get_devices, ping, disconnect.
|
// Phase 3 implements: get_salt, login, get_devices, ping, disconnect.
|
||||||
// Phase 4/5/6 commands (connect, mouse, key, term_*, etc.) get a friendly
|
// Phase 4 adds: connect, screen frame relay.
|
||||||
// "not yet implemented" reply so the browser UI doesn't hang silently.
|
// Phase 5 adds: mouse, key (input forwarding to the device screen sub-conn).
|
||||||
|
// Phase 6/7 commands (term_*, user mgmt) get a friendly "not yet implemented"
|
||||||
|
// reply so the browser UI doesn't hang silently.
|
||||||
func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) {
|
func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) {
|
||||||
switch cmd {
|
switch cmd {
|
||||||
case "get_salt":
|
case "get_salt":
|
||||||
@@ -30,8 +34,10 @@ func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) {
|
|||||||
h.handleConnect(c, raw)
|
h.handleConnect(c, raw)
|
||||||
case "rdp_reset":
|
case "rdp_reset":
|
||||||
// silently ignored — UI uses this as a fire-and-forget
|
// silently ignored — UI uses this as a fire-and-forget
|
||||||
case "mouse", "key":
|
case "mouse":
|
||||||
// silently ignored — no remote screen yet
|
h.handleMouse(c, raw)
|
||||||
|
case "key":
|
||||||
|
h.handleKey(c, raw)
|
||||||
case "term_open":
|
case "term_open":
|
||||||
h.replyNotImplemented(c, "term_closed", "Web terminal not yet implemented on Go server")
|
h.replyNotImplemented(c, "term_closed", "Web terminal not yet implemented on Go server")
|
||||||
case "term_input", "term_resize", "term_close":
|
case "term_input", "term_resize", "term_close":
|
||||||
@@ -39,13 +45,13 @@ func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) {
|
|||||||
|
|
||||||
// Admin operations (Phase 7).
|
// Admin operations (Phase 7).
|
||||||
case "create_user":
|
case "create_user":
|
||||||
h.replyNotImplemented(c, "create_user_result", "User management not yet implemented")
|
h.handleCreateUser(c, raw)
|
||||||
case "delete_user":
|
case "delete_user":
|
||||||
h.replyNotImplemented(c, "delete_user_result", "User management not yet implemented")
|
h.handleDeleteUser(c, raw)
|
||||||
case "list_users":
|
case "list_users":
|
||||||
h.replyNotImplemented(c, "list_users_result", "User management not yet implemented")
|
h.handleListUsers(c, raw)
|
||||||
case "get_groups":
|
case "get_groups":
|
||||||
c.queue([]byte(`{"cmd":"groups","ok":true,"groups":[]}`))
|
h.handleGetGroups(c, raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +63,23 @@ func (h *wsHub) replyNotImplemented(c *wsClient, replyCmd, msg string) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requireAdmin combines token validation with a role=="admin" check. The
|
||||||
|
// reply on failure has the standard `{cmd, ok:false, msg}` shape so the
|
||||||
|
// front-end's generic toast handler can surface the reason.
|
||||||
|
func (h *wsHub) requireAdmin(c *wsClient, raw []byte, replyCmd string) (ok bool) {
|
||||||
|
if !h.requireAuth(c, raw, replyCmd) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// c.role is cached on first successful requireAuth; safe to read here.
|
||||||
|
if c.role != "admin" {
|
||||||
|
c.queue(mustJSON(map[string]any{
|
||||||
|
"cmd": replyCmd, "ok": false, "msg": "Permission denied",
|
||||||
|
}))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// ----- handlers ------------------------------------------------------------
|
// ----- handlers ------------------------------------------------------------
|
||||||
|
|
||||||
func (h *wsHub) handleGetSalt(c *wsClient, raw []byte) {
|
func (h *wsHub) handleGetSalt(c *wsClient, raw []byte) {
|
||||||
@@ -275,6 +298,271 @@ func (h *wsHub) requireAuth(c *wsClient, raw []byte, replyCmd string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleMouse forwards one mouse event from the browser to the device's
|
||||||
|
// screen sub-connection as a COMMAND_SCREEN_CONTROL packet carrying a
|
||||||
|
// single MSG64. Mirrors the C++ CWebService::HandleMouse path
|
||||||
|
// (server/2015Remote/WebService.cpp:773) so the client's
|
||||||
|
// CScreenManager::ProcessCommand sees identical bytes regardless of which
|
||||||
|
// server is serving the device. Unauthenticated and unknown event types
|
||||||
|
// drop silently — input is high-frequency, error replies would just spam
|
||||||
|
// the WS.
|
||||||
|
func (h *wsHub) handleMouse(c *wsClient, raw []byte) {
|
||||||
|
if !h.requireAuth(c, raw, "mouse_result") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
X int32 `json:"x"`
|
||||||
|
Y int32 `json:"y"`
|
||||||
|
Button int `json:"button"`
|
||||||
|
Delta int `json:"delta"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &in); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deviceID := c.watching
|
||||||
|
if deviceID == "" {
|
||||||
|
return // browser hasn't picked a device yet
|
||||||
|
}
|
||||||
|
|
||||||
|
var message, wParam uint64
|
||||||
|
switch in.Type {
|
||||||
|
case "down":
|
||||||
|
switch in.Button {
|
||||||
|
case 0:
|
||||||
|
message, wParam = protocol.WMLButtonDown, protocol.MKLButton
|
||||||
|
case 1:
|
||||||
|
message, wParam = protocol.WMMButtonDown, protocol.MKMButton
|
||||||
|
case 2:
|
||||||
|
message, wParam = protocol.WMRButtonDown, protocol.MKRButton
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "up":
|
||||||
|
switch in.Button {
|
||||||
|
case 0:
|
||||||
|
message = protocol.WMLButtonUp
|
||||||
|
case 1:
|
||||||
|
message = protocol.WMMButtonUp
|
||||||
|
case 2:
|
||||||
|
message = protocol.WMRButtonUp
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "move":
|
||||||
|
message = protocol.WMMouseMove
|
||||||
|
case "wheel":
|
||||||
|
message = protocol.WMMouseWheel
|
||||||
|
// Windows expects ±120 per notch in HIWORD(wParam). Browsers report
|
||||||
|
// `deltaY` in pixels with sign flipped relative to Win32 conventions
|
||||||
|
// (positive deltaY = scroll down). Normalize to one notch per call,
|
||||||
|
// signed so up scrolls "up" on the remote.
|
||||||
|
var wheel int16
|
||||||
|
switch {
|
||||||
|
case in.Delta > 0:
|
||||||
|
wheel = -120
|
||||||
|
case in.Delta < 0:
|
||||||
|
wheel = 120
|
||||||
|
}
|
||||||
|
wParam = uint64(uint16(wheel)) << 16
|
||||||
|
case "dblclick":
|
||||||
|
// Windows synthesizes double-click from rapid down/up sequences, so
|
||||||
|
// the C++ server only forwards this for macOS clients. We don't
|
||||||
|
// currently track client OS on the Go side; drop the event — the
|
||||||
|
// down/up pair the browser already sent is sufficient on Windows.
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lParam := protocol.MakeLParam(in.X, in.Y)
|
||||||
|
pkt := protocol.BuildScreenControlPacket(message, wParam, lParam, in.X, in.Y, tickMillis())
|
||||||
|
_ = h.devices.SendToScreen(deviceID, pkt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleKey forwards one keyboard event to the device. lParam is built to
|
||||||
|
// match the Windows convention so applications relying on TranslateMessage
|
||||||
|
// (e.g. text input fields) behave correctly. Mirrors
|
||||||
|
// CWebService::HandleKey at server/2015Remote/WebService.cpp:878.
|
||||||
|
//
|
||||||
|
// Scan code: the C++ server resolves the VK -> scan code via the local
|
||||||
|
// MapVirtualKey. Go is cross-platform so we leave the scan-code bits zero;
|
||||||
|
// the client side ultimately delivers events via SendInput/keybd_event
|
||||||
|
// which accept VK-only input, and the extended-key bit (bit 24) is what
|
||||||
|
// actually matters for distinguishing numpad/arrow keys.
|
||||||
|
func (h *wsHub) handleKey(c *wsClient, raw []byte) {
|
||||||
|
if !h.requireAuth(c, raw, "key_result") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in struct {
|
||||||
|
KeyCode int `json:"keyCode"`
|
||||||
|
Down bool `json:"down"`
|
||||||
|
Alt bool `json:"alt"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &in); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if in.KeyCode == protocol.VKLWin || in.KeyCode == protocol.VKRWin {
|
||||||
|
return // Windows keys stay local; matches both C++ server and client
|
||||||
|
}
|
||||||
|
deviceID := c.watching
|
||||||
|
if deviceID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var message uint64
|
||||||
|
switch {
|
||||||
|
case in.Alt && in.Down:
|
||||||
|
message = protocol.WMSysKeyDown
|
||||||
|
case in.Alt && !in.Down:
|
||||||
|
message = protocol.WMSysKeyUp
|
||||||
|
case in.Down:
|
||||||
|
message = protocol.WMKeyDown
|
||||||
|
default:
|
||||||
|
message = protocol.WMKeyUp
|
||||||
|
}
|
||||||
|
|
||||||
|
// lParam layout (Win32 keyboard message):
|
||||||
|
// bits 0..15: repeat count (= 1)
|
||||||
|
// bits 16..23: scan code (we leave zero — see handleKey doc)
|
||||||
|
// bit 24: extended-key flag (arrows, numpad, RCtrl, RAlt, ...)
|
||||||
|
// bit 29: context code (Alt held)
|
||||||
|
// bits 30..31: previous-key/transition flags (both set on key-up)
|
||||||
|
lParam := uint64(1)
|
||||||
|
if protocol.IsExtendedKey(in.KeyCode) {
|
||||||
|
lParam |= 1 << 24
|
||||||
|
}
|
||||||
|
if in.Alt {
|
||||||
|
lParam |= 1 << 29
|
||||||
|
}
|
||||||
|
if !in.Down {
|
||||||
|
lParam |= 3 << 30
|
||||||
|
}
|
||||||
|
|
||||||
|
wParam := uint64(uint32(in.KeyCode))
|
||||||
|
pkt := protocol.BuildScreenControlPacket(message, wParam, lParam, 0, 0, tickMillis())
|
||||||
|
_ = h.devices.SendToScreen(deviceID, pkt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateUser provisions a new web account. Admin-only. Mirrors the
|
||||||
|
// C++ CWebService::HandleCreateUser semantics (WebService.cpp:1009): the
|
||||||
|
// password is hashed with a fresh salt, the user table is persisted, and
|
||||||
|
// any duplicate username is rejected.
|
||||||
|
func (h *wsHub) handleCreateUser(c *wsClient, raw []byte) {
|
||||||
|
const replyCmd = "create_user_result"
|
||||||
|
if !h.requireAdmin(c, raw, replyCmd) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
AllowedGroups []string `json:"allowed_groups"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &in); err != nil {
|
||||||
|
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": "Invalid JSON"}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if in.Role == "" {
|
||||||
|
in.Role = "viewer"
|
||||||
|
}
|
||||||
|
if err := h.auth.CreateUser(in.Username, in.Password, in.Role, in.AllowedGroups); err != nil {
|
||||||
|
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": err.Error()}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.log.Info("user created by %s: name=%s role=%s groups=%d",
|
||||||
|
c.role, in.Username, in.Role, len(in.AllowedGroups))
|
||||||
|
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": true}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteUser removes an account and revokes any of its live sessions.
|
||||||
|
// Admin-only. The bootstrap "admin" account is rejected at the wsauth layer.
|
||||||
|
func (h *wsHub) handleDeleteUser(c *wsClient, raw []byte) {
|
||||||
|
const replyCmd = "delete_user_result"
|
||||||
|
if !h.requireAdmin(c, raw, replyCmd) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &in); err != nil {
|
||||||
|
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": "Invalid JSON"}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.auth.DeleteUser(in.Username); err != nil {
|
||||||
|
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": err.Error()}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.log.Info("user deleted by %s: name=%s", c.role, in.Username)
|
||||||
|
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": true}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListUsers returns the account table to admin callers. Field shape
|
||||||
|
// matches the C++ list_users_result the browser already parses: an array
|
||||||
|
// of {username, role, allowed_groups}.
|
||||||
|
func (h *wsHub) handleListUsers(c *wsClient, raw []byte) {
|
||||||
|
const replyCmd = "list_users_result"
|
||||||
|
if !h.requireAdmin(c, raw, replyCmd) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
users := h.auth.ListUsers()
|
||||||
|
out := make([]map[string]any, 0, len(users))
|
||||||
|
for _, u := range users {
|
||||||
|
groups := u.AllowedGroups
|
||||||
|
if groups == nil {
|
||||||
|
groups = []string{}
|
||||||
|
}
|
||||||
|
out = append(out, map[string]any{
|
||||||
|
"username": u.Username,
|
||||||
|
"role": u.Role,
|
||||||
|
"allowed_groups": groups,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.queue(mustJSON(map[string]any{
|
||||||
|
"cmd": replyCmd,
|
||||||
|
"ok": true,
|
||||||
|
"users": out,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetGroups returns the deduplicated set of group labels currently
|
||||||
|
// observed on online devices, plus a synthetic "default" entry that the
|
||||||
|
// admin UI uses for ungrouped devices. Admin-only — non-admins don't get
|
||||||
|
// to enumerate the device fleet's groups. Matches the contract of
|
||||||
|
// CWebService::HandleGetGroups (WebService.cpp:1130).
|
||||||
|
func (h *wsHub) handleGetGroups(c *wsClient, raw []byte) {
|
||||||
|
const replyCmd = "groups"
|
||||||
|
if !h.requireAdmin(c, raw, replyCmd) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen := map[string]struct{}{"default": {}}
|
||||||
|
for _, d := range h.devices.ListDevices() {
|
||||||
|
g := d.Group
|
||||||
|
if g == "" {
|
||||||
|
g = "default"
|
||||||
|
}
|
||||||
|
seen[g] = struct{}{}
|
||||||
|
}
|
||||||
|
groups := make([]string, 0, len(seen))
|
||||||
|
for g := range seen {
|
||||||
|
groups = append(groups, g)
|
||||||
|
}
|
||||||
|
sort.Strings(groups)
|
||||||
|
c.queue(mustJSON(map[string]any{
|
||||||
|
"cmd": replyCmd,
|
||||||
|
"ok": true,
|
||||||
|
"groups": groups,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// tickMillis returns a 32-bit-truncated ms timestamp suitable for the
|
||||||
|
// MSG64.time field. The client compares these with GetTickCount(), which
|
||||||
|
// is also a 32-bit ms counter — exact origin doesn't matter, only that
|
||||||
|
// successive events have non-decreasing values within the wrap window.
|
||||||
|
func tickMillis() uint32 {
|
||||||
|
return uint32(time.Now().UnixMilli() & 0xFFFFFFFF)
|
||||||
|
}
|
||||||
|
|
||||||
func mustJSON(v any) []byte {
|
func mustJSON(v any) []byte {
|
||||||
b, err := json.Marshal(v)
|
b, err := json.Marshal(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -32,6 +36,12 @@ type User struct {
|
|||||||
PasswordHash string // SHA256(password+salt) in lowercase hex
|
PasswordHash string // SHA256(password+salt) in lowercase hex
|
||||||
Salt string // empty for admin (matches C++ convention)
|
Salt string // empty for admin (matches C++ convention)
|
||||||
Role string // "admin" or "viewer"
|
Role string // "admin" or "viewer"
|
||||||
|
// AllowedGroups restricts which device groups this user can view. Empty
|
||||||
|
// slice = no device access (admin role is treated as "all" elsewhere
|
||||||
|
// without consulting this list). Matches the C++ WebUser.allowed_groups
|
||||||
|
// field one-for-one so users.json is interchangeable between the two
|
||||||
|
// servers.
|
||||||
|
AllowedGroups []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session is the authenticated state attached to a valid token.
|
// Session is the authenticated state attached to a valid token.
|
||||||
@@ -48,6 +58,10 @@ type Authenticator struct {
|
|||||||
users map[string]*User // username -> user
|
users map[string]*User // username -> user
|
||||||
tokens map[string]*Session // token -> session
|
tokens map[string]*Session // token -> session
|
||||||
tokenExpire time.Duration
|
tokenExpire time.Duration
|
||||||
|
// usersFile is the persistence target for non-admin accounts (admin
|
||||||
|
// lives in env/master-password only and is never written out, matching
|
||||||
|
// the C++ SaveUsers behavior). Empty disables persistence.
|
||||||
|
usersFile string
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns an empty Authenticator. Call AddUser to populate.
|
// New returns an empty Authenticator. Call AddUser to populate.
|
||||||
@@ -92,6 +106,100 @@ func (a *Authenticator) AddAdminFromPlainPassword(username, plainPassword string
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateUser registers a new account, persists the user table, and returns
|
||||||
|
// nil on success. Mirrors the C++ CWebService::CreateUser semantics:
|
||||||
|
// - role must be "admin" or "viewer"
|
||||||
|
// - "admin" is reserved for the bootstrap account and cannot be created
|
||||||
|
// via this API
|
||||||
|
// - duplicate usernames are rejected
|
||||||
|
//
|
||||||
|
// Password is hashed with a fresh per-user salt before being stored.
|
||||||
|
func (a *Authenticator) CreateUser(username, plainPassword, role string, allowedGroups []string) error {
|
||||||
|
if username == "" || plainPassword == "" {
|
||||||
|
return errors.New("username and password required")
|
||||||
|
}
|
||||||
|
if username == "admin" {
|
||||||
|
return errors.New("'admin' is reserved")
|
||||||
|
}
|
||||||
|
if role != "admin" && role != "viewer" {
|
||||||
|
return errors.New("role must be 'admin' or 'viewer'")
|
||||||
|
}
|
||||||
|
|
||||||
|
salt, err := NewSalt()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u := User{
|
||||||
|
Username: username,
|
||||||
|
PasswordHash: HashPassword(plainPassword, salt),
|
||||||
|
Salt: salt,
|
||||||
|
Role: role,
|
||||||
|
AllowedGroups: append([]string(nil), allowedGroups...),
|
||||||
|
}
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
if _, exists := a.users[username]; exists {
|
||||||
|
a.mu.Unlock()
|
||||||
|
return errors.New("user already exists")
|
||||||
|
}
|
||||||
|
a.users[username] = &u
|
||||||
|
path := a.usersFile
|
||||||
|
snapshot := a.snapshotPersistableLocked()
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if path != "" {
|
||||||
|
return writeUsersFile(path, snapshot)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser removes a user account and persists the change. The bootstrap
|
||||||
|
// admin (always Role=="admin" with empty Salt) is protected: deleting it
|
||||||
|
// would lock everyone out of the UI with no way back in.
|
||||||
|
func (a *Authenticator) DeleteUser(username string) error {
|
||||||
|
if username == "" || username == "admin" {
|
||||||
|
return errors.New("cannot delete admin")
|
||||||
|
}
|
||||||
|
a.mu.Lock()
|
||||||
|
if _, ok := a.users[username]; !ok {
|
||||||
|
a.mu.Unlock()
|
||||||
|
return errors.New("user not found")
|
||||||
|
}
|
||||||
|
delete(a.users, username)
|
||||||
|
// Also drop any live sessions belonging to that user so they don't
|
||||||
|
// outlive their account.
|
||||||
|
for tok, s := range a.tokens {
|
||||||
|
if s.Username == username {
|
||||||
|
delete(a.tokens, tok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path := a.usersFile
|
||||||
|
snapshot := a.snapshotPersistableLocked()
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if path != "" {
|
||||||
|
return writeUsersFile(path, snapshot)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers returns a stable, copied snapshot of all users in name order.
|
||||||
|
// PasswordHash and Salt are zeroed in the returned records so callers can
|
||||||
|
// JSON-marshal the result without leaking credentials.
|
||||||
|
func (a *Authenticator) ListUsers() []User {
|
||||||
|
a.mu.RLock()
|
||||||
|
defer a.mu.RUnlock()
|
||||||
|
out := make([]User, 0, len(a.users))
|
||||||
|
for _, u := range a.users {
|
||||||
|
c := *u
|
||||||
|
c.PasswordHash = ""
|
||||||
|
c.Salt = ""
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Username < out[j].Username })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// GetSalt returns the per-user salt. If the user does not exist, returns ("", false).
|
// GetSalt returns the per-user salt. If the user does not exist, returns ("", false).
|
||||||
// Note: the C++ admin uses an empty salt — that is still considered "found"
|
// Note: the C++ admin uses an empty salt — that is still considered "found"
|
||||||
// and the empty string is returned with ok=true.
|
// and the empty string is returned with ok=true.
|
||||||
@@ -135,6 +243,134 @@ func (a *Authenticator) VerifyLogin(username, response, nonce string) (token, ro
|
|||||||
return token, u.Role, nil
|
return token, u.Role, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// usersFileEntry is the on-disk shape of one record in users.json. Field
|
||||||
|
// names match the C++ WebService SaveUsers output exactly so the two
|
||||||
|
// servers can share the file.
|
||||||
|
type usersFileEntry struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
PasswordHash string `json:"password_hash"`
|
||||||
|
Salt string `json:"salt"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
AllowedGroups []string `json:"allowed_groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type usersFile struct {
|
||||||
|
Users []usersFileEntry `json:"users"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUsersFile points the authenticator at a JSON file used to persist
|
||||||
|
// non-admin accounts and loads any existing entries. Calling it again with
|
||||||
|
// a different path is allowed but the previous file is not re-read on a
|
||||||
|
// nil-arg call — initialize once at startup. Missing files are not an
|
||||||
|
// error; they're treated as an empty user table.
|
||||||
|
func (a *Authenticator) SetUsersFile(path string) error {
|
||||||
|
a.mu.Lock()
|
||||||
|
a.usersFile = path
|
||||||
|
a.mu.Unlock()
|
||||||
|
if path == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var f usersFile
|
||||||
|
if err := json.Unmarshal(data, &f); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
for _, e := range f.Users {
|
||||||
|
// Skip admin: it's always sourced from env/master password and
|
||||||
|
// already injected via AddAdminFromPlainPassword.
|
||||||
|
if e.Username == "" || e.Username == "admin" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e.PasswordHash == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
role := e.Role
|
||||||
|
if role == "" {
|
||||||
|
role = "viewer"
|
||||||
|
}
|
||||||
|
a.users[e.Username] = &User{
|
||||||
|
Username: e.Username,
|
||||||
|
PasswordHash: e.PasswordHash,
|
||||||
|
Salt: e.Salt,
|
||||||
|
Role: role,
|
||||||
|
AllowedGroups: append([]string(nil), e.AllowedGroups...),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapshotPersistableLocked builds the on-disk slice while a.mu is held by
|
||||||
|
// the caller. Admin records are excluded — they live in env, not in the file.
|
||||||
|
func (a *Authenticator) snapshotPersistableLocked() []usersFileEntry {
|
||||||
|
out := make([]usersFileEntry, 0, len(a.users))
|
||||||
|
for _, u := range a.users {
|
||||||
|
if u.Username == "admin" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
groups := append([]string{}, u.AllowedGroups...)
|
||||||
|
out = append(out, usersFileEntry{
|
||||||
|
Username: u.Username,
|
||||||
|
PasswordHash: u.PasswordHash,
|
||||||
|
Salt: u.Salt,
|
||||||
|
Role: u.Role,
|
||||||
|
AllowedGroups: groups,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Username < out[j].Username })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeUsersFile writes the user list atomically: encode to a temp file in
|
||||||
|
// the same directory, fsync, rename — so a crash mid-write can't leave the
|
||||||
|
// service starting up with a half-written users.json next time.
|
||||||
|
func writeUsersFile(path string, entries []usersFileEntry) error {
|
||||||
|
data, err := json.MarshalIndent(usersFile{Users: entries}, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if dir != "" {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tmp, err := os.CreateTemp(dir, "users-*.json.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
if _, err := tmp.Write(data); err != nil {
|
||||||
|
_ = tmp.Close()
|
||||||
|
_ = os.Remove(tmpName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tmp.Sync(); err != nil {
|
||||||
|
_ = tmp.Close()
|
||||||
|
_ = os.Remove(tmpName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
_ = os.Remove(tmpName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpName, path); err != nil {
|
||||||
|
_ = os.Remove(tmpName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ValidateToken returns the session for a token or ErrInvalidToken. Expired
|
// ValidateToken returns the session for a token or ErrInvalidToken. Expired
|
||||||
// tokens are removed lazily as they are looked up.
|
// tokens are removed lazily as they are looked up.
|
||||||
func (a *Authenticator) ValidateToken(token string) (*Session, error) {
|
func (a *Authenticator) ValidateToken(token string) (*Session, error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user