648 lines
23 KiB
Go
648 lines
23 KiB
Go
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 screen sub-context mapping first — the connection
|
||
// may be a screen sub-conn (which has no ClientInfo) rather than a main
|
||
// login connection. UnbindScreenConn is a no-op if not tracked.
|
||
h.hub.UnbindScreenConn(ctx)
|
||
|
||
info := ctx.GetInfo()
|
||
if info.ClientID != "" {
|
||
h.log.ClientEvent("offline", ctx.ID, ctx.GetPeerIP(),
|
||
"clientID", info.ClientID,
|
||
"computer", info.ComputerName,
|
||
)
|
||
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
|
||
}
|
||
|
||
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.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, _ []byte) {
|
||
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)
|
||
}
|
||
}
|
||
|
||
// 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")
|
||
}
|