Files
SimpleRemoter/server/go/cmd/main.go

762 lines
28 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 (
"encoding/binary"
"flag"
"fmt"
"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/hub"
"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
signPwd string // HMAC key for CMD_MASTERSETTING signatures (YAMA_SIGN_PASSWORD)
}
// 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.TokenKeyframe:
// Sent by the client only when frameID % m_GOP == 0; the client's
// DEFAULT_GOP is 0x7FFFFFFF (effectively infinite), so this token
// is essentially unused in practice. Treat as a no-op for now —
// IDRs always arrive in-band via TOKEN_NEXTSCREEN and we catch
// them via the H264 NAL scan in handleScreenFrame.
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.
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.
//
// The handshake includes an HMAC signature field. The reference server
// treats verification failures as soft (logs and still allows commands),
// and the signing primitive lives in a vendored component out of scope
// for this server, so we 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) {
// 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)
}
}
// 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) {
// No pending session — this is a stale sub-conn (e.g. browser
// gave up and closed term_close already). Drop it.
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) {
// Device not registered — main login hasn't happened (or device just
// went offline). Drop the orphan sub-conn rather than leak it.
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 H.264 NAL payload. The browser-facing WS packet uses
// the C++-compatible layout: [deviceID:4 LE][frameType:1][dataLen:4 LE][H264:N].
//
// alwaysKey=true is used for TOKEN_FIRSTSCREEN (always IDR by construction);
// TOKEN_NEXTSCREEN is keyframe iff the NAL stream contains a 5/7/8 unit.
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])
h264 := data[skip:]
isKey := alwaysKey || protocol.IsH264Keyframe(h264)
// 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(h264))
packet := make([]byte, 9+len(h264))
binary.LittleEndian.PutUint32(packet[0:4], idLow)
packet[4] = frameType
binary.LittleEndian.PutUint32(packet[5:9], dataLen)
copy(packet[9:], h264)
h.hub.PublishScreenFrame(deviceID, packet, isKey)
}
// 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)
}
// 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)
// Push CMD_MASTERSETTING with a signature over "StartTime|ClientID".
// The client's private FileUpload init verifies this before allowing
// screen / file operations — without it the binary aborts itself.
h.sendMasterSetting(ctx, info.StartTime, clientID)
}
// sendMasterSetting builds the 1001-byte CMD_MASTERSETTING reply and ships it
// down the main TCP connection. Most fields stay zeroed — only Signature
// matters today. If no signing password is configured, a zeroed signature is
// still sent (and logged once) so the client at least sees a well-formed
// packet; in that case the client's private library will refuse to start
// screen / file features and abort.
func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, clientID string) {
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))
if h.signPwd == "" {
h.log.Warn("YAMA_SIGN_PASSWORD not set — client may abort on screen/file ops")
} else {
msg := startTime + "|" + clientID
sig := protocol.SignMessage(h.signPwd, []byte(msg))
// Signature[64] lives at offset 508 of the struct, +1 for the cmd byte.
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)
}
}
// handleAuth handles authorization request (TOKEN_AUTH = 100)
func (h *MyHandler) handleAuth(ctx *connection.Context, data []byte) {
result := h.auth.Authenticate(data)
info := ctx.GetInfo()
authType := "V1"
if result.IsV2 {
authType = "V2"
}
if result.Valid {
h.log.Info("Auth %s success: clientID=%s computer=%s ip=%s sn=%s passcode=%s",
authType, info.ClientID, info.ComputerName, ctx.GetPeerIP(), result.SN, result.Passcode)
} else {
h.log.Warn("Auth %s failed: clientID=%s computer=%s ip=%s sn=%s passcode=%s",
authType, info.ClientID, info.ComputerName, ctx.GetPeerIP(), result.SN, result.Passcode)
}
// 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()
if authResult.IsV2 {
// V2 authorization
h.log.Info("Heartbeat auth V2 success: clientID=%s computer=%s ip=%s sn=%s passcode=%s",
info.ClientID, info.ComputerName, ctx.GetPeerIP(), authResult.SN, authResult.Passcode)
} else {
// V1 authorization
h.log.Info("Heartbeat auth V1 success: clientID=%s computer=%s ip=%s sn=%s passcode=%s pwdHmac=%d",
info.ClientID, info.ComputerName, ctx.GetPeerIP(), authResult.SN, authResult.Passcode, authResult.PwdHmac)
}
}
}
}
// 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
}
func main() {
// 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
log := logger.New(logCfg)
// 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"
}
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()
// HMAC key used to sign the per-login CMD_MASTERSETTING reply. The
// client verifies this signature before enabling its screen / file
// features and aborts the process on mismatch. Kept in an env var so
// the literal stays out of the binary; provision out-of-band and
// never commit it.
signPwd := os.Getenv("YAMA_SIGN_PASSWORD")
if signPwd == "" {
log.Warn("YAMA_SIGN_PASSWORD not set; clients will refuse screen/file ops")
}
// 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, no admin is registered and login will always fail —
// the user must define a password before browsers can log in.
webAuth := wsauth.New()
adminPass := os.Getenv("YAMA_WEB_ADMIN_PASS")
if adminPass == "" {
adminPass = os.Getenv("YAMA_PWD")
}
if adminPass != "" {
webAuth.AddAdminFromPlainPassword("admin", adminPass)
log.Info("Web admin user configured")
} else {
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
var servers []*server.Server
for _, port := range ports {
config := server.DefaultConfig()
config.Port = port
config.MaxConnections = 9999
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,
signPwd: signPwd,
}
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)
}
}
// 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)
if err := httpSrv.Start(); err != nil {
log.Fatal("Failed to start HTTP server: %v", err)
}
fmt.Printf("Server started on port(s): %v\n", ports)
if *httpPort != 0 {
fmt.Printf("Web UI on http://localhost:%d/\n", *httpPort)
}
fmt.Println("Logs are written to: logs/server.log")
fmt.Println("Press Ctrl+C to stop...")
// Wait for interrupt signal
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
fmt.Println("\nShutting down...")
httpSrv.Stop()
for _, srv := range servers {
srv.Stop()
}
fmt.Println("Server stopped")
}