Files
SimpleRemoter/server/go/cmd/main.go
T
yuanyuanxianganddeepseek-flash a522424491 Feature: Security audit logging with admin web query page
Add a dedicated security audit trail to the Go server. Login attempts,
authorization denials, WebSocket lifecycle, remote screen and terminal
sessions, clipboard operations, device authentication and admin mutations
now emit stable JSON-line events (audit=true, event=<name>) to a separate
logs/audit.log, mirrored to server.log. The path is configurable through
YAMA_AUDIT_LOG and the separate file can be switched off. Records never
contain passcodes, password hashes, tokens or clipboard contents, and
untrusted string fields are stripped of control characters and truncated
so they cannot forge log lines.

Expose the trail to administrators through a query_audit WebSocket command
and an Audit Log page in the web UI. The page filters by time range, event
(exact or prefix), actor, IP, device id, keyword and warning level, pages
newest-first, and stays hidden unless the server advertises the auditLog
capability, so older C++ servers degrade cleanly. Each query scans at most
the newest 32 MB of the current file to bound latency, records itself as
admin.audit.query, and tolerates torn or non-audit lines.

Add unit tests for event emission and redaction, query filtering, paging,
malformed lines, missing or disabled audit files, and handler
authorization.

Co-Authored-By: deepseek-flash
2026-10-18 18:44:17 +02:00

1336 lines
52 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/auth"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/devicehistory"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/licensing"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/server"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/web"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
)
// MyHandler implements the server.Handler interface
type MyHandler struct {
log *logger.Logger
auth *auth.Authenticator
srv *server.Server
hub *hub.Hub
signer licensing.Signer // CMD_MASTERSETTING signer (local / remote / noop)
loginLimit *wsauth.RateLimiter // per-IP TOKEN_AUTH throttle
connAuthKey []byte // nil = ConnAuth HMAC verification disabled (legacy always-OK)
}
// OnConnect is called when a client connects
func (h *MyHandler) OnConnect(ctx *connection.Context) {
// Only log connection established, detailed info logged on login
}
// OnDisconnect is called when a client disconnects
func (h *MyHandler) OnDisconnect(ctx *connection.Context) {
// Always clean up any sub-context mapping first — the connection may
// be a screen / terminal sub-conn rather than a main login connection.
// Both Unbind* calls are no-ops if not tracked. UnbindTerminalConn
// also fires OnTerminalClosed so the browser sees the session end on
// unexpected device-side drops.
h.hub.UnbindScreenConn(ctx)
h.hub.UnbindTerminalConn(ctx)
info := ctx.GetInfo()
// Only treat this disconnect as a device-going-offline event if this
// ctx is the device's MAIN login connection. Phase 6 added ClientID
// pinning to sub-conns (via ConnAuth — needed for terminal routing),
// so a non-empty ClientID alone no longer distinguishes main from
// sub. Closing a screen / terminal sub-conn must NOT remove the
// device from the hub.
if info.ClientID != "" && h.hub.MainConn(info.ClientID) == ctx {
h.log.ClientEvent("offline", ctx.ID, ctx.GetPeerIP(),
"clientID", info.ClientID,
"computer", info.ComputerName,
)
// Tear down any active sub-conn sessions BEFORE Unregister so the
// browser sees screen/terminal close events alongside the
// device-offline event, instead of frames/output continuing to
// stream from orphaned sub-conn ctxs until they time out on
// their own. Both calls no-op if there's no active session.
h.hub.CloseScreen(info.ClientID)
h.hub.CloseTerminalSession(info.ClientID)
h.hub.Unregister(info.ClientID)
}
}
// OnReceive is called when data is received from a client
func (h *MyHandler) OnReceive(ctx *connection.Context, data []byte) {
if len(data) == 0 {
return
}
// Terminal-bound sub-conns deliver RAW shell output with no leading
// command byte — see client/ConPTYManager.cpp:328 (Send2Server with
// just the buffer). We must short-circuit BEFORE the command switch
// or the first output byte will be misinterpreted as a token.
// Exception: a length-1 packet whose byte is TOKEN_TERMINAL_CLOSE
// is the device's "shell exited" notification, NOT data.
if devID := h.hub.TerminalDeviceID(ctx); devID != "" {
if len(data) == 1 && data[0] == protocol.TokenTerminalClose {
h.log.Info("terminal closed by device=%s conn=%d", devID, ctx.ID)
h.hub.CloseTerminalSession(devID)
return
}
// Wrap with the 'TRM1' magic the browser uses to demultiplex
// terminal output from screen frames over the shared WS.
packet := make([]byte, 4+len(data))
copy(packet[:4], protocol.TerminalBinaryMagic[:])
copy(packet[4:], data)
h.hub.PublishTerminalData(devID, packet)
return
}
cmd := data[0]
// Handle commands
switch cmd {
case protocol.TokenLogin:
h.handleLogin(ctx, data)
case protocol.TokenAuth:
h.handleAuth(ctx, data)
case protocol.TokenHeartbeat:
h.handleHeartbeat(ctx, data)
case protocol.TokenConnAuth:
h.handleConnAuth(ctx, data)
case protocol.TokenBitmapInfo:
h.handleBitmapInfo(ctx, data)
case protocol.TokenTerminalStart:
h.handleTerminalStart(ctx, true)
case protocol.TokenShellStart:
h.handleTerminalStart(ctx, false)
case protocol.TokenTerminalClose:
// Pre-bind close (rare — device gives up before the server
// finished its half of the handshake). Best-effort cleanup.
if devID := h.deviceIDOfSubConn(ctx); devID != "" {
h.log.Info("pre-bind terminal close: device=%s conn=%d", devID, ctx.ID)
h.hub.CloseTerminalSession(devID)
}
case protocol.TokenFirstScreen:
// TOKEN_FIRSTSCREEN delivers a RAW BGRA baseline frame, not an
// H264 unit — bytes ≈ width × height × 4. The C++ MFC dialog
// blits it directly into a DIB; web viewers only consume H264 NAL
// data, so dropping it here is correct. The first real H264 IDR
// arrives shortly after via TOKEN_NEXTSCREEN.
case protocol.TokenNextScreen:
h.handleScreenFrame(ctx, data, false)
case protocol.TokenClipboardText:
h.handleClipboardText(ctx, data)
case protocol.TokenKeyframe:
// The client switches to this token when it emits an IDR on request
// (COMMAND_SCREEN_KEYFRAME_REQ) or on a GOP boundary. The header
// layout is identical to TOKEN_NEXTSCREEN, but the payload is always
// an IDR, so relay it as frameType=1 without sniffing. Dropping it
// (the old behaviour, harmless while the client's DEFAULT_GOP of
// 0x7FFFFFFF meant this token never appeared) breaks keyframe
// recovery: the browser keeps decoding P-frames against a reference
// chain it never received, so the picture stays corrupt until the
// screen session restarts.
h.handleScreenFrame(ctx, data, true)
case protocol.CmdCursorImage:
// Custom cursor bitmaps — relayed in Phase 5+ when the web cursor
// overlay learns to render arbitrary BGRA images. Drop silently for
// now; the standard IDC_* index (data[10] of every frame header) is
// what we actually use right now.
case protocol.CmdSockDllLoader:
// SOCKET_DLLLOADER (210): the shellcode / memory-DLL loader asks for the
// full DLL payload before it can load the client. Serving it is the MFC
// 主控端's job (server/2015Remote SendServerDll); this web device port has
// no payload to hand out, so drop it silently instead of logging "Unhandled".
default:
// Other commands are not implemented yet
h.log.Info("Unhandled command %d from client %d", cmd, ctx.ID)
}
}
// handleConnAuth answers a sub-connection identity handshake. Every sub-conn
// the client opens (screen, terminal, file, ...) sends a 512-byte
// ConnAuthPacket as its very first payload and blocks for up to 10 s waiting
// on our 256-byte ConnAuthAck. Without an OK reply the client closes the
// connection, so a missing ack here means nothing else can proceed.
//
// When the operator supplied YAMA_CONN_AUTH_KEY, the packet's HMAC-SHA256
// signature and timestamp are verified first (see verifyConnAuth); a bad
// packet is answered with a non-OK status and the sub-conn is dropped.
// Without a key we keep the legacy behavior: always reply OK and let
// TOKEN_BITMAPINFO carry the device ID via offset 41 when the screen
// sub-conn proceeds.
func (h *MyHandler) handleConnAuth(ctx *connection.Context, data []byte) {
// When the operator supplied a ConnAuth HMAC key (YAMA_CONN_AUTH_KEY),
// verify the packet's signature and timestamp before trusting its
// clientID. This closes the sub-connection hijack hole that the
// source-IP check in hub.BindScreenConn otherwise alone guards against.
// A nil key preserves the legacy always-OK behavior.
if len(h.connAuthKey) > 0 {
if status := h.verifyConnAuth(data); status != protocol.ConnAuthStatusOK {
h.rejectConnAuth(ctx, status)
return
}
}
// Pin the parent device's ClientID onto the sub-conn. Without this,
// later 1-byte tokens (TOKEN_TERMINAL_START / TOKEN_SHELL_START) have
// no way to identify which device they belong to — they carry no
// clientID themselves. ConnAuthPacket layout has clientID at offset 1
// (uint64 LE); see common/commands.h::ConnAuthPacket.
if len(data) >= protocol.ConnAuthOffClientID+8 {
clientID := binary.LittleEndian.Uint64(
data[protocol.ConnAuthOffClientID : protocol.ConnAuthOffClientID+8])
if clientID != 0 {
// Sub-conns never go through handleLogin, so their ctx.Info
// is otherwise empty. We only need ClientID for routing.
info := ctx.GetInfo()
info.ClientID = strconv.FormatUint(clientID, 10)
ctx.SetInfo(info)
}
}
ack := make([]byte, protocol.ConnAuthAckSize)
ack[0] = protocol.TokenConnAuth
ack[protocol.ConnAuthAckOffStatus] = protocol.ConnAuthStatusOK
binary.LittleEndian.PutUint64(
ack[protocol.ConnAuthAckOffServerTime:protocol.ConnAuthAckOffServerTime+8],
uint64(time.Now().Unix()))
if err := h.srv.Send(ctx, ack); err != nil {
h.log.Error("ConnAuth ack send failed for conn=%d: %v", ctx.ID, err)
}
}
// verifyConnAuth checks the ConnAuthPacket's HMAC-SHA256 signature and its
// timestamp against the configured shared key. Returns a ConnAuthStatus code
// (OK on success).
func (h *MyHandler) verifyConnAuth(data []byte) byte {
// Need the full [token][clientID][timestamp][nonce][signature] prefix.
if len(data) < protocol.ConnAuthOffSignature+protocol.ConnAuthSignatureSize {
return protocol.ConnAuthStatusBadSize
}
// Anti-replay: the client signs its own Unix timestamp, so an old (but
// valid) packet cannot be replayed once it leaves the tolerance window.
ts := binary.LittleEndian.Uint64(
data[protocol.ConnAuthOffTimestamp : protocol.ConnAuthOffTimestamp+8])
now := uint64(time.Now().Unix())
var skew uint64
if now >= ts {
skew = now - ts
} else {
skew = ts - now
}
if skew > protocol.ConnAuthTimestampToleranceSec {
return protocol.ConnAuthStatusClockSkew
}
// HMAC-SHA256 over clientID||timestamp||nonce (offsets 1..33), matching
// the client's signMessage("", sigInput, 32). The client stores the hex
// encoding of the 32-byte digest in the 64-byte signature field.
mac := hmac.New(sha256.New, h.connAuthKey)
mac.Write(data[protocol.ConnAuthOffClientID:protocol.ConnAuthOffSignature])
want := make([]byte, protocol.ConnAuthSignatureSize)
hex.Encode(want, mac.Sum(nil))
if !hmac.Equal(want, data[protocol.ConnAuthOffSignature:protocol.ConnAuthOffSignature+protocol.ConnAuthSignatureSize]) {
return protocol.ConnAuthStatusBadSignature
}
return protocol.ConnAuthStatusOK
}
// rejectConnAuth replies with a non-OK ConnAuth status and closes the
// sub-connection. The client (IOCPClient::TryHandleAuthResponse) treats any
// non-zero status as failure and disconnects, so the close here is
// belt-and-suspenders for peers that don't.
func (h *MyHandler) rejectConnAuth(ctx *connection.Context, status byte) {
ack := make([]byte, protocol.ConnAuthAckSize)
ack[0] = protocol.TokenConnAuth
ack[protocol.ConnAuthAckOffStatus] = status
binary.LittleEndian.PutUint64(
ack[protocol.ConnAuthAckOffServerTime:protocol.ConnAuthAckOffServerTime+8],
uint64(time.Now().Unix()))
if err := h.srv.Send(ctx, ack); err != nil {
h.log.Error("ConnAuth reject send failed for conn=%d: %v", ctx.ID, err)
}
h.log.AuditWarn("device.connauth.rejected", map[string]any{
"conn": ctx.ID,
"ip": ctx.GetPeerIP(),
"status": status,
"device_id": ctx.GetInfo().ClientID,
})
_ = ctx.Close()
}
// deviceIDOfSubConn resolves the parent device of a sub-conn from the
// ClientID pinned by handleConnAuth. Returns "" for the rare case of a
// legacy client that skipped ConnAuth (the Go server's only target is
// modern clients, so this is effectively a paranoia check).
func (h *MyHandler) deviceIDOfSubConn(ctx *connection.Context) string {
return ctx.GetInfo().ClientID
}
// handleTerminalStart fires when the device's freshly-spawned shell
// sub-conn announces itself. TOKEN_TERMINAL_START (232) means PTY mode
// (Linux/macOS or Windows ConPTY); TOKEN_SHELL_START (128) means the
// legacy Windows cmd-pipe path. Both packets are 1-byte tokens — the
// device identity comes from ConnAuth's pinned ClientID.
//
// After binding we send:
// - For PTY only: an initial CMD_TERMINAL_RESIZE 80x24 so the shell
// doesn't render at the PTY default before the browser's first fit.
// vim/htop look broken otherwise. The browser will follow up with a
// real term_resize once xterm.js sizes the canvas.
// - Always: COMMAND_NEXT to unblock the device's read thread (the
// ConPTYManager ReadThread sits on m_hEventDlgOpen until then —
// see client/ConPTYManager.cpp:259).
func (h *MyHandler) handleTerminalStart(ctx *connection.Context, isPTY bool) {
devID := h.deviceIDOfSubConn(ctx)
if devID == "" {
h.log.Warn("terminal start with no clientID: conn=%d", ctx.ID)
ctx.Close()
return
}
if !h.hub.BindTerminalConn(devID, ctx, isPTY) {
// Either a stale sub-conn (no pending session — browser gave up and
// closed term_close already), or a hijack attempt from a source IP
// other than the device's login conn. Log the latter distinctly.
if mc := h.hub.MainConn(devID); mc != nil && mc.GetPeerIP() != ctx.GetPeerIP() {
h.log.AuditWarn("device.subconn.foreign_ip", map[string]any{
"kind": "terminal",
"device_id": devID,
"conn": ctx.ID,
"ip": ctx.GetPeerIP(),
"want_ip": mc.GetPeerIP(),
})
} else {
h.log.Warn("orphan terminal sub-conn: device=%s conn=%d isPTY=%v",
devID, ctx.ID, isPTY)
}
ctx.Close()
return
}
if isPTY {
if err := h.srv.Send(ctx, protocol.BuildTerminalResize(80, 24)); err != nil {
h.log.Error("initial resize send failed: conn=%d: %v", ctx.ID, err)
}
}
if err := h.srv.Send(ctx, []byte{protocol.CommandNext}); err != nil {
h.log.Error("COMMAND_NEXT send failed on terminal: conn=%d: %v", ctx.ID, err)
}
h.log.Info("terminal bound: device=%s conn=%d isPTY=%v", devID, ctx.ID, isPTY)
}
// handleBitmapInfo is the first packet on a freshly-arrived screen
// sub-connection. Packet layout (after the command byte at data[0]):
//
// [BITMAPINFOHEADER:40][clientID:8 uint64 LE][dlgID:8 uint64 LE][...]
//
// So clientID lives at data[41..49] and dlgID at data[49..57]. We use
// clientID (= MasterID) to bind this sub-context to its parent device.
func (h *MyHandler) handleBitmapInfo(ctx *connection.Context, data []byte) {
if len(data) < 49 {
h.log.Warn("TOKEN_BITMAPINFO from conn %d too short (%d bytes)", ctx.ID, len(data))
return
}
clientID := uint64(data[41]) | uint64(data[42])<<8 | uint64(data[43])<<16 | uint64(data[44])<<24 |
uint64(data[45])<<32 | uint64(data[46])<<40 | uint64(data[47])<<48 | uint64(data[48])<<56
deviceID := strconv.FormatUint(clientID, 10)
if !h.hub.BindScreenConn(deviceID, ctx) {
// A failed bind is either a plain orphan (device not registered, or it
// just went offline) or a hijack attempt — a sub-conn claiming a
// MasterID from a different source IP than the device's login conn.
// The latter is a security signal worth surfacing distinctly.
if mc := h.hub.MainConn(deviceID); mc != nil && mc.GetPeerIP() != ctx.GetPeerIP() {
h.log.AuditWarn("device.subconn.foreign_ip", map[string]any{
"kind": "screen",
"device_id": deviceID,
"conn": ctx.ID,
"ip": ctx.GetPeerIP(),
"want_ip": mc.GetPeerIP(),
})
} else {
h.log.Warn("orphan screen sub-conn %d for unknown device %s; closing", ctx.ID, deviceID)
}
ctx.Close()
return
}
// BITMAPINFOHEADER starts at data[1]. biWidth at offset 4, biHeight at
// offset 8 (both int32 LE). biHeight may be negative for top-down DIBs.
width := int(int32(binary.LittleEndian.Uint32(data[5:9])))
height := int(int32(binary.LittleEndian.Uint32(data[9:13])))
if height < 0 {
height = -height
}
h.log.Info("screen sub-conn bound: conn=%d device=%s resolution=%dx%d",
ctx.ID, deviceID, width, height)
h.hub.PublishResolution(deviceID, width, height)
// Notify the client its "dialog is open" so it stops blocking in
// Manager::WaitForDialogOpen (client/Manager.cpp:259). Without this
// the client waits a full 8 s timeout before it begins streaming
// real H264 frames via TOKEN_NEXTSCREEN. 32-byte packet matches the
// C++ CScreenSpyDlg::SendNext layout:
// [0]=COMMAND_NEXT [1..9]=dlgID uint64 [9..13]=capabilities uint32
// [13..17]=scrollInterval int32 [17..32]=zero reserved
// We don't need scroll-detect / a real dlgID, so leave them zero.
nextCmd := make([]byte, 32)
nextCmd[0] = protocol.CommandNext
if err := h.srv.Send(ctx, nextCmd); err != nil {
h.log.Error("COMMAND_NEXT send failed for conn=%d: %v", ctx.ID, err)
}
}
// handleScreenFrame relays one TOKEN_FIRSTSCREEN / TOKEN_NEXTSCREEN packet
// to all browsers watching this device. The on-the-wire packet starts with
// the token byte then a small fixed header (algorithm, cursor pos, cursor
// index) before the video payload (H.264 Annex B or AV1 OBU). The browser-
// facing WS packet uses the C++-compatible layout:
// [deviceID:4 LE][frameType:1][dataLen:4 LE][Video:N].
//
// alwaysKey=true is used for TOKEN_FIRSTSCREEN (always IDR by construction);
// TOKEN_NEXTSCREEN keyframe detection is delegated to protocol.IsAnyKeyframe
// which sniffs the codec from the first byte (0x00 → H.264, else AV1).
func (h *MyHandler) handleScreenFrame(ctx *connection.Context, data []byte, alwaysKey bool) {
deviceID := h.hub.ScreenDeviceID(ctx)
if deviceID == "" {
return // not a bound screen sub-conn — drop
}
// data[0] is the token; the 11-byte header sits at data[1..12].
const skip = 1 + protocol.ScreenFrameHeaderLen
if len(data) <= skip {
return
}
// Cursor index lives at the last byte of the small per-frame header
// (offset 1 + 1 + 8 = 10). Publish before the heavy frame work so the
// browser sees cursor updates even if we end up dropping frames later.
h.hub.PublishCursor(deviceID, data[10])
video := data[skip:]
// 按首字节嗅探 H.264 / AV1,分发到对应的 keyframe 探测器。浏览器侧用同样方式
// 决定 VideoDecoder codec string,因此 server 不必感知客户端实际编码器。
isKey := alwaysKey || protocol.IsAnyKeyframe(video)
// Build the WS packet exactly as the C++ ScreenSpyDlg does — the front-end
// decoder reads these offsets directly.
id64, _ := strconv.ParseUint(deviceID, 10, 64)
idLow := uint32(id64)
frameType := byte(0)
if isKey {
frameType = 1
}
dataLen := uint32(len(video))
packet := make([]byte, 9+len(video))
binary.LittleEndian.PutUint32(packet[0:4], idLow)
packet[4] = frameType
binary.LittleEndian.PutUint32(packet[5:9], dataLen)
copy(packet[9:], video)
h.hub.PublishScreenFrame(deviceID, packet, isKey)
}
// handleClipboardText handles TOKEN_CLIPBOARD_TEXT (118): the device replies to
// a COMMAND_SCREEN_GET_CLIPBOARD request with its clipboard text as
// [118][UTF-8 text][NUL]. data[0] is the token; the payload is data[1:].
func (h *MyHandler) handleClipboardText(ctx *connection.Context, data []byte) {
deviceID := h.hub.ScreenDeviceID(ctx)
if deviceID == "" {
return // not a bound screen sub-conn — drop
}
text := strings.TrimRight(string(data[1:]), "\x00")
h.hub.PublishClipboardText(deviceID, text)
}
// handleLogin handles client login (TOKEN_LOGIN = 102)
func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
info, err := protocol.ParseLoginInfo(data)
if err != nil {
h.log.Error("Failed to parse login info from client %d: %v", ctx.ID, err)
return
}
// The device's unique ID lives in reserved field 16 (RES_CLIENT_ID) as a
// decimal string of a uint64 — the same number the device later puts at
// offset 41 of TOKEN_BITMAPINFO. Using szMasterID here is WRONG: it is a
// compile-time MASTER_HASH constant shared by every binary built from
// the same source, so all clients would collide in the hub.
clientID := info.GetReservedField(protocol.ResFieldClientID)
if clientID == "" || clientID == "0" {
// Legacy fallback (very old clients that don't fill RES_CLIENT_ID).
// MasterID is still preferable to a per-connection number because it
// at least stays stable across reconnects of the same binary.
clientID = info.MasterID
}
if clientID == "" {
clientID = fmt.Sprintf("conn-%d", ctx.ID)
}
// Store client info
reserved := info.ParseReserved()
clientInfo := connection.ClientInfo{
ClientID: clientID,
ComputerName: info.PCName,
OS: info.OsVerInfo,
Version: info.ModuleVersion,
HasCamera: info.WebCamExist,
InstallTime: info.StartTime,
}
// Parse additional info from reserved field
if len(reserved) > protocol.ResFieldClientType {
clientInfo.ClientType = info.GetReservedField(protocol.ResFieldClientType)
}
if len(reserved) > 2 {
clientInfo.CPU = info.GetReservedField(2)
}
if len(reserved) > protocol.ResFieldFilePath {
clientInfo.FilePath = info.GetReservedField(protocol.ResFieldFilePath)
}
if len(reserved) > protocol.ResFieldClientPubIP {
clientInfo.IP = info.GetReservedField(protocol.ResFieldClientPubIP)
}
ctx.SetInfo(clientInfo)
ctx.IsLoggedIn.Store(true)
h.log.ClientEvent("online", ctx.ID, ctx.GetPeerIP(),
"clientID", clientID,
"computer", info.PCName,
"os", info.OsVerInfo,
"version", info.ModuleVersion,
"path", clientInfo.FilePath,
)
// PCName carries "ComputerName/Group"; ModuleVersion carries "Version-Capability".
// strings.Cut returns the full string as the head when the separator is
// absent, which gives us the natural "no group / no capability" fallback.
name, group, _ := strings.Cut(info.PCName, "/")
version, capability, _ := strings.Cut(info.ModuleVersion, "-")
// Client-reported geo string (RES_CLIENT_LOC).
location := ""
if len(reserved) > 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)
}
// Sign BEFORE registering in the hub so a quota-rejected device never
// appears in the web device list, even briefly. If signing fails we still
// send CMD_MASTERSETTING (zeroed signature, wire protocol stays clean) and
// then close the connection — no hub registration happens at all.
sigErr := h.sendMasterSetting(ctx, info.StartTime, clientID)
if sigErr != nil {
// Any sign error means no valid signature was issued — close without
// registering. Covers: quota exceeded (403), anonymous IP rate limit
// (429), and transient errors on a brand-new device with no cache.
// Existing devices reconnecting hit the fresh-cache path in Sign() and
// return ("sig", nil), so they are never rejected here.
h.log.Warn("sign failed for clientID=%s (%v) — closing connection", clientID, sigErr)
go func() {
time.Sleep(50 * time.Millisecond) // let CMD_MASTERSETTING flush
ctx.Close()
}()
return
}
// Signing succeeded: register with hub so the web side can list this
// device. Sub-connections (screen / terminal etc.) reuse the MasterID and
// will overwrite this entry harmlessly, but only the main login carries
// enough info to be useful here.
h.hub.Register(&hub.Device{
ID: clientID,
Name: name,
Group: group,
Version: version,
Capability: capability,
OS: info.OsVerInfo,
CPU: clientInfo.CPU,
FilePath: clientInfo.FilePath,
InstallTime: info.StartTime,
Location: location,
Resolution: resolution,
PeerIP: ctx.GetPeerIP(),
PublicIP: clientInfo.IP,
ConnectedAt: time.Now(),
}, ctx)
}
// sendMasterSetting builds the 1001-byte CMD_MASTERSETTING reply and ships it
// down the main TCP connection. Most fields stay zeroed — only Signature
// matters today. The signer is one of:
// - LocalSigner: HMAC directly with master key (operator's own deployment)
// - RemoteSigner: HTTPS POST to operator's License Server (customer deployment)
// - NoOpSigner: returns empty signature (free tier; client refuses screen/file ops)
//
// Returns the signer error (not the send error) so callers can distinguish
// quota-exceeded rejections from transient failures and act accordingly.
// The packet is always sent — even on error — so the wire protocol stays clean.
func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, clientID string) error {
buf := make([]byte, 1+protocol.MasterSettingsSize)
buf[0] = protocol.CmdMasterSetting
// ReportInterval (int32 LE at struct offset 0, +1 for the cmd byte).
// Sending 0 makes the client drop the active-window field of its
// heartbeat, which kills the web UI's live activeWindow updates.
binary.LittleEndian.PutUint32(
buf[1:5],
uint32(protocol.DefaultReportIntervalSec))
sig, sigErr := h.signer.Sign(startTime, clientID)
if sigErr != nil {
h.log.Error("signer (%s) failed for clientID=%s: %v — sending zeroed signature",
h.signer.Mode(), clientID, sigErr)
} else if sig == "" {
// NoOpSigner path, or LocalSigner with empty master key — same effect.
// Log only once per process via the startup banner; don't spam here.
} else {
const sigOffset = 1 + protocol.MasterSettingsOffSignature
copy(buf[sigOffset:sigOffset+protocol.MasterSettingsSignatureLen], []byte(sig))
}
if err := h.srv.Send(ctx, buf); err != nil {
h.log.Error("CMD_MASTERSETTING send failed for conn=%d: %v", ctx.ID, err)
}
return sigErr
}
// handleAuth handles authorization request (TOKEN_AUTH = 100)
func (h *MyHandler) handleAuth(ctx *connection.Context, data []byte) {
// Per-IP throttle on TOKEN_AUTH: rate-limit online passcode-guessing
// attempts against the device authorization protocol. On exceed we answer
// with a normal "failed" response (resp[0]=0) so the client gets a
// definitive answer instead of hanging, while a brute-forcer is capped.
ip := ctx.GetPeerIP()
if !h.loginLimit.Allow(ip) {
h.log.AuditWarn("device.auth.throttled", map[string]any{
"conn": ctx.ID,
"ip": ip,
"reason": "rate_limited",
})
resp := h.auth.BuildResponse(&auth.AuthResult{Valid: false, Message: "尝试过于频繁,请稍后再试"})
if err := h.srv.Send(ctx, resp); err != nil {
h.log.Error("Failed to send rate-limit auth response to client %d: %v", ctx.ID, err)
}
return
}
result := h.auth.Authenticate(data)
info := ctx.GetInfo()
authType := "V1"
if result.IsV2 {
authType = "V2"
}
// Device auth records are audit events (not just debug logs): a flood of
// failures against one address is how passcode guessing looks. The
// submitted passcode is NEVER logged — only its presence and length.
fields := map[string]any{
"conn": ctx.ID,
"ip": ip,
"auth_version": authType,
"device_id": info.ClientID,
"computer": info.ComputerName,
"sn": result.SN,
"passcode_len": len(result.Passcode),
"has_passcode": result.Passcode != "",
}
if result.Valid {
h.log.Audit("device.auth.success", fields)
} else {
h.log.AuditWarn("device.auth.failure", fields)
}
// Build and send response
resp := h.auth.BuildResponse(result)
if err := h.srv.Send(ctx, resp); err != nil {
h.log.Error("Failed to send auth response to client %d: %v", ctx.ID, err)
}
}
// handleHeartbeat handles heartbeat from client (TOKEN_HEARTBEAT = 101)
// Heartbeat structure (after command byte):
// - offset 0: Time (8 bytes, uint64)
// - offset 8: ActiveWnd (512 bytes)
// - offset 520: Ping (4 bytes, int)
// - offset 524: HasSoftware (4 bytes, int)
// - offset 528: SN (20 bytes)
// - offset 548: Passcode (44 bytes)
// - offset 592: PwdHmac (8 bytes, uint64)
//
// HeartbeatACK structure:
// - offset 0: Time (8 bytes, uint64)
// - offset 8: Authorized (1 byte, char)
// - offset 9: Reserved (23 bytes)
func (h *MyHandler) handleHeartbeat(ctx *connection.Context, data []byte) {
// Parse Time from heartbeat request (offset 1, 8 bytes)
var hbTime uint64
if len(data) >= 9 {
hbTime = uint64(data[1]) | uint64(data[2])<<8 | uint64(data[3])<<16 | uint64(data[4])<<24 |
uint64(data[5])<<32 | uint64(data[6])<<40 | uint64(data[7])<<48 | uint64(data[8])<<56
}
// Forward live fields (ActiveWnd + Ping) to the hub so the web UI can
// display current latency and foreground window per device. Skip until
// login has happened — the hub is keyed by MasterID, which only exists
// post-login.
if info := ctx.GetInfo(); info.ClientID != "" {
var rtt int32
var activeWindow 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 {
activeWindow = protocol.DecodeClientString(
data[9:9+512],
h.hub.Capability(info.ClientID),
info.ClientType,
)
}
// Ping at data[521..525] is a little-endian int32.
if len(data) >= 525 {
rtt = int32(uint32(data[521]) | uint32(data[522])<<8 |
uint32(data[523])<<16 | uint32(data[524])<<24)
}
h.hub.UpdateLive(info.ClientID, int(rtt), activeWindow)
}
// Authenticate heartbeat if it contains authorization info
// data[1:] skips the command byte to get the raw Heartbeat structure
var authorized byte = 0
if len(data) > 1 {
authResult := h.auth.AuthenticateHeartbeat(data[1:])
if authResult.Authorized {
authorized = 2 // Auth by admin
// Log authorization success (only log once per connection to avoid spam)
if !ctx.IsAuthorized.Load() {
ctx.IsAuthorized.Store(true)
info := ctx.GetInfo()
version := "V1"
if authResult.IsV2 {
version = "V2"
}
// Same redaction rule as TOKEN_AUTH: never log the passcode
// or the password HMAC, only that they were supplied.
h.log.Audit("device.auth.heartbeat.success", map[string]any{
"conn": ctx.ID,
"ip": ctx.GetPeerIP(),
"auth_version": version,
"device_id": info.ClientID,
"computer": info.ComputerName,
"sn": authResult.SN,
"passcode_len": len(authResult.Passcode),
"has_pwd_hmac": authResult.PwdHmac != 0,
})
}
}
}
// Build HeartbeatACK response: CMD_HEARTBEAT_ACK(1) + HeartbeatACK(32)
resp := make([]byte, 33)
resp[0] = protocol.CommandHeartbeat // CMD_HEARTBEAT_ACK = 216
// Time at offset 1 (8 bytes, little-endian)
resp[1] = byte(hbTime)
resp[2] = byte(hbTime >> 8)
resp[3] = byte(hbTime >> 16)
resp[4] = byte(hbTime >> 24)
resp[5] = byte(hbTime >> 32)
resp[6] = byte(hbTime >> 40)
resp[7] = byte(hbTime >> 48)
resp[8] = byte(hbTime >> 56)
// Authorized at offset 9 (1 byte)
resp[9] = authorized
// Reserved[23] at offset 10 is already zero
if err := h.srv.Send(ctx, resp); err != nil {
h.log.Error("Failed to send heartbeat ACK to client %d: %v", ctx.ID, err)
}
}
// parsePorts parses a semicolon-separated port string and returns port numbers
func parsePorts(portStr string) ([]int, error) {
var ports []int
parts := strings.Split(portStr, ";")
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
port, err := strconv.Atoi(p)
if err != nil {
return nil, fmt.Errorf("invalid port %q: %v", p, err)
}
if port < 1 || port > 65535 {
return nil, fmt.Errorf("port %d out of range (1-65535)", port)
}
ports = append(ports, port)
}
if len(ports) == 0 {
return nil, fmt.Errorf("no valid ports specified")
}
return ports, nil
}
// splitCSV splits a comma-separated env-var value into trimmed, non-empty
// entries. Returns nil for an empty input so callers can keep the natural
// "no value → no restriction" semantics with a single nil check.
func splitCSV(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
if len(out) == 0 {
return nil
}
return out
}
// normalizeBasePath turns a YAMA_WEB_BASE_PATH value into a leading-slash,
// no-trailing-slash prefix ("" for root). "" (or "/") means the web UI is
// served at the domain root (方案A); "/remoter" means it is served under that
// prefix (方案B), with a reverse proxy routing the subpath to this process.
func normalizeBasePath(s string) string {
s = strings.TrimSpace(s)
s = strings.Trim(s, "/")
if s == "" {
return ""
}
return "/" + s
}
// envInt parses an integer env var, returning def when unset or malformed.
func envInt(name string, def int) int {
v := strings.TrimSpace(os.Getenv(name))
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
return def
}
return n
}
// runIssueToken handles the "issue-token" subcommand. It mints a customer JWT
// signed with the operator's RSA private key and prints it to stdout.
//
// The private key path defaults to $YAMA_LICENSE_PRIVATE_KEY so that on the
// authorization server — where env vars are already configured — only the
// per-customer fields need to be specified:
//
// server issue-token -sub customer-acme [-tier paid|trial] [-devices 10] [-ttl 8760h]
//
// If neither -key nor $YAMA_LICENSE_PRIVATE_KEY is set, the command exits
// with a clear error rather than silently using a wrong default.
func runIssueToken(args []string) {
fs := flag.NewFlagSet("issue-token", flag.ExitOnError)
// Default from env so the operator doesn't need to retype the path.
keyPath := fs.String("key", os.Getenv(licensing.EnvLicensePrivKeyPath),
"Path to RSA private key PEM (PKCS#1 or PKCS#8); default: $"+licensing.EnvLicensePrivKeyPath)
sub := fs.String("sub", "", "Customer identifier — unique string, e.g. \"customer-acme\" (required)")
tier := fs.String("tier", licensing.TierPaid, "License tier: \"paid\" or \"trial\"")
devices := fs.Int("devices", 10, "Max concurrent managed devices")
ttl := fs.Duration("ttl", 365*24*time.Hour, "Token validity period, e.g. 8760h (1 year)")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: %s issue-token [flags]\n\nFlags:\n", os.Args[0])
fs.PrintDefaults()
fmt.Fprintf(os.Stderr, "\nExample:\n %s issue-token -sub customer-acme -tier paid -devices 20 -ttl 8760h\n", os.Args[0])
}
_ = fs.Parse(args)
var errs []string
if *keyPath == "" {
errs = append(errs, fmt.Sprintf("-key or $%s is required", licensing.EnvLicensePrivKeyPath))
}
if *sub == "" {
errs = append(errs, "-sub is required")
}
if len(errs) > 0 {
for _, e := range errs {
fmt.Fprintln(os.Stderr, "error:", e)
}
fmt.Fprintln(os.Stderr)
fs.Usage()
os.Exit(1)
}
privKey, err := licensing.LoadRSAPrivateKey(*keyPath)
if err != nil {
fmt.Fprintf(os.Stderr, "error loading private key: %v\n", err)
os.Exit(1)
}
token, err := licensing.Issue(privKey, *sub, *tier, *devices, *ttl)
if err != nil {
fmt.Fprintf(os.Stderr, "error issuing token: %v\n", err)
os.Exit(1)
}
fmt.Println(token)
}
func main() {
// Subcommand dispatch: "server issue-token ..." runs the token-minting
// helper and exits without starting the server.
if len(os.Args) > 1 && os.Args[1] == "issue-token" {
runIssueToken(os.Args[2:])
return
}
// Parse command line flags
portStr := flag.String("port", "6543", "Server listen ports (semicolon-separated, e.g. 6543;6544;6545)")
flag.StringVar(portStr, "p", "6543", "Server listen ports (shorthand)")
httpPort := flag.Int("http-port", 8080, "HTTP server port for web UI (0 to disable)")
noConsole := flag.Bool("no-console", false, "Disable console output (for daemon mode)")
flag.Parse()
// Parse ports
ports, err := parsePorts(*portStr)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Create logger with file output
logCfg := logger.DefaultConfig()
logCfg.Level = logger.LevelDebug
logCfg.Console = !*noConsole
logCfg.File = "logs/server.log"
logCfg.MaxSize = 100 // 100 MB
logCfg.MaxBackups = 10 // keep 10 old files
logCfg.MaxAge = 30 // 30 days
logCfg.Compress = true
// Security audit trail: a separate JSON-lines file so login attempts,
// authz denials, remote-control sessions and admin mutations are not
// buried in debug output and can be retained / shipped independently.
// YAMA_AUDIT_LOG overrides the path; "off"/"none"/"0"/"-" disables the
// separate file (records still reach server.log).
auditFile := strings.TrimSpace(os.Getenv("YAMA_AUDIT_LOG"))
if auditFile == "" {
auditFile = "logs/audit.log"
}
switch strings.ToLower(auditFile) {
case "off", "none", "0", "-":
auditFile = ""
}
logCfg.AuditFile = auditFile
logCfg.AuditMaxSize = 100
logCfg.AuditMaxBackups = 20
logCfg.AuditMaxAge = 90
logCfg.AuditCompress = true
log := logger.New(logCfg)
log.Info("====== Copyright (c) 2026 simpleremoter.com. All rights resvered. ======")
if auditFile != "" {
log.Info("Security audit log: %s", auditFile)
}
// Track env vars where we fell back to a built-in default. Printed once
// at the end of startup so the operator sees what's in effect — vars the
// operator explicitly set are NOT listed (they already know their value).
type defaultedEnv struct{ name, value string }
var defaultsUsed []defaultedEnv
rememberDefault := func(name, value string) {
defaultsUsed = append(defaultsUsed, defaultedEnv{name, value})
}
// Create auth config
authCfg := auth.DefaultConfig()
// PwdHash can be set from environment or config file
authCfg.PwdHash = os.Getenv("YAMA_PWDHASH")
if authCfg.PwdHash == "" {
// Default placeholder - should be configured in production
authCfg.PwdHash = "61f04dd637a74ee34493fc1025de2c131022536da751c29e3ff4e9024d8eec43"
rememberDefault("YAMA_PWDHASH", authCfg.PwdHash)
}
authCfg.SuperPass = os.Getenv("YAMA_PWD")
// Create authenticator (shared by all servers)
authenticator := auth.New(authCfg)
// Shared device registry — every TCP handler reports devices into it,
// the HTTP server reads from it.
deviceHub := hub.New()
// Persistent device history: every device that has ever logged in, so the
// web UI can keep showing it after it goes offline. Same working-directory
// convention as users.json. Retention is capped by count (newest last_seen
// wins) so the file and the device_list payload stay bounded.
devicesFile := os.Getenv("YAMA_DEVICES_FILE")
if devicesFile == "" {
devicesFile = "devices.json"
rememberDefault("YAMA_DEVICES_FILE", devicesFile)
}
devicesMax := 1000
if v := os.Getenv("YAMA_DEVICES_MAX"); v != "" {
if n, convErr := strconv.Atoi(v); convErr == nil && n > 0 {
devicesMax = n
} else {
log.Warn("Invalid YAMA_DEVICES_MAX=%q (want a positive integer); using %d", v, devicesMax)
}
}
deviceHistory, histErr := devicehistory.Open(devicesFile, devicesMax,
log.WithPrefix("History"))
if histErr != nil {
// Keep the store: live recording still works, and the next flush
// replaces the unreadable file. Losing old history is preferable to
// refusing to start the server.
log.Warn("Failed to load %s: %v (starting with empty history)", devicesFile, histErr)
}
deviceHub.Subscribe(deviceHistory)
log.Info("Device history: %s (max %d)", devicesFile, devicesMax)
// Build the CMD_MASTERSETTING signer based on env vars:
// - YAMA_SIGN_PASSWORD set → LocalSigner (operator's own deployment;
// HMAC master key lives here)
// - YAMA_LICENSE_DISABLED=1 → NoOpSigner (explicit opt-out; dev / offline)
// - YAMA_LICENSE_TOKEN set → RemoteSigner (paid customer; talks to
// operator's License Server with JWT)
// - neither of the above → RemoteSigner (anonymous trial; default
// URL, no JWT, cap FreeMaxDevices)
signer, mode, err := licensing.NewFromEnv(log)
if err != nil {
log.Fatal("Failed to initialize signer: %v", err)
}
// signer.Close() is called explicitly after the License Server HTTP is
// drained at shutdown — sequencing matters because an in-flight
// handleSign on the HTTP path needs a live signer to complete.
switch mode {
case licensing.ModeLocal:
log.Info("Signer mode: LOCAL (operator deployment, master key held in-process)")
case licensing.ModeRemote:
licServer := os.Getenv(licensing.EnvLicenseServer)
if licServer == "" {
licServer = licensing.DefaultLicenseServerURL
}
log.Info("Signer mode: REMOTE (paid customer, license server=%s)", licServer)
case licensing.ModeTrial:
licServer := os.Getenv(licensing.EnvLicenseServer)
if licServer == "" {
licServer = licensing.DefaultLicenseServerURL
}
log.Info("Signer mode: TRIAL (anonymous试用模式, license server=%s, 最多 %d 台受管设备; 设置 %s 解锁付费配额)",
licServer, licensing.FreeMaxDevices, licensing.EnvLicenseToken)
case licensing.ModeNoOp:
log.Warn("Signer mode: NOOP (licensing disabled via %s=1; client refuses screen/file features)",
licensing.EnvLicenseDisabled)
}
// If the operator also wants this LocalSigner deployment to serve as
// License Server for RemoteSigner customers, YAMA_LICENSE_PUBLIC_KEY +
// YAMA_LICENSE_HTTP_ADDR enable it. Off by default.
licSrv, licAddr, err := licensing.LicenseServerFromEnv(signer, log)
if err != nil {
log.Fatal("Failed to initialize License Server: %v", err)
}
// Web user authenticator. Bootstrap admin from env var YAMA_WEB_ADMIN_PASS;
// if unset, fall back to YAMA_PWD (same secret the TCP authorization uses)
// so a single password env var is enough to bring up the whole stack.
// If neither is set we use a hard-coded "admin" default so the web UI is
// usable out of the box — the startup banner surfaces this so operators
// know to override it in any non-dev deployment.
const defaultWebAdminPass = "admin"
webAuth := wsauth.New()
adminPass := os.Getenv("YAMA_WEB_ADMIN_PASS")
if adminPass == "" {
adminPass = os.Getenv("YAMA_PWD")
}
usingDefaultWebPass := false
if adminPass == "" {
adminPass = defaultWebAdminPass
rememberDefault("YAMA_WEB_ADMIN_PASS", defaultWebAdminPass)
usingDefaultWebPass = true
// Loud warn (in addition to the startup banner): the binary is now
// accepting admin/admin on the public web UI. Anyone running this
// without overriding the env var in prod needs to see it in red.
log.Warn("⚠ YAMA_WEB_ADMIN_PASS / YAMA_PWD 均未设置,Web 管理使用默认密码 admin/admin — 生产环境务必覆盖")
}
webAuth.AddAdminFromPlainPassword("admin", adminPass)
log.Info("Web admin user configured")
// 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"
rememberDefault("YAMA_USERS_FILE", usersFile)
}
if err := webAuth.SetUsersFile(usersFile); err != nil {
log.Warn("Failed to load %s: %v (continuing with admin only)", usersFile, err)
}
// TCP authorization (TOKEN_AUTH) brute-force throttle. Keyed by source IP
// (the real TCP peer — there is no X-Forwarded-For on the device port).
// Generous enough that a NAT office whose devices all re-auth at once is
// unaffected, tight enough to make online passcode guessing impractical.
authLoginLimit := wsauth.NewRateLimiter(120, time.Minute)
// Bounded-key hygiene: prune aged-out entries so the per-IP map cannot grow
// without bound. Runs for the process lifetime (a daemon goroutine; the
// process exits via the SIGINT/SIGTERM handler at the bottom of main).
go func() {
t := time.NewTicker(time.Minute)
defer t.Stop()
for range t.C {
authLoginLimit.Sweep()
}
}()
// Optional one-way TLS on the device port. Both vars must be set to enable;
// a cert without its key (or vice versa) is treated as disabled with a
// warning. The cert is loaded (and errors surfaced) inside Server.Start().
tlsCert := os.Getenv("YAMA_TLS_CERT")
tlsKey := os.Getenv("YAMA_TLS_KEY")
if tlsCert != "" && tlsKey != "" {
log.Info("TLS enabled for device port (one-way): cert=%s", tlsCert)
} else if tlsCert != "" || tlsKey != "" {
log.Warn("TLS disabled: both YAMA_TLS_CERT and YAMA_TLS_KEY must be set")
} else {
log.Info("TLS disabled (YAMA_TLS_CERT/YAMA_TLS_KEY not set) — device port is plaintext")
}
// Optional ConnAuth HMAC verification for sub-connections (screen/terminal/
// file). The shared key must match what the client signs with — either the
// client's built-in HMAC key (see client license.cpp / license_unix.cpp) or
// a custom key once the client is taught to read one. Unset means legacy
// behavior: accept any sub-connection and rely on the source-IP check in
// hub.BindScreenConn alone.
var connAuthKey []byte
if v := strings.TrimSpace(os.Getenv("YAMA_CONN_AUTH_KEY")); v != "" {
connAuthKey = []byte(v)
log.Info("ConnAuth HMAC verification enabled for device sub-connections (YAMA_CONN_AUTH_KEY)")
}
// Create servers for each port
var servers []*server.Server
for _, port := range ports {
config := server.DefaultConfig()
config.Port = port
config.MaxConnections = 9999
config.PerIPMaxConnections = envInt("YAMA_PERIP_MAX_CONNS", config.PerIPMaxConnections)
config.PerIPConnRateLimit = envInt("YAMA_PERIP_CONN_RATE", config.PerIPConnRateLimit)
config.TLSCertFile = tlsCert
config.TLSKeyFile = tlsKey
srv := server.New(config)
srv.SetLogger(log.WithPrefix(fmt.Sprintf("Server:%d", port)))
// Create handler for this server
handler := &MyHandler{
log: log.WithPrefix(fmt.Sprintf("Handler:%d", port)),
auth: authenticator,
srv: srv,
hub: deviceHub,
signer: signer,
loginLimit: authLoginLimit,
connAuthKey: connAuthKey,
}
srv.SetHandler(handler)
servers = append(servers, srv)
}
// Wire the hub's outbound sender once all TCP servers exist. Any server's
// Send method will do — the per-connection encoder uses ctx-local state
// and is independent of which server originally accepted the connection.
if len(servers) > 0 {
s := servers[0]
deviceHub.SetSender(func(ctx *connection.Context, data []byte) error {
return s.Send(ctx, data)
})
}
// Start all TCP servers
for _, srv := range servers {
if err := srv.Start(); err != nil {
log.Fatal("Failed to start server: %v", err)
}
}
// Web-UI hardening knobs for public-HTTPS deployment.
//
// YAMA_WEB_ALLOWED_ORIGINS: comma-separated Origin allowlist (e.g.
// "https://yama.example.com,https://yama-mobile.example.com").
// Empty (default) → only same-origin WS upgrades accepted, which
// is correct when the web UI and WS endpoint share a host.
//
// Login rate limits are hard-coded at sensible defaults for the
// small-user web UI: 10 attempts / minute per IP, 5 / 15 min per
// username. The handler also injects a 250 ms delay on every failure
// so online brute force is impractical even within budget.
allowedOrigins := splitCSV(os.Getenv("YAMA_WEB_ALLOWED_ORIGINS"))
trustProxy := os.Getenv("YAMA_WEB_TRUST_PROXY") == "1"
if trustProxy {
log.Info("Trusting X-Forwarded-For for client IP — make sure a reverse proxy is in front")
}
basePath := normalizeBasePath(os.Getenv("YAMA_WEB_BASE_PATH"))
if basePath != "" {
log.Info("Serving web UI under base path %q (subpath deployment)", basePath)
}
// White-label title: when the web UI is mounted under another domain
// (basePath != ""), replace the SimpleRemoter branding with this name.
// YAMA_WEB_BRAND_NAME is human-editable; empty falls back to the base
// path's last segment (e.g. "/remoter" → "Remoter").
brandName := strings.TrimSpace(os.Getenv("YAMA_WEB_BRAND_NAME"))
if brandName != "" {
log.Info("White-label web UI title %q (YAMA_WEB_BRAND_NAME)", brandName)
}
// SSO: an optional detector bridges an already-authenticated portal
// session into a YAMA web session via /sso — but only for portal
// administrators. Any other portal role (viewer, unknown) still has to
// authenticate against YAMA's own login form. The built-in JWT detector is
// enabled by YAMA_SSO_JWT_SECRET (the shared HS256 secret); a different
// portal can plug in its own web.SSODetector by assigning webCfg.SSO.
var ssoDetector web.SSODetector
if secret := os.Getenv("YAMA_SSO_JWT_SECRET"); secret != "" {
ssoDetector = web.NewJWTDetector(web.JWTDetectorConfig{Secret: secret})
log.Info("SSO enabled: bridging portal admins into web sessions via /sso")
} else {
log.Info("SSO disabled (YAMA_SSO_JWT_SECRET not set) — web login only")
}
webCfg := web.Config{
AllowedOrigins: allowedOrigins,
LoginIPLimit: wsauth.NewRateLimiter(10, time.Minute),
LoginUserLimit: wsauth.NewRateLimiter(5, 15*time.Minute),
TrustForwardedFor: trustProxy,
BasePath: basePath,
BrandName: brandName,
SSO: ssoDetector,
}
// Start HTTP server for web UI. Hub gives it read-only access to the
// device registry; the authenticator owns user accounts and session tokens.
httpSrv := web.New(*httpPort, log.WithPrefix("Web"), deviceHub, webAuth).
WithDeviceHistory(deviceHistory).
WithConfig(webCfg)
if err := httpSrv.Start(); err != nil {
log.Fatal("Failed to start HTTP server: %v", err)
}
// Optionally serve the License Server HTTP endpoints in this process.
// Only active when the operator set YAMA_LICENSE_PUBLIC_KEY +
// YAMA_LICENSE_HTTP_ADDR. We serve plain HTTP — operator should put
// nginx/Caddy in front for TLS. (RemoteSigner customers connect to
// the public URL configured via YAMA_LICENSE_SERVER on their side.)
//
// Timeouts cap Slowloris / FD-exhaustion attacks. Values are generous
// enough for a slow public link (TLS-terminating proxy in front, real
// customer round-trips at ~hundreds of ms) but tight enough that a
// trickle-byte attacker cannot pin a connection indefinitely.
var licenseHTTP *http.Server
if licSrv != nil {
licenseHTTP = &http.Server{
Addr: licAddr,
Handler: licSrv.Handler(),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 16 << 10,
}
go func() {
log.Info("License Server listening on %s (POST /license/sign, /license/heartbeat)", licAddr)
if err := licenseHTTP.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Error("License Server stopped: %v", err)
}
}()
}
log.Info("Server started on port(s): %v", ports)
if *httpPort != 0 {
log.Info("Web UI on http://localhost:%d/", *httpPort)
if usingDefaultWebPass {
log.Info("Default login: admin / %s (set YAMA_WEB_ADMIN_PASS to override)", defaultWebAdminPass)
}
}
if licenseHTTP != nil {
log.Info("License Server on http://%s/license/{sign,heartbeat}", licAddr)
}
if len(defaultsUsed) > 0 {
for _, d := range defaultsUsed {
log.Info("[!] Using built-in defaults (set the env var to override): %s = %s", d.name, d.value)
}
}
log.Info("Logs are written to: logs/server.log")
log.Info("Press Ctrl+C to stop...")
// Wait for interrupt signal
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
log.Info("\nShutting down...")
// Order matters: drain License Server HTTP first so no handleSign is
// mid-flight; THEN close the signer (which may release HTTP keepalives
// in RemoteSigner mode, or be a no-op for LocalSigner/NoOp).
if licenseHTTP != nil {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = licenseHTTP.Shutdown(shutdownCtx)
cancel()
}
if err := signer.Close(); err != nil {
log.Warn("signer Close: %v", err)
}
httpSrv.Stop()
for _, srv := range servers {
srv.Stop()
}
// Devices still connected at shutdown never fire OnDeviceOffline (their
// TCP handlers are stopped above), so stamp them now. Otherwise a device
// that had been online for days would show a stale "last seen" after the
// restart until it reconnects.
for _, d := range deviceHub.ListDevices() {
deviceHistory.OnDeviceOffline(d.ID)
}
// Last-chance flush: the debounce timer may hold the final offline
// timestamps from devices that dropped while we were shutting down.
if err := deviceHistory.Close(); err != nil {
log.Warn("device history flush: %v", err)
}
log.Info("Server stopped")
// Flush and release the rotated log handles. Without this the last audit
// records can sit in lumberjack's buffered writer on shutdown.
_ = log.Close()
}