35 Commits
Author SHA1 Message Date
yuanyuanxianganddeepseek-flash f76cd79bb0 Fix: Renew expired licenses from today and report the actual expiry
Preset renewal stored its target date as the old expiry plus the entered
days, while the authorization actually issued to the client is always
today plus a day count. For a license that had already expired, the
preset date was therefore in the past: SendPendingRenewal clamped the
day count to one, the client received a one-day license starting today,
but the renewal tip and the logs still printed the stale preset date.
The result was a message such as "renewed until 2026-05-30" while the
license really ran until tomorrow.

Presetting a renewal now bases the new date on today when the license
has already expired, and on the original expiry date otherwise, which
matches the one-day-per-day extension admins expect for live licenses.
The renewal dialog and the success prompt state which base was used, so
the computed date is never ambiguous. Renewal tips and both logging
paths (heartbeat-driven SendPendingRenewal and client-requested
GenerateRenewalInfo) now print the date that was actually issued, which
also corrects legacy preset records whose stored date has already
passed.

Co-Authored-By: deepseek-flash
2026-10-19 15:15:49 +02:00
yuanyuanxianganddeepseek-flash 39c8daa9b0 Fix: Keep the audit log open across WebSocket reconnects
Hiding the browser tab while the audit log was open closed the socket,
because the visibility handler treats only the remote screen, terminal
and wall pages as streaming pages. Returning to the tab reconnected the
socket, and the onopen restore path took its catch-all branch, which
navigated to the device list. The audit log therefore vanished whenever
the browser was minimized.

The catch-all branch now leaves the active page alone when it is the
audit log, and only refreshes the device list in the background. Every
other page keeps the previous behaviour: the login page advances with a
cached token, and the terminal page falls back to the device list
because its session died with the socket. Audit results already rendered
stay on screen, so the admin can keep reading without re-querying.

Co-Authored-By: deepseek-flash
2026-10-19 10:39:36 +02:00
yuanyuanxianganddeepseek-flash cd4f3675fd Improve: Replace native browser prompts with themed in-app dialogs
Native alert() and confirm() render their buttons in the browser or OS
locale, so on a German system prompts such as the Clear Screen Wall
confirmation showed German labels that disagreed with the rest of the
web UI. Both are now served by a single themed modal that reuses the
logout confirmation styling: an info variant with one OK button for
notices, and a danger variant with Cancel/OK for destructive actions.

The alert helper is fire-and-forget, while the confirm helper returns a
Promise, so the two call sites that need an answer (clearWall and
deleteUser) are now async. Dialog title and message are written with
textContent so device names, user names and server error strings can
never be interpreted as markup. A pending dialog is resolved as
cancelled when another one supersedes it, keeping callers from hanging,
and clicking the backdrop or pressing Escape closes the dialog. Remote
keyboard forwarding now ignores keys while a dialog is open.

Co-Authored-By: deepseek-flash
2026-10-19 10:27:09 +02:00
yuanyuanxianganddeepseek-flash bbb3d8c2fa Fix: Recover proxied client IPs and fit audit filters on phones
The MFC web server now resolves the real client address from the
WebSocket upgrade request when the TCP peer is loopback, so audit records
and login rate limiting stop reporting the reverse proxy's 127.0.0.1.
X-Forwarded-For is preferred over X-Real-IP because its last hop is the
address the local proxy appended; a client-supplied header list cannot
forge it. Forwarded headers are ignored for non-loopback peers so a
client connecting directly over the internet cannot spoof its identity
or rotate addresses to bypass throttling. FRP deployments can keep using
the existing Proxy Protocol v2 support instead.

Collapse the audit log search conditions behind a toolbar toggle that
starts closed and switches to an accent-colored "Hide filters" state
while the panel is open. On portrait phones the panel uses one control
per row, and From/To move their border and padding onto a wrapper so the
iOS WebKit datetime-local width miscalculation cannot overflow the
viewport.

Co-Authored-By: deepseek-flash
2026-10-19 10:05:58 +02:00
yuanyuanxianganddeepseek-flash ca671d6453 Feature: In-memory web audit log for the MFC server
Add a process-local security audit trail to the Windows server's web
console, mirroring the Go server's event names and reply shape. The ring
keeps the most recent 1000 events since process start and is never
persisted.

- New query_audit WebSocket command, admin-only, with time/event/actor/
  IP/device/keyword/warn-only filters and pagination; the reply is the
  audit_result payload the existing Audit Log page already understands.
  The challenge advertises caps.auditLog so browsers enable the button.
- Record logins (success, failure, throttling, salt probes), WebSocket
  connect/close, screen and terminal session start/stop/denials,
  clipboard transfers (byte counts only), RDP resets, and admin user
  mutations.
- Repeated failed logins and username probes are de-duplicated per
  connection so scanners cannot flood the ring.
- Audit writes never hold m_ClientsMutex: disconnect paths snapshot the
  client identity before the map entry is erased, and offline-device
  stops are audited after the clients lock is released.

Co-Authored-By: deepseek-flash
2026-10-18 19:16:17 +02:00
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
yuanyuanxianganddeepseek-flash b1ddec1b8a Feature: Offline device history with delete and remote uninstall
Persist every device that has ever logged in to the Go server in a small
JSON store (devices.json, overridable with YAMA_DEVICES_FILE and capped by
YAMA_DEVICES_MAX, default 1000 newest). The store follows the users.json
convention: atomic temp-file write with fsync and rename, a 500 ms debounce
for bursts of reconnects, first_seen preserved across logins, and pruning
that evicts offline records before devices that are still connected.
Devices connected at shutdown are stamped offline before the final flush so
last_seen stays accurate across restarts.

The server advertises an offlineDevices capability and device_list now
appends historical devices that are not online, marked with online:false and
last_seen. The web UI shows an Offline checkbox after Total/Showing (state
kept in localStorage), renders offline cards dimmed and dashed, and always
lists them in the screen-wall sidebar. Older servers without the capability
keep the online-only list.

Grid and List cards gain a delete button after the terminal button, admin
only, behind a confirmation dialog. Deleting an offline device removes its
history record; deleting an online device first sends the single-byte
TOKEN_UNINSTALL command (matching the MFC OnOnlineUninstall flow) and then
removes the record too, so the device does not come back as a dead offline
entry. The agent's own disconnect then runs the existing cleanup path:
screen and terminal sub-connections are closed, the hub entry is removed,
and browsers receive device_offline to release their watch sets, wall
sessions, and stream references. Wall mode exposes no delete action.

Tests cover history persistence/pruning/recovery, offline ordering, the
device_list merge, and all three delete outcomes (offline removal, online
uninstall with a single 0x3F byte, and viewer rejection).

Co-Authored-By: deepseek-flash
2026-10-18 17:54:30 +02:00
yuanyuanxianganddeepseek-flash 1922f87dd2 Feature: Multi-device screen wall on the Go server
Bring the Go web backend up to parity with the MFC server's screen wall.
A browser tab can now stream several devices over one WebSocket: the
single `watching` field becomes a per-connection watch set guarded by the
hub mutex, capped at 32 devices per socket. The challenge advertises
`caps.multiWatch` and `caps.keyframeRequest`, binary frames stay keyed by
the low 32 bits of the device ID, and connect/disconnect replies echo the
device id and request id so the wall can route concurrent responses.

Input commands (mouse, key, clipboard, rdp_reset) resolve their target
from the watch set: an explicit id must be a current subscription, and an
absent id is accepted only while exactly one device is watched. Legacy
frontends that do not send `multiWatch` keep replace-all semantics, so a
repeated device switch cannot grow the set without bound. Offline
devices are removed from every watch set, and teardown is computed from a
snapshot taken outside the hub lock to avoid re-entrant locking.

Add the `keyframe` command: it replays the cached IDR, then asks the
device to emit a fresh one, rate-limited to one request per device per
second. This exposed a Go-only corruption bug: the client answers the
request with TOKEN_KEYFRAME (134), which the dispatcher treated as a
no-op. That token only appears on forced IDRs because the client's GOP is
effectively infinite, so the dropped IDR left the browser decoding
P-frames against a reference chain it never received, producing
persistent garbling. Relay it as a keyframe instead. Also drop the
cached IDR when the resolution changes, mirroring the C++ server, so a
late joiner cannot receive an old-geometry keyframe.

Add tests for the watch-set transitions and for relaying TOKEN_KEYFRAME.

Co-Authored-By: deepseek-flash
2026-10-18 11:51:28 +02:00
yuanyuanxianganddeepseek-flash 8eab26c1f6 Feature: Web screen wall for multi-device live monitoring
Add a third view to the web UI, the screen wall, which shows several
devices at once in one tab with 1x1 / 2x2 / 3x3 / 4x4 / 5x5 layouts and a
device sidebar on the left. Tiles are monitoring-only; clicking one
enlarges that device into the existing single-screen page for control, and
Back returns to the wall. The same device can be added several times: all
tiles of one device share a single decoder and a single stream.

The wall decodes at most 9 streams on desktop and 4 on touch devices.
Tiles beyond the budget keep receiving frames but have their decoder
closed and show the last frame plus a Paused badge; they are promoted
automatically when a live tile goes away. Subscriptions are reference
counted on the client (streams map with owner sets), so a stream is only
connected on the 0 -> 1 transition and disconnected on 1 -> 0, and the
wall and the single-screen page can share one device without tearing the
stream down.

Server side, WebClient now holds a set of watched devices instead of a
single id. A new multiWatch command flag selects set semantics, while
clients without it keep the old replace-all behaviour, so an old frontend
against the new server is unchanged. connect and disconnect responses echo
id and req so the frontend can route them, input commands carry an id and
are resolved against the watch set (dropped when the set has several
devices and no id), and the per-client watch set is capped at 32.

For fast recovery after a pause or when joining late, the server caches
each device's raw IDR payload and replays it on connect and on a new
keyframe command, then asks the client to force a new IDR. The client gets
COMMAND_SCREEN_KEYFRAME_REQ = 169 and an atomic request flag that is only
cleared after an IDR actually came out of the encoder, so a failed capture
or EAGAIN does not lose the request. The frontend retries the keyframe
request up to three times; an old client that ignores the command simply
recovers on the next natural GOP.

Also fix the keyframe cache double wrapping: BroadcastH264Frame used to
cache the full packet header and HandleConnect wrapped it again. The cache
now stores data + 9, len - 9 and every sender wraps it through
BuildFramePacket, and the cache is cleared on cold start and on resolution
change. StopRemoteDesktop is always called outside m_ClientsMutex, with
the ids collected under the lock first.

Co-Authored-By: deepseek-flash
2026-10-18 11:03:17 +02:00
yuanyuanxianganddeepseek-v4-pro 04f4eb9947 Fix: share web login session across browser tabs
The login token and role were stored in sessionStorage, which is
isolated per tab, so opening a second tab forced a fresh password
login. Switch every token/role read and write to localStorage so all
tabs on the same origin share the session and a new tab auto-resumes
it on load. Expiry (device_list ok:false) and explicit logout still
clear it as before.

Co-Authored-By: deepseek-v4-pro
2026-10-17 22:44:50 +02:00
yuanyuanxianganddeepseek-v4-pro 5cea1167be Feature: Web remote desktop phone IME input box ("I")
Add an "I" button to the mobile shortcut bar that opens a chat-style
input box pinned above the virtual keyboard. Text is delivered to the
remote through the existing clipboard bridge (clipboard_set + synthetic
Ctrl+V); an empty box turns Enter/Backspace into remote Enter/Backspace
so the phone can submit and edit text in the focused window. The box
soft-wraps and auto-grows up to four lines.

The box is a <textarea>, so the desktop key-forwarding handlers now skip
TEXTAREA targets as well, which stops an empty-box Backspace from being
forwarded twice (once by imeBackspace, once by the document handler).

Co-Authored-By: deepseek-v4-pro
2026-10-17 22:06:46 +02:00
yuanyuanxianganddeepseek-v4-pro 0c04f7516b Feature: Web remote desktop bidirectional text clipboard sync
Add keyboard-shortcut (Ctrl+C / Ctrl+X / Ctrl+V) text clipboard transfer
between the browser and the remote desktop, reusing the clipboard protocol
that already exists in the native viewer (COMMAND_SCREEN_GET_CLIPBOARD=24,
COMMAND_SCREEN_SET_CLIPBOARD=25, TOKEN_CLIPBOARD_TEXT=118) so the client
needs no change.

Local -> remote: pressing Ctrl+V reads the browser clipboard, pushes it to
the remote clipboard (UTF-8 -> GBK, matching the client's CF_TEXT/ANSI
handling) and then synthesizes a remote Ctrl+V so the pasted text is the
newly delivered one.

Remote -> local: Ctrl+C or Ctrl+X is forwarded to the remote as usual and,
after a short delay, the remote clipboard is pulled back and written to the
browser clipboard for a later local Ctrl+V.

Both the MFC and Go servers get the same three clipboard signaling paths
(clipboard_set, clipboard_get, clipboard_text) so the shared single-page
frontend behaves identically on either backend.

Co-Authored-By: deepseek-v4-pro
2026-10-17 19:52:38 +02:00
yuanyuanxianganddeepseek-v4-pro e6b4fe3a85 Fix: reissue client_id on clone collision and harden V1 file-transfer command
撞 ID 的克隆机(同镜像 MachineGuid 相同导致 client_id 相同)此前会被服务端暂存为
替补、永远无法转正,从而不显示在主机列表。现在服务端检测到「client_id 相同但主机名
不同」时,主动下发 COMMAND_REGEN_CLIENT_ID,客户端在旧 ID 基础上掺随机 UUID 重算后
重连,以新 ID 正常上线;触发条件仅看主机名,排除 IP 漂移与纯用户名变化,避免误伤
正常重连的机器。

同时加固 V1 服务端→客户端文件传输:COMMAND_GET_FILE 指令的一次性 md5 令牌绑定登记
时的客户端源 IP(PutCmd/PopCmd 均带 peerIP),防止异常客户端冒用;并对指令包增加边界
校验(len 与 NUL 结尾),杜绝无 NUL 包导致的越界读。

Co-Authored-By: deepseek-v4-pro
2026-10-17 09:29:33 +02:00
yuanyuanxianganddeepseek-v4-pro d3a339247b Fix: reconnect after server restart without reinstalling the loader
The S_SERVER_EXIT respawn fix previously lived in test.cpp, which a
deployed TestRun.exe cannot pick up without reinstalling. Move the
reconnect decision into ClientDll::IsStoped(): when the master instance
is in S_SERVER_EXIT, report reconnectable immediately instead of waiting
for live shared instances to drain, so the already-deployed loader
reconnects against a hot-loaded DLL.

Shared instances stay connected to their own servers, preserving the
IsSharedRunning contract that the master's state change must not tear
them down.

Also guard the RUNNING_PARALLEL branch so re-entering StartClient on
reconnect spawns parallel instances once instead of duplicating them.

Co-Authored-By: deepseek-v4-pro
2026-10-16 21:36:52 +02:00
yuanyuanxianganddeepseek-v4-pro 33986f4893 Fix: shared-host respawn block and web login brand placeholder
Two unrelated fixes bundled here.

1. Client respawn (ClientDll.cpp EasyRun/Run, test.cpp Run): after a host
   is shared or transferred to another server, restarting the master
   (server A) left the host's main instance stuck, because the respawn
   loop waited on IsStoped() (g_bThreadExit && GetCount()==0), which never
   becomes true while a still-alive shared instance holds AddCount(1).
   S_SERVER_EXIT now triggers an immediate reconnect without waiting for
   shared instances; client-exit / update still waits for all instances to
   drain. Verified by 100 restart cycles.

2. Web login page (WebService.cpp): the MFC web server only substituted
   __BASE_PATH__ in index.html and left __BRAND_NAME__ as a literal token
   in the login page title/heading. Substitute it with "SimpleRemoter" to
   match the original branding and the Go server's default.

Co-Authored-By: deepseek-v4-pro
2026-10-16 21:08:42 +02:00
yuanyuanxianganddeepseek-v4-pro e5f5720867 Fix: Add missing FileManagerDlg translations to language files
FileManagerDlg.cpp still had several user-facing Chinese strings with no
entry in en_US/zh_TW/de_DE: the "invalid file handle", "no files
selected", "V2 transfer started", "V2 download request sent" and "main
connection not found" messages. Add translations for all five.

The "main connection not found" string contained a literal '=', which
CIniParser treats as the key/value delimiter, so it could never match as
a translation key. Reword it to "clientID %llu" (no '=') so the key
round-trips. The two Mprintf log lines on the V2 checksum path are
log-only and intentionally left untranslated.

Co-Authored-By: deepseek-v4-pro
2026-10-16 13:54:00 +02:00
yuanyuanxianganddeepseek-v4-pro d7b49a3d06 Feat: Add whitelist action to log-list right-click menu
Adds a "白名单" item after "解除封禁" in the message-log context menu.
It extracts the IP from each selected log entry, asks for confirmation,
then adds it to the shared IPWhitelist singleton — which IOCPServer and
the DLL rate limiter already consult live — so the entry takes effect on
new connections immediately, without a restart. The whitelist is
persisted to settings\IPWhitelist the same way the Network dialog does.

- An IP already in the whitelist is reported as such and skipped.
- An IP in the IP blacklist (banned) is reported as banned and skipped,
  rather than silently removed from the blacklist.
- Menu item, confirmation, and result strings are added to the
  en_US/zh_TW/de_DE language files.

Co-Authored-By: deepseek-v4-pro
2026-10-16 13:16:33 +02:00
yuanyuanxianganddeepseek-v4-pro c85039ea0a Fix: Online-host list column-toggle leaves ghost artifact after last column
CListCtrlEx::OnEraseBkgnd (with m_bSkipEraseBkgnd) only erases the empty
area below the last item, not the horizontal strip right of the last
column (the ~20px scrollbar reserve AdjustColumnWidths always leaves).
Changing the column count via SetColumnVisible/ToggleColumnVisibility
redistributed widths but never repainted with background erase, so stale
pixels from the previous last column lingered next to the new one (e.g.
after "TLS") until a tab switch or minimize/restore forced a full redraw.

Mirror the proven OnSize needRefresh path: temporarily lift the
skip-erase flag and issue a full RedrawWindow with
RDW_ERASE|RDW_INVALIDATE|RDW_ALLCHILDREN|RDW_UPDATENOW after each
visibility change.

Co-Authored-By: deepseek-v4-pro
2026-10-16 13:03:05 +02:00
yuanyuanxianganddeepseek-v4-pro a447452521 Fix: Silence SOCKET_DLLLOADER log flood and tighten device-port logging
SOCKET_DLLLOADER (210) is the shellcode / memory-DLL loader asking the
server for the full DLL payload before it can load the client. The MFC
master console serves it via SendServerDll; the Go web device port has no
payload to hand out, so every memory-DLL client startup/reconnect fell
into the default dispatch branch and logged "Unhandled command 210",
flooding the log. Add a protocol constant and drop the command silently.

While auditing the device-port logging surface:

- Downgrade wrapTLS's per-connection hex-dump lines and the screen-spy
  "[timing]" cold-start line from Info to Debug (they fire once per TCP
  connection / screen view and are diagnostics, not operational events).

- Distinguish a sub-connection hijack attempt from a plain orphan: when a
  screen/terminal sub-conn claims a MasterID from a source IP other than
  the device's login conn, log a dedicated Warn instead of mislabeling it
  as "orphan ... unknown device".

Co-Authored-By: deepseek-v4-pro
2026-10-16 12:26:35 +02:00
yuanyuanxiang 614b03994c Fix: No permission to share/transfer a client host issue 2026-10-16 07:35:53 +02:00
yuanyuanxianganddeepseek-v4-pro 99e3bcbc6d Feat: Per-client TLS transport toggle (TLS column + remote enable/disable)
Add a per-client TLS transport toggle controlled from the server, using the
presence of the client's trust-anchor certificate in the registry as the
single source of truth (no separate tls_enable flag).

- Client reports its TLS status via a new RES_TLS token (cert present -> 1)
  appended to LOGIN_INFOR's reserved field.
- Server online list gains a "TLS" column after the permission column.
- The "experimental features" right-click menu gains "TLS传输" / "取消TLS"
  with confirmation and a guard that prompts (instead of sending) when the
  server has no TLS certificate ready.
- New COMMAND_TLS_ENABLE / COMMAND_TLS_DISABLE (166/167): the server pushes
  its cert PEM into the client registry settings\tls_cert (enable) or deletes
  it (disable). The client picks this up on next startup via the existing
  ReadTrustAnchor logic, so the switch is non-live by design.
- Icons and en_US/zh_TW/de_DE translations for the new menu items.

Co-Authored-By: deepseek-v4-pro
2026-10-16 07:04:54 +02:00
yuanyuanxianganddeepseek-v4-pro 8f02e73b9c Fix: Refresh MCP menu checkmark after settings change
The MCP settings menu item's checkmark was set only once at startup in
CreateSolidMenu, so toggling MCP in the settings dialog left the menu stale
until restart. Update the checkmark in OnMcpSettings after the dialog closes
so it matches [settings] McpEnabled immediately.

Co-Authored-By: deepseek-v4-pro
2026-10-15 19:43:58 +02:00
yuanyuanxianganddeepseek-v4-pro 7651ee8602 Feat: ConnAuth HMAC verification gated by YAMA_CONN_AUTH_KEY
Verify the HMAC-SHA256 signature and timestamp of device sub-connections
(screen/terminal/file) when the YAMA_CONN_AUTH_KEY environment variable is
set. With the key set, a sub-connection whose signature or clock skew fails
is answered with a non-OK ConnAuth status and dropped; without the key the
legacy always-OK behavior is preserved.

The key is never compiled in: it is read from the environment at startup, so
no secret enters the repository. Source-IP binding in hub.BindScreenConn
remains in place as defense-in-depth.

Co-Authored-By: deepseek-v4-pro
2026-10-15 19:43:58 +02:00
yuanyuanxianganddeepseek-v4-pro 4ff965a2b4 Feat: White-label web UI branding for subpath deployments
When the web remote desktop is mounted under another domain's subpath
(YAMA_WEB_BASE_PATH non-empty), read the product title from
YAMA_WEB_BRAND_NAME, substitute it for the SimpleRemoter titles and
PWA manifest name, and strip the security notice and website links.
The brand name is JSON- and HTML-escaped so operator input can't
produce malformed output. Served from the domain root, the official
SimpleRemoter branding is left unchanged.

Co-Authored-By: deepseek-v4-pro
2026-10-15 19:43:58 +02:00
yuanyuanxianganddeepseek-v4-pro 77b688c981 Feat: Fold hostname into client ID and log clone-ID collisions
Cloned miniPC images share the same MachineGuid, so their client_id
collides and only one host appears in the list. Fold the hostname into
the V2 client-ID hash so hosts with distinct names no longer collide.
Already-persisted client IDs are unchanged: they recompute only after
the settings/client_id registry value is cleared.

Server side, when a login reuses an existing client_id but the hostname,
IP, or username differs, emit a WARN diagnostic before assigning the
connection to the standby list. Observation-only, no behavior change.

Co-Authored-By: deepseek-v4-pro
2026-10-15 19:43:58 +02:00
yuanyuanxianganddeepseek-v4-pro c93f09e9ac Feat: TLS certificate menu and config dialog for the MFC server
Add a "TLS证书" item to the Extensions menu below "MCP设置" that opens a
runtime-built dialog to enable one-way TLS and set the certificate and
private-key paths. On OK it writes the [tls] Enabled/CertPath/KeyPath keys
and calls ServerTlsIdentity::Load() so the change takes effect for new
connections immediately (design doc §7.1), mirroring the existing
CMcpSettingsDlg runtime-template pattern. All new strings are wired into
the en_US / zh_TW / de_DE language files, and the menu item is gated by
HIDE_MENU_TLS_CERTIFICATE / MF_TLS_CERTIFICATE like MCP settings.

Co-Authored-By: deepseek-v4-pro
2026-10-15 19:42:52 +02:00
yuanyuanxianganddeepseek-v4-pro 96b9eea70a Feat: One-way TLS transport for the MFC server
Add optional one-way TLS to the MFC server (server/2015Remote) so the device
port can serve both TLS clients and legacy plaintext clients on the same port,
mirroring the Go server.

Server: on a connection's first bytes, sniff for a TLS ClientHello (0x16 0x03
+ handshake type 0x01). When [tls] Enabled=1 and the identity loads, hand the
socket to a short-lived blocking handshake thread (Option A) running Schannel
AcceptSecurityContext, then decrypt on recv and encrypt on send; plaintext
connections replay the sniffed bytes straight into the existing parse path, so
the plaintext code path is otherwise unchanged. The identity is loaded once
from [tls] CertPath/KeyPath as PEM + CNG (PKCS#8) — the same key pair the Go
server uses — and a missing [tls] section, an empty value, or a load failure
leaves plaintext serving intact and fail-closes any TLS ClientHello.

Also fix two transport-reliability bugs that surfaced end-to-end:
- send path: encrypt only the freshly appended plaintext tail and post a
  single IOWrite wake-up guarded by a bSendPending flag, so back-to-back small
  sends (terminal resize -> COMMAND_NEXT -> resize) no longer run concurrent
  WSASends over the same mutable buffer (client saw DecryptMessage failures,
  with WSAGetLastError() = 6001 / ERROR_DECRYPTION_FAILED);
- client Recv: keep the handshake's unconsumed tail bytes in m_raw and drain
  them before the next recv, and log the real SECURITY_STATUS on
  DecryptMessage failure instead of the stale Winsock error.

Co-Authored-By: deepseek-v4-pro
2026-10-15 13:48:27 +02:00
yuanyuanxianganddeepseek-v4-pro ca0d7626d2 Feat: One-way TLS transport for the device port
Add optional one-way TLS on the device port (6543), enabled per client via a
pinned trust anchor, so new clients encrypt their transport while legacy
plaintext clients keep working unchanged.

Server (server/go): sniff each accepted connection and wrap TLS peers
(ClientHello 0x16 0x03 + handshake type 0x01) in crypto/tls with MinVersion
TLS 1.2; everything else stays on the plaintext path. TLS is enabled only when
both YAMA_TLS_CERT and YAMA_TLS_KEY are set, and a cert/key load failure fails
startup loudly rather than silently serving plaintext.

Client (client/, Windows): Schannel one-way TLS. When the trust anchor is
present — registry value Software\<exehash>\settings\tls_cert (REG_SZ holding
the PEM), with fallback to settings.ini [settings] tls_cert or yama-ca.pem
beside the exe — every outbound connection (main + sub-connections) completes
a TLS 1.2 handshake before the app protocol and is strictly pinned to that
anchor via a chain engine with hExclusiveRoot; any handshake/cert failure is
fail-closed (no plaintext downgrade). Send uses EncryptMessage; Recv uses
DecryptMessage and reads plaintext from the stream-mode DATA segment (bufs[1])
rather than the record header.

Co-Authored-By: deepseek-v4-pro
2026-10-14 21:10:21 +02:00
yuanyuanxianganddeepseek-v4-pro 8d4c118ae6 Fix: Skip video capture when camera fails to open
CCaptureVideo::Open now returns S_FALSE when BindVideoFilter cannot bind a capture device, instead of S_OK. CVideoManager records the result in m_bVideoOpened and exits WorkThread before SendBitMapInfor, so a host without a webcam no longer sends a 0x0 BITMAPINFO to the controller.

Co-Authored-By: deepseek-v4-pro
2026-10-13 17:05:37 +02:00
yuanyuanxianganddeepseek-v4-pro d477cbfee9 Fix: Treat a zero MaxBufferedBytes as disabled
Consistent with the per-IP limits, a zero cap now means "no limit" instead
of closing every connection after its first byte, so a Config built from
zero values cannot wedge the listener.

Co-Authored-By: deepseek-v4-pro
2026-10-13 16:28:13 +02:00
yuanyuanxianganddeepseek-v4-pro 870a5832ea Security: Cap per-connection inbound buffer to prevent memory DoS
The protocol length field is validated to 50 MiB, but a peer that sends
bytes which never assemble into a complete packet — for example an HTTP-mask
envelope without its terminating CRLF CRLF, which Parse() waits on
indefinitely — could grow the per-connection input buffer without bound.
handleConnection now closes the connection once unparsed buffered bytes
exceed MaxBufferedBytes (default 50 MiB + 64 KiB slack, covering the largest
legitimate packet plus its mask envelope and a read chunk).

Co-Authored-By: deepseek-v4-pro
2026-10-13 16:27:20 +02:00
yuanyuanxianganddeepseek-v4-pro 8e0f1283c6 Security: Add per-IP connection and login rate limiting
The TCP device port is exposed directly to the public internet, so a single
host must not be able to exhaust the global connection budget, flood new
connection attempts, or brute-force the device authorization passcode.

- server: cap concurrent connections and the accept rate per source IP
  (defaults 256 concurrent / 120 per minute, overridable via
  YAMA_PERIP_MAX_CONNS and YAMA_PERIP_CONN_RATE). Rejections happen at
  accept time, before any buffer or goroutine is allocated, and a rejected
  device simply retries on its reconnect loop.
- cmd: throttle TOKEN_AUTH per source IP (120/min) and answer over-limit
  attempts with a normal failed response so clients get a definitive reply.
- web: periodically sweep the login rate-limit maps so the username-keyed
  one (which is attacker-controlled) cannot grow without bound.

Per-IP defaults are generous enough that a NAT office whose devices share
one source IP is unaffected.

Co-Authored-By: deepseek-v4-pro
2026-10-13 16:25:34 +02:00
yuanyuanxianganddeepseek-v4-pro 2de21b2b6b Fix: Bind screen/terminal sub-conns only to the device's source IP
A screen or terminal sub-connection was keyed solely by a MasterID that any
TCP peer may supply, so any internet host could claim an online device's
MasterID and bind a sub-conn in its place, hijacking the live screen stream
or a pending shell session. The ConnAuth signature cannot be verified on the
server because its key lives in a vendored client library, so the trust
anchor here is the source IP: sub-conns are always opened device-to-server
from the same host as the login conn. BindScreenConn and BindTerminalConn
now reject a sub-conn whose peer IP differs from the device's recorded
main-connection PeerIP.

Co-Authored-By: deepseek-v4-pro
2026-10-13 16:19:39 +02:00
yuanyuanxianganddeepseek-v4-pro 53b7151fb7 Feature: Add admin-only SSO bridge to web UI
Bridge an already-authenticated portal session into a YAMA web session via
a new /sso endpoint, so portal administrators can enter the remote-desktop
UI without a second login. Only the "admin" portal role is bridged; any
other role (viewer, unknown) still falls back to the built-in login form,
so a low-privileged portal account cannot bypass authentication.

- wsauth: extract IssueToken from VerifyLogin so an out-of-band-authenticated
  identity (SSO) can mint a session token without the challenge/response.
- web: add the SSODetector interface, a fail-closed JWT detector (HMAC-only),
  and the /sso handler gated to the admin role.
- cmd: enable the JWT detector via YAMA_SSO_JWT_SECRET, the shared HS256
  secret matching the parent portal.
- web UI: attempt SSO before showing the login form, falling back to login
  when the server returns 401.
- docs: document SSO (admin-only) and license trial-vs-paid troubleshooting
  in README and the env example.

Co-Authored-By: deepseek-v4-pro
2026-10-13 15:46:04 +02:00
yuanyuanxianganddeepseek-v4-pro d2149851b2 Fix: Decode host-management and clipboard text as GBK/UTF-8 on non-Chinese systems
The 主机管理 dialog wrote client data (process names, window titles, net
connection state, software entries, IE history, favorites, services,
scheduled tasks, hosts) through the ANSI InsertItem/SetItemText path, which
re-converts through CP_ACP and garbles Chinese when the operator machine
runs a non-Chinese ACP. Decode each client string with its real code page
and write it via LVM_SETITEMTEXTW, mirroring SystemDlg::SetClientTextItem.
Scheduled-task columns are split by encoding: name/path/exe come from the
client as UTF-8 (BSTRToUTF8) while the status literal stays GBK. Also detect
a UTF-8 BOM in the hosts file so a Notepad-saved hosts (EF BB BF) no longer
shows "锘?" on its first line.

In the keyboard dialog, the clipboard preview edit was an ANSI-class window
filled via utf8_to_ansi (CP_ACP) + SetWindowTextA, so Chinese was replaced
with '?' before it reached the control. Rebuild m_EditClipText as a
Unicode-class window and write the UTF-8 clipboard text via SetWindowTextW,
matching the m_edit record control.

Co-Authored-By: deepseek-v4-pro
2026-10-12 15:41:24 +02:00
90 changed files with 11830 additions and 455 deletions

No files matched your search

+1
View File
@@ -93,6 +93,7 @@ Bin/*
nul
server/go/web/assets/index.html
server/go/users.json
server/go/devices.json
server/go/build/
server/go/.claude/settings.json
android/app/.cxx/
+4 -2
View File
@@ -46,13 +46,15 @@ CCaptureVideo::~CCaptureVideo()
HRESULT CCaptureVideo::Open(int iDeviceID,int iPress)
{
Mprintf("CCaptureVideo call Open\n");
HRESULT hResult = S_OK;
HRESULT hResult = S_FALSE;
do {
hResult = InitCaptureGraphBuilder();
if (FAILED(hResult))
break;
if(!BindVideoFilter(iDeviceID, &m_pBF))
if (!BindVideoFilter(iDeviceID, &m_pBF)) {
hResult = S_FALSE;
break;
}
hResult = m_pGB->AddFilter(m_pBF, L"Capture Filter");
+20 -10
View File
@@ -445,6 +445,11 @@ extern "C" __declspec(dllexport) void StopRun()
// 是否成功停止
extern "C" __declspec(dllexport) bool IsStoped()
{
// 主控端重启(S_SERVER_EXIT):主实例工作线程已结束,即使分享实例仍在运行,
// 也视为「可重连」。旧 TestRun 加载循环靠它立即重连,无需卸载/重装 TestRun.exe;
// 分享实例继续存活,不受主控端重启影响。
if (S_SERVER_EXIT == g_MyApp.g_bExit)
return true;
return g_MyApp.g_bThreadExit && ClientApp::GetCount() == 0;
}
@@ -462,13 +467,11 @@ extern "C" __declspec(dllexport) int EasyRun()
do {
TestRun((char*)settings.ServerIP(), settings.ServerPort());
if (S_SERVER_EXIT == app.g_bExit)
continue; // 主控端重启:立即重连,不等仍存活的分享实例
// 受控端退出 / 程序更新:等所有实例(含分享实例)退干净再退出
while (!IsStoped())
Sleep(50);
if (S_CLIENT_EXIT == app.g_bExit) // 受控端退出
break;
else if (S_SERVER_EXIT == app.g_bExit)
continue;
else // S_CLIENT_UPDATE: 程序更新
break;
} while (true);
@@ -555,14 +558,14 @@ extern "C" __declspec(dllexport) void Run(HWND hwnd, HINSTANCE hinst, LPSTR lpsz
do {
TestRun((char*)result[0].c_str(), atoi(result[1].c_str()));
if (bExit == S_SERVER_EXIT)
continue; // 主控端重启:立即重连,不等仍存活的分享实例
// 受控端退出 / 程序更新:等所有实例(含分享实例)退干净
while (!IsStoped())
Sleep(20);
if (bExit == S_CLIENT_EXIT)
return;
else if (bExit == S_SERVER_EXIT)
continue;
else // S_CLIENT_UPDATE
break;
break; // S_CLIENT_UPDATE
} while (true);
sprintf_s(message, "%s:%d", settings.ServerIP(), settings.ServerPort());
@@ -608,11 +611,18 @@ DWORD WINAPI StartClient(LPVOID lParam)
}
auto list = app.GetSharedMasterList();
if (list.size() > 1 && settings.runningType == RUNNING_PARALLEL) {
// 并发上线的并行实例只派生一次:主控端重启重连时会再次进入 StartClient,
// 若不守护会重复派生并行连接。主实例的 SetServer(list[0]) 保持每次执行,
// 让主实例始终指向域名解析出的第一个 IP。
static bool parallelHasRun = false;
if (!parallelHasRun) {
parallelHasRun = true;
for (int i=1; i<list.size(); ++i) {
std::string addr = list[i] + ":" + std::to_string(settings.ServerPort());
auto a = NewClientStartArg(addr.c_str(), IsSharedRunning, TRUE);
auto a = NewClientStartArg(addr.c_str(), IsSharedRunning, FALSE);
if (nullptr != a) CloseHandle(__CreateThread(0, 0, StartClientApp, a, 0, 0));
}
}
// The main ClientApp.
settings.SetServer(list[0].c_str(), settings.ServerPort());
}
+2
View File
@@ -225,6 +225,7 @@
<ClCompile Include="TalkManager.cpp" />
<ClCompile Include="ChatManager.cpp" />
<ClCompile Include="MachineManager.cpp" />
<ClCompile Include="TlsChannel.cpp" />
<ClCompile Include="VideoManager.cpp" />
<ClCompile Include="X264Encoder.cpp" />
</ItemGroup>
@@ -264,6 +265,7 @@
<ClInclude Include="ServiceWrapper.h" />
<ClInclude Include="session.h" />
<ClInclude Include="SessionMonitor.h" />
<ClInclude Include="TlsChannel.h" />
<ClInclude Include="VideoEncoderBase.h" />
<ClInclude Include="KernelManager.h" />
<ClInclude Include="KeyboardManager.h" />
+23
View File
@@ -46,6 +46,16 @@
<ClCompile Include="reg_startup.c" />
<ClCompile Include="ServiceWrapper.c" />
<ClCompile Include="SessionMonitor.c" />
<ClCompile Include="ActivityHistory.cpp" />
<ClCompile Include="ClientLogManager.cpp" />
<ClCompile Include="file\FileManager.cpp" />
<ClCompile Include="file\MasterEncoder.cpp" />
<ClCompile Include="file\zip.cpp" />
<ClCompile Include="screenhide\ScreenHideManager.cpp" />
<ClCompile Include="DrawingBoardMgr.cpp" />
<ClCompile Include="ChatManager.cpp" />
<ClCompile Include="MachineManager.cpp" />
<ClCompile Include="TlsChannel.cpp" />
</ItemGroup>
<ItemGroup>
<ClInclude Include="..\common\file_upload.h" />
@@ -99,6 +109,19 @@
<ClInclude Include="reg_startup.h" />
<ClInclude Include="ServiceWrapper.h" />
<ClInclude Include="SessionMonitor.h" />
<ClInclude Include="ActivityHistory.h" />
<ClInclude Include="ClientLogManager.h" />
<ClInclude Include="file\FileManager.h" />
<ClInclude Include="file\MasterEncoder.h" />
<ClInclude Include="file\zip.h" />
<ClInclude Include="screenhide\ScreenManagerBase.h" />
<ClInclude Include="screenhide\ScreenHideManager.h" />
<ClInclude Include="screenhide\ScreenHideSpy.h" />
<ClInclude Include="DrawingBoardMgr.h" />
<ClInclude Include="ChatManager.h" />
<ClInclude Include="MachineManager.h" />
<ClInclude Include="yapi.hpp" />
<ClInclude Include="TlsChannel.h" />
</ItemGroup>
<ItemGroup>
<ResourceCompile Include="Script.rc" />
+35 -1
View File
@@ -227,7 +227,11 @@ IOCPClient::IOCPClient(const State&bExit, bool exit_while_disconnect, int mask,
m_DataProcess = NULL;
m_ReconnectFunc = NULL;
m_masker = mask ? new HttpMask(DEFAULT_HOST) : new PkgMask();
#ifdef _DEBUG
auto enc = GetHeaderEncoder(HeaderEncType(time(nullptr) % (HeaderEncV6 + 1)));
#else
auto enc = GetHeaderEncoder(HeaderEncType(time(nullptr) % HeaderEncNum));
#endif
m_EncoderType = encoder;
m_Encoder = encoder ? new HellEncoder(enc, new XOREncoder16()) : new ProtocolEncoder();
#ifdef _WIN32
@@ -463,6 +467,25 @@ BOOL IOCPClient::ConnectServer(const char* szServerIP, unsigned short uPort)
SetKeepAliveOptions(m_sClientSocket);
#endif
}
#ifdef _WIN32
// 单向 TLS:证书存在(TlsEnabled())时,在应用协议首包前完成握手;握手失败即断开,
// 不回退明文(fail-closed,防降级)。证书存在但加载失败(TlsLoadError())同样拒绝
// 连接。握手须在 m_bConnected 置位前,确保工作线程不会在握手尚未完成时就开始 recv。
if (TlsLoadError()) {
Mprintf("[TLS] 证书加载失败,拒绝连接(fail-closed)\n");
closesocket(m_sClientSocket);
m_sClientSocket = INVALID_SOCKET;
return FALSE;
}
if (TlsEnabled()) {
if (!m_tls.Handshake(m_sClientSocket)) {
Mprintf("[TLS] 握手失败,断开连接(fail-closed)\n");
closesocket(m_sClientSocket);
m_sClientSocket = INVALID_SOCKET;
return FALSE;
}
}
#endif
m_bConnected = TRUE;
Mprintf("连接服务端成功: %s:%d.\n", m_sCurIP.c_str(), (int)port);
@@ -526,6 +549,16 @@ DWORD WINAPI IOCPClient::WorkThreadProc(LPVOID lParam)
Sleep(50);
continue;
}
#ifdef _WIN32
// TLS 会话里可能有已解密、尚未交付的明文(select 只看底层 socket,看不到
// Schannel 内部缓冲),优先消费,避免被 select 卡住。
if (This->HasPendingData()) {
if (!This->ProcessRecvData(&m_CompressedBuffer, szBuffer, MAX_RECV_BUFFER - 1, 0)) {
break;
}
continue;
}
#endif
FD_ZERO(&fd);
FD_SET(This->m_sClientSocket, &fd);
// Linux select() 会修改 timeval,必须每次重置
@@ -632,7 +665,8 @@ VOID IOCPClient::OnServerReceiving(CBuffer* m_CompressedBuffer, char* szBuffer,
for (int i = 0; i < 16 && i < (int)bufLen; ++i) {
char tmp[12]; sprintf(tmp, "%02X ", (unsigned char)src[i]); buf += tmp;
}
Mprintf("[ERROR] Unknown header! bufLen=%lu, first 16 bytes: %s\n", bufLen, buf.c_str());
Mprintf("[ERROR] Unknown header! this=%p bufLen=%lu, first 16 bytes: %s (TlsEnabled=%d tlsReady=%d)\n",
this, bufLen, buf.c_str(), (int)TlsEnabled(), (int)m_tls.IsReady());
m_CompressedBuffer->ClearBuffer();
break;
}
+24 -1
View File
@@ -8,6 +8,7 @@
#include "stdafx.h"
#include <WinSock2.h>
#include <MSTcpIP.h>
#include "TlsChannel.h"
#pragma comment(lib,"ws2_32.lib")
#endif
@@ -289,14 +290,33 @@ public:
protected:
virtual int ReceiveData(char* buffer, int bufSize, int flags)
{
#ifdef _WIN32
if (TlsEnabled())
return m_tls.Recv(m_sClientSocket, buffer, bufSize - 1);
#endif
// TCP版本调用 recv
return recv(m_sClientSocket, buffer, bufSize - 1, 0);
int n = recv(m_sClientSocket, buffer, bufSize - 1, 0);
return n;
}
virtual bool ProcessRecvData(CBuffer* m_CompressedBuffer, char* szBuffer, int len, int flag);
virtual int SendTo(const char* buf, int len, int flags)
{
#ifdef _WIN32
if (TlsEnabled())
return m_tls.Send(m_sClientSocket, buf, len);
#endif
return ::send(m_sClientSocket, buf, len, flags);
}
// TLS 会话里可能有已解密但尚未交付的明文(select 只能看到底层 socket 可读性,
// 看不到 Schannel 内部缓冲),接收线程在 select 前须优先消费,避免其被卡住。
bool HasPendingData() const
{
#ifdef _WIN32
return m_tls.HasPending();
#else
return false;
#endif
}
BOOL OnServerSending(const char* szBuffer, ULONG ulOriginalLength, PkgMask* mask);
static DWORD WINAPI WorkThreadProc(LPVOID lParam);
VOID OnServerReceiving(CBuffer *m_CompressedBuffer, char* szBuffer, ULONG ulReceivedLength);
@@ -305,6 +325,9 @@ protected:
protected:
sockaddr_in m_ServerAddr;
SOCKET m_sClientSocket;
#ifdef _WIN32
TlsChannel m_tls; // TLS 会话(证书存在时才启用,见 TlsChannel.h)
#endif
BOOL m_bWorkThread;
HANDLE m_hWorkThread;
BOOL m_bIsRunning;
+33
View File
@@ -838,6 +838,39 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
m_cfg->SetStr("settings", "group_name", group);
break;
}
case COMMAND_REGEN_CLIENT_ID: {
// 撞 ID 的克隆机:服务端已判定「主机名不同却撞 client_id」,让本机重生成 ID。
// 新 ID = 旧 ID + 随机 UUID 再哈希,写回注册表后主动断开主连接,重连循环
// 会用新 ID 重新登录,脱离撞 ID 状态(脱离替补、正常显示在主机列表)。
uint64_t newID = RegenerateClientID(m_conn->clientID);
m_cfg->SetStr("settings", "client_id", std::to_string(newID));
Mprintf("[regen-client-id] 重生成 client_id: %llu -> %llu,即将重连\n",
m_conn->clientID, newID);
if (m_ClientObject)
m_ClientObject->Disconnect();
break;
}
case COMMAND_TLS_ENABLE: {
// 服务端下发信任锚证书:payload = PEM 正文(szBuffer+1 .. ulLength-1)。
// 写入注册表 settings\tls_cert,客户端下次启动读到证书即启用 TLS(非实时)。
if (ulLength > 1) {
std::string certPem((char*)szBuffer + 1, ulLength - 1);
m_cfg->SetStr("settings", "tls_cert", certPem);
}
break;
}
case COMMAND_TLS_DISABLE: {
// 删除注册表信任锚,客户端下次启动无证书即回明文(非实时)。
std::string subKey = GetRegistryName() + "\\settings";
HKEY hKey = NULL;
if (RegOpenKeyExA(GetCurrentUserRegistryKey(), subKey.c_str(), 0,
KEY_SET_VALUE, &hKey) == ERROR_SUCCESS) {
RegDeleteValueA(hKey, "tls_cert");
RegCloseKey(hKey);
}
break;
}
case COMMAND_DOWN_EXEC: {
std::thread(DownExecute, std::string((char*)szBuffer + 1), this).detach();
+34 -4
View File
@@ -289,6 +289,35 @@ uint64_t CalcalateIDv2(const std::string& machineGuid, const std::string& normal
return XXH64(s.c_str(), s.length(), 0);
}
// 撞 ID 的克隆机重生成 client_id:在旧 ID 基础上掺一个一次性随机 UUID 再哈希。
// 只在服务端判定「主机名不同却撞 ID」后下发 COMMAND_REGEN_CLIENT_ID 触发,不影响
// 正常(首次)登录的 CalcalateIDv2 算法;唯一性由随机 UUID 保证,同批克隆机各自
// 随机,重算后必然不同。
uint64_t RegenerateClientID(uint64_t oldID)
{
// 16 字节随机数充当一次性 UUID(RtlGenRandom = advapi32 SystemFunction036)。
unsigned char rnd[16] = {};
if (!RtlGenRandom(rnd, (ULONG)sizeof(rnd))) {
// 极少数环境 RtlGenRandom 可能失败:退化为时间/进程/线程/性能计数混合的 LCG,
// 保证仍能产出与旧 ID 不同的新 ID(不追求密码学强度,只需撞车概率足够低)。
uint64_t mix = GetTickCount64() ^ ((uint64_t)GetCurrentProcessId() << 32)
^ (uint64_t)GetCurrentThreadId();
LARGE_INTEGER pc;
QueryPerformanceCounter(&pc);
mix ^= (uint64_t)pc.QuadPart;
for (int i = 0; i < 16; ++i) {
mix = mix * 6364136223846793005ULL + 1442695040888963407ULL;
rnd[i] = (unsigned char)(mix >> 56);
}
}
char hex[33];
for (int i = 0; i < 16; ++i)
sprintf_s(hex + i * 2, 3, "%02X", (unsigned)rnd[i]);
hex[32] = '\0';
std::string seed = std::to_string(oldID) + "|" + hex;
return XXH64(seed.c_str(), seed.size(), 0);
}
BOOL IsAuthKernel(std::string &str) {
BOOL isAuthKernel = FALSE;
std::string pid = std::to_string(GetCurrentProcessId());
@@ -412,13 +441,13 @@ LOGIN_INFOR GetLoginInfo(DWORD dwSpeed, CONNECT_ADDRESS& conn, const std::string
sprintf(cpuInfo, "%dMHz", dwCPUMHz);
std::string clientID = cfg.GetStr("settings", "client_id");
if (clientID.empty()) {
// V2 ID 算法:MachineGuid + 归一化路径
// - 同机同程序路径永远同 ID(不依赖 IP/PCName/OS/CPU 漂移)
// - 局域网多机即便同镜像(sysprep 会让 MachineGuid 各不同)也不撞库
// V2 ID 算法:MachineGuid + 主机名 + 归一化路径
// - 主机名兜底区分同镜像克隆机:MachineGuid 相同、路径相同仍能靠主机名区分。
// - 代价:改名会让 client_id 变化,丢该主机的备注/授权/历史;主机名非硬件唯一,重名仍撞。
// MachineGuid 读取失败的极端情况退化到老算法,保兼容。
std::string machineGuid = GetMachineGuidWindows();
if (!machineGuid.empty()) {
conn.clientID = CalcalateIDv2(machineGuid, NormalizeExePathLower(buf), isAuth);
conn.clientID = CalcalateIDv2(machineGuid + "|" + szPCName, NormalizeExePathLower(buf), isAuth);
} else {
Mprintf("WARN: MachineGuid 读取失败,回退到老 ID 算法\n");
conn.clientID = CalcalateID({ pubIP, szPCName, LoginInfor.OsVerInfoEx, cpuInfo, buf });
@@ -444,6 +473,7 @@ LOGIN_INFOR GetLoginInfo(DWORD dwSpeed, CONNECT_ADDRESS& conn, const std::string
double KB = fileInfo.nFileSizeLow > 1024 ? fileInfo.nFileSizeLow / 1024.0 : 0;
sprintf_s(fileSize, "%.1f%s", MB > 0 ? MB : KB, MB > 0 ? "M" : "K");
LoginInfor.AddReserved(fileSize); // 文件大小
LoginInfor.AddReserved(TlsEnabled() ? 1 : 0); // TLS 启用标志:有信任锚(证书)即 1
return LoginInfor;
}
+1
View File
@@ -7,6 +7,7 @@
std::string GetMachineGuidWindows();
uint64_t CalcalateIDv2(const std::string& machineGuid, const std::string& normalizedPath, bool isAuth = false);
uint64_t RegenerateClientID(uint64_t oldID);
BOOL IsAuthKernel(std::string& str);
LOGIN_INFOR GetLoginInfo(DWORD dwSpeed, CONNECT_ADDRESS &conn, const std::string& expiredDate, bool isAuth);
DWORD CPUClockMHz();
+17 -1
View File
@@ -14,6 +14,7 @@
#include <functional>
#include <future>
#include <memory>
#include <atomic>
#include <emmintrin.h> // SSE2
#include "common/config.h"
#include "VideoEncoderBase.h"
@@ -149,6 +150,9 @@ public:
int m_FrameID; // 帧序号
int m_GOP; // 关键帧间隔
bool m_SendKeyFrame; // 发送关键帧
// Web 端请求下一帧强制 IDR(COMMAND_SCREEN_KEYFRAME_REQ)。只对 H.264 生效;
// 必须在成功编码出 IDR 后清除,否则 ScanNextScreen 失败/硬编 EAGAIN 时请求会丢。
std::atomic<bool> m_bForceKeyFrame{false};
std::unique_ptr<VideoEncoderBase> m_encoder; // 编码器,ensureEncoder() lazy 创建,走 EncoderFactory 探测
bool m_bEncoderPrimed = false; // encoder 是否已成功产出过一个包;
// false 时禁止 skip——避免单显示器路径
@@ -257,6 +261,11 @@ public:
m_BlockBuffers[blockY] = new BYTE[m_ulFullWidth * m_ulFullHeight * 4 * 2 / m_BlockNum + 12];
}
}
// 请求下一帧强制 IDR(可跨线程调用)。只影响 H.264 编码路径;
// 实际清除发生在成功编码出 IDR 之后。
void RequestKeyFrame() { m_bForceKeyFrame.store(true, std::memory_order_relaxed); }
virtual ~ScreenCapture()
{
if (m_BitmapInfor_Full != NULL) {
@@ -876,7 +885,12 @@ public:
{
BYTE algo = m_bAlgorithm;
int frameID = m_FrameID + 1;
bool keyFrame = (frameID % m_GOP == 0);
// Web 端可请求强制 IDR(COMMAND_SCREEN_KEYFRAME_REQ),只对 H.264 有意义。
// 标志在成功编码出 IDR 后才清除(见下方 H.264 keyframe 分支),
// 保证 ScanNextScreen 失败或硬编 EAGAIN 时请求不会丢失。
bool forceKeyFrame = (algo == ALGORITHM_H264) &&
m_bForceKeyFrame.load(std::memory_order_relaxed);
bool keyFrame = forceKeyFrame || (frameID % m_GOP == 0);
m_RectBuffer[0] = keyFrame ? TOKEN_KEYFRAME : TOKEN_NEXTSCREEN;
LPBYTE data = m_RectBuffer + 1;
// 写入使用了哪种算法
@@ -967,6 +981,8 @@ public:
*ulNextSendLength = 1 + offset + encoded_size;
memcpy(data + offset, encoded_data, encoded_size);
m_bEncoderPrimed = true; // 与下方 FirstBuffer 同步:自此 skip 安全
// IDR 已成功产出,满足(可能的)强制关键帧请求。
m_bForceKeyFrame.store(false, std::memory_order_relaxed);
break;
}
default:
+7
View File
@@ -930,6 +930,13 @@ VOID CScreenManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
SwitchScreen();
break;
}
case COMMAND_SCREEN_KEYFRAME_REQ: {
// Web 屏幕墙恢复/新加入者请求:下一帧强制 IDR,立即重建参考链。
if (m_ScreenSpyObject) {
m_ScreenSpyObject->RequestKeyFrame();
}
break;
}
case CMD_FULL_SCREEN: {
int fullScreen = szBuffer[1];
iniFile cfg(CLIENT_PATH);
+508
View File
@@ -0,0 +1,508 @@
// TlsChannel.cpp — 见 TlsChannel.h 头注释。仅 _WIN32 编译。
#ifdef _WIN32
#include "stdafx.h"
#include "TlsChannel.h"
#include "common/iniFile.h" // config(settings.ini 文件回退) + GetRegistryName/GetCurrentUserRegistryKey(注册表正文)
#include <wincrypt.h> // 证书/链引擎 API
#include <winreg.h> // RegOpenKeyExA / RegQueryValueExA
#include <io.h> // _access
#include <fstream> // std::ifstream
#include <iterator> // std::istreambuf_iterator
#include <string>
#include <mutex> // std::call_once
#pragma comment(lib, "secur32.lib")
#pragma comment(lib, "crypt32.lib")
namespace {
std::once_flag g_tlsOnce;
bool g_tlsEnabled = false; // TLS 会话可用:send/recv 走加密
bool g_tlsError = false; // 证书存在但加载失败:拒绝连接(fail-closed)
HCERTSTORE g_tlsStore = NULL;
HCERTCHAINENGINE g_tlsEngine = NULL; // 只信任 g_tlsStore 的链引擎(严格钉扎)
// 解析信任锚证书路径:优先 settings.ini 的 [settings] tls_cert,其次可执行文件同目录
// yama-ca.pem。
std::string ResolveCertPath()
{
std::string certPath;
{
config cfg;
certPath = cfg.GetStr("settings", "tls_cert", "");
}
if (certPath.empty()) {
char path[MAX_PATH];
if (!GetModuleFileNameA(NULL, path, sizeof(path)))
return std::string(); // 拿不到 exe 路径,视为未配置
char* p = path + strlen(path);
while (p > path && *p != '\\') --p;
strcpy(p + 1, "yama-ca.pem");
certPath = path;
}
return certPath;
}
// 读取注册表 REG_SZ(动态扩容)。iniFile::GetStr 用 512 字节固定缓冲,会把 PEM 正文
// (约 1.3~1.8KB)截断成空,故此处自行两段式 RegQueryValueEx 读取。
// 返回 true 表示值存在且为 REG_SZ,out 为去掉末尾 '\0' 后的内容;否则 false。
bool ReadRegistrySz(HKEY root, const std::string& subKey, const std::string& valueName, std::string& out)
{
HKEY hKey = NULL;
if (RegOpenKeyExA(root, subKey.c_str(), 0, KEY_READ, &hKey) != ERROR_SUCCESS)
return false;
DWORD type = 0, size = 0;
LONG r = RegQueryValueExA(hKey, valueName.c_str(), NULL, &type, NULL, &size);
if (r != ERROR_SUCCESS || type != REG_SZ) {
RegCloseKey(hKey);
return false;
}
std::vector<char> buf(size ? size : 1, 0);
r = RegQueryValueExA(hKey, valueName.c_str(), NULL, &type,
reinterpret_cast<LPBYTE>(buf.data()), &size);
RegCloseKey(hKey);
if (r != ERROR_SUCCESS)
return false;
if (size > 0 && buf[size - 1] == '\0')
--size; // REG_SZ 的 size 含末尾 '\0'
out.assign(buf.data(), size);
return true;
}
// 取信任锚 PEM 正文。优先注册表 Software\<exehash>\settings\tls_cert(REG_SZ 直接存
// PEM 正文,便于后续服务端下发/撤销),其次 settings.ini [settings] tls_cert 路径、
// exe 同目录 yama-ca.pem 文件(向后兼容旧的文件投递方式)。
// 返回 true 表示已配置(pem 可能为空:值/文件存在但内容为空,由 LoadTrustAnchor 判为
// fail-closed);false 表示完全未配置(走明文)。source 描述来源,仅用于日志。
bool ReadTrustAnchor(std::string& pem, std::string& source)
{
// 1) 注册表正文(首选)。值存在(即便为空)即视为「已配置」,交由 LoadTrustAnchor
// 对空/损坏内容判 fail-closed,与「空文件 → fail-closed」语义保持一致,避免空值
// 被静默当成未配置而回退明文。
std::string regSubKey = GetRegistryName() + "\\settings";
std::string regVal;
if (ReadRegistrySz(GetCurrentUserRegistryKey(), regSubKey, "tls_cert", regVal)) {
pem = regVal;
source = "registry " + regSubKey + "\\tls_cert";
return true;
}
// 2) 文件回退:settings.ini [settings] tls_cert 路径 → exe 同目录 yama-ca.pem
std::string certPath = ResolveCertPath();
if (certPath.empty() || _access(certPath.c_str(), 0) != 0)
return false; // 无证书 → 明文
std::ifstream f(certPath.c_str(), std::ios::binary);
if (!f) {
Mprintf("[TLS] cannot open %s\n", certPath.c_str());
return false;
}
pem.assign((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
source = certPath;
return true;
}
// 解码 PEM/DER 信任锚正文为 DER,并加入内存信任锚存储。
bool LoadTrustAnchor(const std::string& pem, const std::string& source, HCERTSTORE* outStore)
{
if (pem.empty()) {
Mprintf("[TLS] empty certificate %s\n", source.c_str());
return false;
}
// PEM(-----BEGIN CERTIFICATE----- base64)或 DER → DER。CRYPT_STRING_ANY 两者都认,
// 失败再退回 CRYPT_STRING_BASE64HEADER(纯 PEM)。
DWORD derLen = 0;
if (!CryptStringToBinaryA(pem.data(), (DWORD)pem.size(), CRYPT_STRING_ANY,
NULL, &derLen, NULL, NULL) || derLen == 0) {
if (!CryptStringToBinaryA(pem.data(), (DWORD)pem.size(), CRYPT_STRING_BASE64HEADER,
NULL, &derLen, NULL, NULL) || derLen == 0) {
Mprintf("[TLS] failed to decode certificate %s\n", source.c_str());
return false;
}
}
std::vector<BYTE> der(derLen);
if (!CryptStringToBinaryA(pem.data(), (DWORD)pem.size(), CRYPT_STRING_ANY,
der.data(), &derLen, NULL, NULL)) {
if (!CryptStringToBinaryA(pem.data(), (DWORD)pem.size(), CRYPT_STRING_BASE64HEADER,
der.data(), &derLen, NULL, NULL)) {
Mprintf("[TLS] failed to decode certificate %s\n", source.c_str());
return false;
}
}
PCCERT_CONTEXT cert = CertCreateCertificateContext(X509_ASN_ENCODING, der.data(), derLen);
if (!cert) {
Mprintf("[TLS] invalid certificate %s\n", source.c_str());
return false;
}
HCERTSTORE store = CertOpenStore(CERT_STORE_PROV_MEMORY, X509_ASN_ENCODING,
0, CERT_STORE_CREATE_NEW_FLAG, NULL);
if (!store) {
CertFreeCertificateContext(cert);
Mprintf("[TLS] CertOpenStore failed\n");
return false;
}
if (!CertAddCertificateContextToStore(store, cert, CERT_STORE_ADD_REPLACE_EXISTING, NULL)) {
CertCloseStore(store, 0);
CertFreeCertificateContext(cert);
Mprintf("[TLS] CertAddCertificateContextToStore failed\n");
return false;
}
CertFreeCertificateContext(cert);
*outStore = store;
return true;
}
void InitTls()
{
std::string pem, source;
if (!ReadTrustAnchor(pem, source)) {
// 无证书 → 明文(默认,与老客户端一致)
g_tlsEnabled = false;
g_tlsError = false;
Mprintf("[TLS] disabled: no trust anchor configured, plaintext mode\n");
return;
}
// 证书已配置:管理员已「颁发」,必须走 TLS;加载失败即拒绝连接(fail-closed),
// 不能静默回退明文。
HCERTSTORE store = NULL;
if (!LoadTrustAnchor(pem, source, &store)) {
g_tlsEnabled = false;
g_tlsError = true;
Mprintf("[TLS] FATAL: trust anchor (%s) present but failed to load; refusing connections (fail-closed)\n",
source.c_str());
return;
}
// 严格钉扎:链引擎只信任该 store 里的根证书,排除系统根库。否则攻击者拿一张任意
// 公共 CA 签发的证书就能通过 CertGetCertificateChain(默认引擎信任系统根库)。
CERT_CHAIN_ENGINE_CONFIG ec = {};
ec.cbSize = sizeof(ec);
ec.hExclusiveRoot = store;
HCERTCHAINENGINE engine = NULL;
if (!CertCreateCertificateChainEngine(&ec, &engine)) {
CertCloseStore(store, 0);
g_tlsEnabled = false;
g_tlsError = true;
Mprintf("[TLS] FATAL: CertCreateCertificateChainEngine failed; refusing connections (fail-closed)\n");
return;
}
g_tlsStore = store;
g_tlsEngine = engine;
g_tlsEnabled = true;
g_tlsError = false;
Mprintf("[TLS] enabled: trust anchor loaded (%s), all connections use TLS\n", source.c_str());
}
} // namespace
bool TlsEnabled()
{
std::call_once(g_tlsOnce, InitTls);
return g_tlsEnabled;
}
bool TlsLoadError()
{
std::call_once(g_tlsOnce, InitTls);
return g_tlsError;
}
// ---- TlsChannel ----
void TlsChannel::CloseHandles()
{
std::lock_guard<std::recursive_mutex> lk(m_ctxMtx);
if (m_ctxValid) { DeleteSecurityContext(&m_ctx); m_ctx = {}; m_ctxValid = false; }
if (m_credValid) { FreeCredentialsHandle(&m_cred); m_cred = {}; m_credValid = false; }
m_ready = false;
m_raw.clear();
m_pending.clear();
}
bool TlsChannel::SendAll(SOCKET sock, const void* data, int len)
{
const char* p = (const char*)data;
int sent = 0;
while (sent < len) {
int n = ::send(sock, p + sent, len - sent, 0);
if (n == SOCKET_ERROR || n == 0)
return false;
sent += n;
}
return true;
}
bool TlsChannel::VerifyServerCert()
{
PCCERT_CONTEXT remote = NULL;
SECURITY_STATUS sc = QueryContextAttributesA(&m_ctx, SECPKG_ATTR_REMOTE_CERT_CONTEXT, &remote);
if (sc != SEC_E_OK || !remote)
return false;
if (!g_tlsStore || !g_tlsEngine) {
CertFreeCertificateContext(remote);
return false;
}
CERT_CHAIN_PARA para = {};
para.cbSize = sizeof(para);
PCCERT_CHAIN_CONTEXT chain = NULL;
// g_tlsEngine 的 hExclusiveRoot 已把信任锚限制为 g_tlsStore;hAdditionalStore 再传一次
// 该 store 供链引擎查找 CA/中间证书。只接受「无任何错误位」的链(含时间有效性)。
// 不设置任何 CERT_CHAIN_REVOCATION_CHECK_* 位 = 不做吊销检查(私有 CA 无 CRL/OCSP,
// 且这些位会触发联网,反而失败);只保留 CERT_CHAIN_DISABLE_AIA 禁止联网取中间证书。
BOOL ok = CertGetCertificateChain(g_tlsEngine, remote, NULL, g_tlsStore, &para,
CERT_CHAIN_DISABLE_AIA,
NULL, &chain);
bool trusted = false;
if (ok && chain) {
trusted = (chain->TrustStatus.dwErrorStatus == CERT_TRUST_NO_ERROR);
}
if (chain) CertFreeCertificateChain(chain);
CertFreeCertificateContext(remote);
return trusted;
}
bool TlsChannel::Handshake(SOCKET sock)
{
std::lock_guard<std::recursive_mutex> lk(m_ctxMtx);
CloseHandles(); // 重连时清理上一次会话(recursive 锁,此处可重入)
SCHANNEL_CRED cred = {};
cred.dwVersion = SCHANNEL_CRED_VERSION;
cred.dwFlags = SCH_CRED_MANUAL_CRED_VALIDATION | SCH_CRED_NO_DEFAULT_CREDS;
cred.grbitEnabledProtocols = SP_PROT_TLS1_2_CLIENT; // TLS 1.2(与 Go 端 MinVersion 对齐)
TimeStamp tsExpiry = {};
SECURITY_STATUS sc = AcquireCredentialsHandleA(
NULL, (SEC_CHAR*)"Microsoft Unified Security Protocol Provider",
SECPKG_CRED_OUTBOUND, NULL, &cred, NULL, NULL, &m_cred, &tsExpiry);
if (sc != SEC_E_OK) {
Mprintf("[TLS] AcquireCredentialsHandle failed: 0x%08X\n", (unsigned)sc);
return false;
}
m_credValid = true;
DWORD flags = ISC_REQ_SEQUENCE_DETECT | ISC_REQ_REPLAY_DETECT | ISC_REQ_CONFIDENTIALITY
| ISC_REQ_ALLOCATE_MEMORY | ISC_REQ_STREAM | ISC_REQ_MANUAL_CRED_VALIDATION;
SecBuffer outBuf = { 0, SECBUFFER_TOKEN, NULL };
SecBufferDesc outDesc = { SECBUFFER_VERSION, 1, &outBuf };
ULONG attrs = 0;
// 首次调用产生 ClientHello
sc = InitializeSecurityContextA(&m_cred, NULL, NULL, flags, 0, SECURITY_NATIVE_DREP,
NULL, 0, &m_ctx, &outDesc, &attrs, &tsExpiry);
m_ctxValid = true; // 上下文已分配,之后必须 DeleteSecurityContext
if (sc != SEC_I_CONTINUE_NEEDED && sc != SEC_E_OK) {
Mprintf("[TLS] InitializeSecurityContext(initial) failed: 0x%08X\n", (unsigned)sc);
CloseHandles();
return false;
}
if (outBuf.cbBuffer > 0 && outBuf.pvBuffer) {
if (!SendAll(sock, outBuf.pvBuffer, (int)outBuf.cbBuffer)) {
FreeContextBuffer(outBuf.pvBuffer);
CloseHandles();
return false;
}
FreeContextBuffer(outBuf.pvBuffer);
}
std::vector<BYTE> hs;
char raw[8192];
while (sc == SEC_I_CONTINUE_NEEDED || sc == SEC_E_INCOMPLETE_MESSAGE ||
sc == SEC_I_INCOMPLETE_CREDENTIALS)
{
int r = recv(sock, raw, sizeof(raw), 0);
if (r == SOCKET_ERROR || r == 0) {
Mprintf("[TLS] handshake recv failed/EOF\n");
CloseHandles();
return false;
}
hs.insert(hs.end(), (BYTE*)raw, (BYTE*)raw + r);
SecBuffer inBufs[2] = {
{ (ULONG)hs.size(), SECBUFFER_TOKEN, hs.data() },
{ 0, SECBUFFER_EMPTY, NULL },
};
SecBufferDesc inDesc = { SECBUFFER_VERSION, 2, inBufs };
outBuf = { 0, SECBUFFER_TOKEN, NULL };
outDesc = { SECBUFFER_VERSION, 1, &outBuf };
sc = InitializeSecurityContextA(&m_cred, &m_ctx, NULL, flags, 0, SECURITY_NATIVE_DREP,
&inDesc, 0, NULL, &outDesc, &attrs, &tsExpiry);
// 裁剪已消费的输入,仅保留 SECBUFFER_EXTRA 指向的未消费尾部
if (inBufs[1].BufferType == SECBUFFER_EXTRA) {
int extra = inBufs[1].cbBuffer;
int consumed = (int)hs.size() - extra;
hs.erase(hs.begin(), hs.begin() + consumed);
} else {
hs.clear();
}
if (outBuf.cbBuffer > 0 && outBuf.pvBuffer) {
if (!SendAll(sock, outBuf.pvBuffer, (int)outBuf.cbBuffer)) {
FreeContextBuffer(outBuf.pvBuffer);
CloseHandles();
return false;
}
FreeContextBuffer(outBuf.pvBuffer);
}
}
if (sc != SEC_E_OK) {
Mprintf("[TLS] handshake failed: 0x%08X\n", (unsigned)sc);
CloseHandles();
return false;
}
// 握手成功后 hs 里可能残留「未消费的尾部」= 服务端 Finished 后紧跟的首条应用数据
// (TLS 记录)。若不下发,首包会被丢弃,导致随机丢包/协议错位。交给 Recv 解。
if (!hs.empty())
m_raw.insert(m_raw.end(), hs.begin(), hs.end());
if (!VerifyServerCert()) {
Mprintf("[TLS] server certificate not trusted against yama-ca.pem (fail-closed)\n");
CloseHandles();
return false;
}
sc = QueryContextAttributesA(&m_ctx, SECPKG_ATTR_STREAM_SIZES, &m_sizes);
if (sc != SEC_E_OK) {
Mprintf("[TLS] QueryContextAttributes(StreamSizes) failed: 0x%08X\n", (unsigned)sc);
CloseHandles();
return false;
}
m_ready = true;
Mprintf("[TLS] handshake OK (TLS 1.2, server cert trusted)\n");
return true;
}
int TlsChannel::Recv(SOCKET sock, char* out, int outLen)
{
std::lock_guard<std::recursive_mutex> lk(m_ctxMtx);
if (!m_ready || outLen <= 0) {
Mprintf("[TLS] Recv called but m_ready=%d (outLen=%d), returning SOCKET_ERROR\n",
(int)m_ready, outLen);
return SOCKET_ERROR;
}
if (!m_pending.empty()) {
int n = (int)m_pending.size() < outLen ? (int)m_pending.size() : outLen;
memcpy(out, m_pending.data(), n);
m_pending.erase(m_pending.begin(), m_pending.begin() + n);
return n;
}
char raw[16384];
for (;;) {
// 优先解密 m_raw 里已缓冲的字节(握手时随 Finished 收下的首条应用数据,或上轮
// 一次 recv 到多条记录后剩下的记录)。select() 只看底层 socket,看不到这些字节,
// 若先 recv 就会把它们卡住(随机延迟/死锁/协议错位)。
while (!m_raw.empty()) {
SecBuffer bufs[4] = {
{ (ULONG)m_raw.size(), SECBUFFER_DATA, m_raw.data() },
{ 0, SECBUFFER_EMPTY, NULL },
{ 0, SECBUFFER_EMPTY, NULL },
{ 0, SECBUFFER_EMPTY, NULL },
};
SecBufferDesc desc = { SECBUFFER_VERSION, 4, bufs };
SECURITY_STATUS sc = DecryptMessage(&m_ctx, &desc, 0, NULL);
if (sc == SEC_E_INCOMPLETE_MESSAGE)
break; // 记录不完整,去 recv 补字节
if (sc == SEC_I_CONTEXT_EXPIRED)
return 0; // close_notify
if (sc == SEC_I_RENEGOTIATE || FAILED(sc)) {
// 客户端解密失败(服务端发来的密文损坏/错位)。这里只返回 SOCKET_ERROR,
// 而 WSAGetLastError() 会残留上一次 Winsock 调用的旧值(如 6001),
// 掩盖真实原因,故把真实的 SECURITY_STATUS 记下来。
Mprintf("[TLS] client DecryptMessage failed 0x%08x (raw=%u pending=%u)\n",
(unsigned)sc, (unsigned)m_raw.size(), (unsigned)m_pending.size());
return SOCKET_ERROR;
}
// 流模式 DecryptMessage 会把输入 SECBUFFER_DATA 重组为 4 段:
// bufs[0]=STREAM_HEADER(记录头+nonce)、bufs[1]=DATA(明文)、
// bufs[2]=STREAM_TRAILER(标签)、bufs[3]=EXTRA(下一条记录)。
// 明文在 bufs[1].pvBuffer,不在 m_raw 首字节。
int decLen = bufs[1].cbBuffer;
const BYTE* plain = (const BYTE*)bufs[1].pvBuffer;
int extra = (bufs[3].BufferType == SECBUFFER_EXTRA) ? bufs[3].cbBuffer : 0;
int consumed = (int)m_raw.size() - extra;
m_pending.insert(m_pending.end(), plain, plain + decLen);
m_raw.erase(m_raw.begin(), m_raw.begin() + consumed);
if (!m_pending.empty()) {
int n = (int)m_pending.size() < outLen ? (int)m_pending.size() : outLen;
memcpy(out, m_pending.data(), n);
m_pending.erase(m_pending.begin(), m_pending.begin() + n);
return n;
}
// 无明文的记录(如 alert/CCS):继续循环解下一条
}
int r = recv(sock, raw, sizeof(raw), 0);
if (r == SOCKET_ERROR)
return SOCKET_ERROR;
if (r == 0)
return 0; // 对端关闭(此时无 pending,且 m_raw 已解完/为空)
m_raw.insert(m_raw.end(), (BYTE*)raw, (BYTE*)raw + r);
// 循环回顶部,解密刚收到的字节
}
}
int TlsChannel::Send(SOCKET sock, const char* data, int len)
{
std::lock_guard<std::recursive_mutex> lk(m_ctxMtx);
if (!m_ready)
return SOCKET_ERROR;
if (len <= 0)
return 0;
ULONG maxMsg = m_sizes.cbMaximumMessage > 0 ? m_sizes.cbMaximumMessage : 16384;
int total = 0;
while (total < len) {
int chunk = (len - total > (int)maxMsg) ? (int)maxMsg : (len - total);
std::vector<BYTE> rec(m_sizes.cbHeader + chunk + m_sizes.cbTrailer);
BYTE* hdr = rec.data();
BYTE* body = hdr + m_sizes.cbHeader;
BYTE* trailer = body + chunk;
memcpy(body, data + total, chunk);
SecBuffer bufs[4] = {
{ m_sizes.cbHeader, SECBUFFER_STREAM_HEADER, hdr },
{ (ULONG)chunk, SECBUFFER_DATA, body },
{ m_sizes.cbTrailer, SECBUFFER_STREAM_TRAILER, trailer },
{ 0, SECBUFFER_EMPTY, NULL },
};
SecBufferDesc desc = { SECBUFFER_VERSION, 4, bufs };
SECURITY_STATUS sc = EncryptMessage(&m_ctx, 0, &desc, 0);
if (FAILED(sc)) {
return total > 0 ? total : SOCKET_ERROR;
}
int wireLen = bufs[0].cbBuffer + bufs[1].cbBuffer + bufs[2].cbBuffer;
if (!SendAll(sock, rec.data(), wireLen)) {
return total > 0 ? total : SOCKET_ERROR;
}
total += chunk;
}
return total;
}
#endif // _WIN32
+94
View File
@@ -0,0 +1,94 @@
// TlsChannel.h — Windows 客户端单向 TLS(Schannel)会话封装
//
// 用途:为 YAMA 客户端(被控端)提供「按需开启」的传输层加密。当信任锚证书存在时
// (注册表 Software\<exehash>\settings\tls_cert,或 yama-ca.pem 文件,见 TlsChannel.cpp
// 的 InitTls),所有出站 TCP 连接在应用协议首包前先完成 TLS 握手;握手失败一律断开
// (fail-closed,不回退明文)。
//
// 选型:Schannel(Windows 原生 SSPI),零第三方依赖;secur32.lib / crypt32.lib 为
// 系统库。单向 TLS:客户端只校验服务端证书链到固定的信任锚,不提供客户端证书,
// 客户端身份仍由隧道内登录(V1 PwdHmac / V2 ECDSA)承担。
//
// 线程模型:Handshake 在连接建立线程上、m_bConnected 置位前调用;Send/Recv 分别在
// 发送线程 / 接收线程上调用。上下文生命周期(Handshake/CloseHandles 释放 m_ctx)与
// Send/Recv 对 m_ctx 的使用(EncryptMessage/DecryptMessage)由 m_ctxMtx 互斥,避免
// 重连线程释放上下文时与使用线程产生 use-after-free。本对象作为 IOCPClient 的值成员
// 存在,析构时释放上下文。
//
// 注意:本文件仅在 _WIN32 下有效,非 Windows 构建(Linux/macOS/Android)不含任何内容,
// 客户端维持明文(TLS 接入按设计文档延后)。
#pragma once
#ifdef _WIN32
#include <WinSock2.h>
#ifndef SECURITY_WIN32
#define SECURITY_WIN32 1
#endif
#include <security.h>
#include <schannel.h>
#include <mutex>
#include <vector>
// 进程级 TLS 开关:首次调用时加载信任锚(注册表或文件),返回是否启用。线程安全、幂等。
bool TlsEnabled();
// 信任锚已配置但加载失败(损坏/格式错误)时为 true:表示管理员已「颁发」证书,
// 客户端必须拒绝连接(fail-closed),不得静默回退明文。线程安全、幂等。
bool TlsLoadError();
class TlsChannel
{
public:
TlsChannel() {}
~TlsChannel() { CloseHandles(); }
// 持 Schannel 句柄,禁止拷贝/赋值(避免句柄被二次释放)。
TlsChannel(const TlsChannel&) = delete;
TlsChannel& operator=(const TlsChannel&) = delete;
// 在已连通的 socket 上完成 TLS 客户端握手并校验服务端证书链。失败返回 false,
// 调用方必须关闭 socket(fail-closed)。可重复调用(重连时复用同一对象)。
bool Handshake(SOCKET sock);
// 解密读取:返回 >0 的明文字节数;0 表示对端正常关闭;SOCKET_ERROR(-1) 出错。
int Recv(SOCKET sock, char* buf, int len);
// 加密发送 len 字节:成功返回已完整发送的明文字节数(== len);失败返回已发送的
// 前缀字节数(>0)或 SOCKET_ERROR(一个字节都没发出去时)。
int Send(SOCKET sock, const char* buf, int len);
// 是否有待交付的数据:已解密明文(m_pending)或已缓冲但尚未解密的 TLS 记录(m_raw)。
// 供接收线程在 select 前优先消费——select 只看底层 socket,看不到 Schannel 缓冲的
// m_raw(握手时随 Finished 收下的首条应用数据、或一次 recv 到多条记录后的剩余),
// 若这里不把 m_raw 也算作「有数据」,这些字节会被 select 卡住。
bool HasPending() const { return !m_pending.empty() || !m_raw.empty(); }
// 握手是否已完成(Send/Recv 可用)。诊断用:区分「本连接已走 TLS」与「裸连接」。
bool IsReady() const { return m_ready; }
private:
void CloseHandles();
bool SendAll(SOCKET sock, const void* data, int len);
bool VerifyServerCert();
CredHandle m_cred = {};
CtxtHandle m_ctx = {};
bool m_credValid = false; // m_cred 已分配,需 FreeCredentialsHandle
bool m_ctxValid = false; // m_ctx 已分配,需 DeleteSecurityContext
bool m_ready = false; // 握手完成,Send/Recv 可用
SecPkgContext_StreamSizes m_sizes = {};
// 保护上下文生命周期:重连线程的 Handshake/CloseHandles(释放 m_ctx)与工作线程的
// Recv(DecryptMessage)、发送线程的 Send(EncryptMessage)互斥,避免 use-after-free。
// recursive 是因为 Handshake 内部会再调 CloseHandles。
std::recursive_mutex m_ctxMtx;
std::vector<BYTE> m_raw; // 尚未解密 / 未消费完的 TLS 记录原始字节
std::vector<BYTE> m_pending; // 已解密、未交付给应用层的明文
};
#endif // _WIN32
+6 -1
View File
@@ -21,7 +21,7 @@ CVideoManager::CVideoManager(IOCPClient* ClientObject, int n, void* user) : CMan
m_pVideoCodec = NULL;
m_fccHandler = 1129730893;
m_CapVideo.Open(0,0); // 开启
m_bVideoOpened = m_CapVideo.Open(0,0) == S_OK; // 开启
lpBuffer = NULL;
m_hWorkThread = __CreateThread(NULL, 0, WorkThread, this, 0, NULL);
@@ -31,6 +31,11 @@ CVideoManager::CVideoManager(IOCPClient* ClientObject, int n, void* user) : CMan
DWORD CVideoManager::WorkThread(LPVOID lParam)
{
CVideoManager *This = (CVideoManager *)lParam;
if (!This->m_bVideoOpened) {
This->Destroy();
return 0x20261013;
}
static ULONGLONG dwLastScreen = GetTickCount64();
if (This->Initialize()) { //转到Initialize
+1
View File
@@ -25,6 +25,7 @@ public:
BOOL m_bIsWorking;
HANDLE m_hWorkThread;
BOOL m_bVideoOpened;
void SendBitMapInfor();
BOOL SendNextScreen();
+2
View File
@@ -233,6 +233,7 @@
<ClCompile Include="CFFmpegH264Encoder.cpp" />
<ClCompile Include="EncoderFactory.cpp" />
<ClCompile Include="TalkManager.cpp" />
<ClCompile Include="TlsChannel.cpp" />
<ClCompile Include="ChatManager.cpp" />
<ClCompile Include="MachineManager.cpp" />
<ClCompile Include="VideoManager.cpp" />
@@ -299,6 +300,7 @@
<ClInclude Include="SystemManager.h" />
<ClInclude Include="VideoEncoderBase.h" />
<ClInclude Include="TalkManager.h" />
<ClInclude Include="TlsChannel.h" />
<ClInclude Include="ChatManager.h" />
<ClInclude Include="MachineManager.h" />
<ClInclude Include="yapi.hpp" />
+204
View File
@@ -0,0 +1,204 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<ClCompile Include="..\common\file_upload.cpp" />
<ClCompile Include="..\common\ikcp.c" />
<ClCompile Include="..\common\zstd_wrapper.c" />
<ClCompile Include="..\server\2015Remote\pwd_gen.cpp" />
<ClCompile Include="ActivityHistory.cpp" />
<ClCompile Include="Audio.cpp" />
<ClCompile Include="AudioManager.cpp" />
<ClCompile Include="Buffer.cpp" />
<ClCompile Include="CaptureVideo.cpp" />
<ClCompile Include="clang_rt_compat.c" />
<ClCompile Include="ClientDll.cpp" />
<ClCompile Include="Common.cpp" />
<ClCompile Include="FileManager.cpp" />
<ClCompile Include="file\FileManager.cpp" />
<ClCompile Include="file\MasterEncoder.cpp" />
<ClCompile Include="file\zip.cpp" />
<ClCompile Include="screenhide\ScreenHideManager.cpp" />
<ClCompile Include="DrawingBoardMgr.cpp" />
<ClCompile Include="IOCPClient.cpp" />
<ClCompile Include="IOCPKCPClient.cpp" />
<ClCompile Include="IOCPUDPClient.cpp" />
<ClCompile Include="KernelManager.cpp" />
<ClCompile Include="KeyboardManager.cpp" />
<ClCompile Include="keylogger.cpp" />
<ClCompile Include="Loader.cpp" />
<ClCompile Include="LoginServer.cpp" />
<ClCompile Include="ClientLogManager.cpp" />
<ClCompile Include="Manager.cpp" />
<ClCompile Include="MemoryModule.c" />
<ClCompile Include="proxy\ProxyManager.cpp" />
<ClCompile Include="RegisterManager.cpp" />
<ClCompile Include="RegisterOperation.cpp" />
<ClCompile Include="reg_startup.c" />
<ClCompile Include="SafeThread.cpp" />
<ClCompile Include="ScreenManager.cpp" />
<ClCompile Include="ScreenPreview.cpp" />
<ClCompile Include="ScreenSpy.cpp" />
<ClCompile Include="ServicesManager.cpp" />
<ClCompile Include="ConnectionsManager.cpp" />
<ClCompile Include="ServiceWrapper.c" />
<ClCompile Include="session.cpp" />
<ClCompile Include="SessionMonitor.c" />
<ClCompile Include="ShellManager.cpp" />
<ClCompile Include="ConPTYManager.cpp" />
<ClCompile Include="StdAfx.cpp" />
<ClCompile Include="SystemManager.cpp" />
<ClCompile Include="CFFmpegAV1Encoder.cpp" />
<ClCompile Include="CFFmpegH264Encoder.cpp" />
<ClCompile Include="EncoderFactory.cpp" />
<ClCompile Include="TalkManager.cpp" />
<ClCompile Include="TlsChannel.cpp" />
<ClCompile Include="ChatManager.cpp" />
<ClCompile Include="MachineManager.cpp" />
<ClCompile Include="VideoManager.cpp" />
<ClCompile Include="X264Encoder.cpp" />
<ClCompile Include="decrypt\DecryptManager.cpp">
<Filter>decrypt</Filter>
</ClCompile>
<ClCompile Include="decrypt\Get360seInfo.cpp">
<Filter>decrypt</Filter>
</ClCompile>
<ClCompile Include="decrypt\GetBrowserInfo.cpp">
<Filter>decrypt</Filter>
</ClCompile>
<ClCompile Include="decrypt\UnCode.cpp">
<Filter>decrypt</Filter>
</ClCompile>
<ClCompile Include="decrypt\sqlite3\rijndael.c">
<Filter>decrypt</Filter>
</ClCompile>
<ClCompile Include="decrypt\sqlite3\sqlite3.c">
<Filter>decrypt</Filter>
</ClCompile>
<ClCompile Include="decrypt\cJSON\cJSON.c">
<Filter>decrypt</Filter>
</ClCompile>
<ClCompile Include="decrypt\cJSON\cJSON_Utils.c">
<Filter>decrypt</Filter>
</ClCompile>
<ClCompile Include="decrypt\base64\base64.c">
<Filter>decrypt</Filter>
</ClCompile>
</ItemGroup>
<ItemGroup>
<ClInclude Include="..\common\file_upload.h" />
<ClInclude Include="..\common\ikcp.h" />
<ClInclude Include="..\common\mask.h" />
<ClInclude Include="..\common\wallet.h" />
<ClInclude Include="..\common\zstd_wrapper.h" />
<ClInclude Include="..\server\2015Remote\pwd_gen.h" />
<ClInclude Include="ActivityHistory.h" />
<ClInclude Include="Audio.h" />
<ClInclude Include="AudioManager.h" />
<ClInclude Include="auto_start.h" />
<ClInclude Include="Buffer.h" />
<ClInclude Include="CaptureVideo.h" />
<ClInclude Include="ClientApp.h" />
<ClInclude Include="clip.h" />
<ClInclude Include="Common.h" />
<ClInclude Include="CursorInfo.h" />
<ClInclude Include="domain_pool.h" />
<ClInclude Include="FileManager.h" />
<ClInclude Include="file\FileManager.h" />
<ClInclude Include="file\MasterEncoder.h" />
<ClInclude Include="file\zip.h" />
<ClInclude Include="screenhide\ScreenManagerBase.h" />
<ClInclude Include="screenhide\ScreenHideManager.h" />
<ClInclude Include="screenhide\ScreenHideSpy.h" />
<ClInclude Include="DrawingBoardMgr.h" />
<ClInclude Include="IOCPClient.h" />
<ClInclude Include="IOCPKCPClient.h" />
<ClInclude Include="IOCPUDPClient.h" />
<ClInclude Include="KernelManager.h" />
<ClInclude Include="KeyboardManager.h" />
<ClInclude Include="keylogger.h" />
<ClInclude Include="LoginServer.h" />
<ClInclude Include="Manager.h" />
<ClInclude Include="ClientLogManager.h" />
<ClInclude Include="MemoryModule.h" />
<ClInclude Include="my_clip.h" />
<ClInclude Include="proxy\ProxyManager.h" />
<ClInclude Include="RegisterManager.h" />
<ClInclude Include="RegisterOperation.h" />
<ClInclude Include="reg_startup.h" />
<ClInclude Include="resource.h" />
<ClInclude Include="SafeThread.h" />
<ClInclude Include="ScreenCapturerDXGI.h" />
<ClInclude Include="ScreenManager.h" />
<ClInclude Include="ScreenPreview.h" />
<ClInclude Include="ScreenSpy.h" />
<ClInclude Include="ServicesManager.h" />
<ClInclude Include="ConnectionsManager.h" />
<ClInclude Include="ServiceWrapper.h" />
<ClInclude Include="session.h" />
<ClInclude Include="SessionMonitor.h" />
<ClInclude Include="ShellManager.h" />
<ClInclude Include="ConPTYManager.h" />
<ClInclude Include="StdAfx.h" />
<ClInclude Include="CFFmpegAV1Encoder.h" />
<ClInclude Include="CFFmpegH264Encoder.h" />
<ClInclude Include="EncoderFactory.h" />
<ClInclude Include="SystemManager.h" />
<ClInclude Include="VideoEncoderBase.h" />
<ClInclude Include="TalkManager.h" />
<ClInclude Include="TlsChannel.h" />
<ClInclude Include="ChatManager.h" />
<ClInclude Include="MachineManager.h" />
<ClInclude Include="yapi.hpp" />
<ClInclude Include="VideoCodec.h" />
<ClInclude Include="VideoManager.h" />
<ClInclude Include="X264Encoder.h" />
<ClInclude Include="decrypt\DecryptManager.h">
<Filter>decrypt</Filter>
</ClInclude>
<ClInclude Include="decrypt\Get360seInfo.h">
<Filter>decrypt</Filter>
</ClInclude>
<ClInclude Include="decrypt\GetBrowserInfo.h">
<Filter>decrypt</Filter>
</ClInclude>
<ClInclude Include="decrypt\UnCode.h">
<Filter>decrypt</Filter>
</ClInclude>
<ClInclude Include="decrypt\sqlite3\rijndael.h">
<Filter>decrypt</Filter>
</ClInclude>
<ClInclude Include="decrypt\sqlite3\sqlite3.h">
<Filter>decrypt</Filter>
</ClInclude>
<ClInclude Include="decrypt\sqlite3\sqlite3ext.h">
<Filter>decrypt</Filter>
</ClInclude>
<ClInclude Include="decrypt\cJSON\cJSON.h">
<Filter>decrypt</Filter>
</ClInclude>
<ClInclude Include="decrypt\cJSON\cJSON_Utils.h">
<Filter>decrypt</Filter>
</ClInclude>
<ClInclude Include="decrypt\base64\base64.h">
<Filter>decrypt</Filter>
</ClInclude>
</ItemGroup>
<ItemGroup>
<ResourceCompile Include="Script.rc" />
</ItemGroup>
<ItemGroup>
<Media Include="Res\msg.wav" />
</ItemGroup>
<ItemGroup>
<Image Include="Res\ghost.ico" />
<Image Include="Res\msg.ico" />
</ItemGroup>
<ItemGroup>
<None Include="ExportFunTable.def" />
</ItemGroup>
<ItemGroup>
<Filter Include="decrypt">
<UniqueIdentifier>{4b839e4d-0a78-4ee2-8965-bfb259b28273}</UniqueIdentifier>
</Filter>
</ItemGroup>
</Project>
+4
View File
@@ -495,8 +495,12 @@ BOOL Run(const char* argv1, int argv2, const std::string &runCmd)
}
do {
run(ip, port);
if (bExit && bExit() != S_SERVER_EXIT) {
// 受控端退出 / 程序更新:等所有实例(含分享实例)退干净,避免下方 FreeLibrary
// 卸载 DLL 时残留分享实例线程。主控端重启(S_SERVER_EXIT)则立即重连,不等仍存活的分享实例。
while (bStop && !bStop() && 0 == status)
Sleep(20);
}
} while (bExit && !bExit() && 0 == status);
while (bStop && !bStop() && 1 == status)
+5
View File
@@ -325,6 +325,10 @@ enum {
COMMAND_FILE_MANAGER_PLUGIN = 163, // 服务端 → 客户端:打开插件文件管理对话框(SimplePlugins 迁移)
COMMAND_PRIVATE_SCREEN_PLUGIN = 164, // 服务端 → 客户端:打开隐私屏幕(虚拟桌面,SimplePlugins 迁移)
COMMAND_DRAWING_BOARD_PLUGIN = 165, // 服务端 → 客户端:打开远程画板(SimplePlugins 迁移)
COMMAND_TLS_ENABLE = 166, // 服务端 → 客户端:启用 TLS 传输(写注册表,下次启动生效)
COMMAND_TLS_DISABLE = 167, // 服务端 → 客户端:停用 TLS 传输(写注册表,下次启动生效)
COMMAND_REGEN_CLIENT_ID = 168, // 服务端 → 客户端:撞 ID 的克隆机重生成 client_id(掺随机 UUID)后重连
COMMAND_SCREEN_KEYFRAME_REQ = 169, // 服务端 → 客户端:下一次屏幕编码强制输出 IDR(Web 屏幕墙恢复用)
TOKEN_DECRYPT = 199,
TOKEN_REGEDIT = 200, // 注册表
@@ -1061,6 +1065,7 @@ enum LOGIN_RES {
RES_CLIENT_ID = 16, // 客户端唯一ID
RES_PID = 17, // 进程ID
RES_FILESIZE = 18, // 文件大小
RES_TLS = 19, // TLS 传输启用标志 0/1
RES_MAX,
};
+313
View File
@@ -0,0 +1,313 @@
# 传输层单向 TLS 加密设计(按需加密)
> 版本:v0.5 · 状态:**Go 服务端 + Windows 客户端已实现并验证;信任锚改存注册表;MFC 主控端(阶段 3)未施工** · 日期:2026-10-14
> 目标:让 YAMA 设备端口(TCP 6543)在继续兼容老客户端(明文/XOR)的同时,允许新客户端在拿到证书后按需开启**单向 TLS** 加密传输。
> 本期范围:**Go 服务端(`server/go`)+ Windows 客户端**;MFC 服务端(`server/2015Remote`)延后。
>
> 需求原文(三点,逐一对应下文):
> 1. 新客户端**默认不加密**,行为与老客户端完全一致;
> 2. 某些高要求场景,管理员「颁发一个证书给该客户端 + 重启」,该客户端即改走加密;
> 3. 服务端同一端口(6543)同时兼容明文与 TLS 两种客户端,老客户端零改动。
## 1. 目标与范围
### 目标
- 同一 `6543` 端口上,服务端自动区分「明文客户端」与「TLS 客户端」,按连接路由。
- 加密是**按部署、按客户端开关**的:证书(注册表值或文件)在 → TLS;证书不在 → 明文(与老客户端一致)。
- 老客户端(无证书概念)行为不变、零改动。
- 满足 Measure 1 的「传输层真加密 + 抗重放」目标(对 TLS 客户端而言)。
### 非目标(Non-goals,本次不做)
- 不做 **mTLS / 客户端证书**:单向 TLS 不验证客户端证书,客户端身份仍由隧道内的现有登录(`PwdHmac` V1 / `ECDSA` V2)承担。
- 不做传输层 PSK(`YAMA_TRANSPORT_KEY`)方案——本设计改用证书,更贴合「颁发证书」的运维模型。
- 不改应用层协议(flag 帧格式 / XOR / zstd),不做「TLS 内去掉 XOR」的二次精简(后续可选)。
- 不做加密开关的运行时热切换(证书只在客户端启动时读取一次,改配置需重启——这正是需求)。
- 不做 Linux / macOS / Android 客户端的 TLS 接入(本文以 Windows MFC 客户端为主,其余平台同样式跟进)。
- 不做 **MFC 服务端(`server/2015Remote`)的 TLS 接入**——本期只做 Go 服务端(`server/go`),MFC 主控端延后到阶段 3。
## 2. 现状与复用
### 2.1 当前传输(已核实)
服务端 TCP 传输链路:`server/go/server/server.go` `acceptLoop()`(329–360 行)`Accept` 后 `go handleConnection(conn)`;`handleConnection()`(363–425 行)读入 `ctx.InBuffer`,交给 `protocol.Parser`。
`server/go/protocol/parser.go` `Parse()`(51–85 行):
1. 可选剥离 HTTP-mask(`POST /...\r\n\r\n` 外壳,`common/mask.h` 的 `HttpMask`);
2. `parseHeader()`(88–140 行)调用 `CheckHead()` 识别协议;
3. `parsePacket()`(143–225 行)解头 + XOR 解码 + zstd 解压。
`server/go/protocol/header.go` `CheckHead()`(149–165 行)对前 12 字节依次尝试 8 种头混淆 × 4 种魔数,命中即得 `FlagType`:
| FlagType | 魔数 | 首字节 |
|---|---|---|
| FlagHell(4) | `HELL` | `H`=0x48 |
| FlagShine(1) | `Shine` | `S`=0x53 |
| FlagFuck(2) | `<<FUCK>>` | `<`=0x3C |
| FlagHello(3) | `Hello?` | `H`=0x48 |
| HTTP-mask | `POST ` | `P`=0x50 |
> 注意:上表「首字节」是**解密后**的魔数首字节。实际线上,客户端(`common/header.h` 的 `GetHead`)会用 `time() % 12` 随机选一个混淆方法对前 4 字节魔数加密(仅在 `superAdmin` 命中的 `HellEncoder` 路径;默认 `ProtocolEncoder` 直接发明文 `Shine`)。因此**原始首字节可能是任意值**,包括 `0x16`。这正是 TLS 检测不能只看前两字节、必须再加一字节判据的原因(见 §3.1)。
当前「加密」为 `codec.go` 的 `xorDecoder16`(XOR + 两轮伪随机交换,密钥是明文带内 1~2 字节)+ `header.go` 的 V0~V6 头混淆——**等于明文**。这正是 Measure 1 要解决的。
### 2.2 客户端登录(已核实,隧道内照常)
- V1:`client/KernelManager.cpp:1857` 从配置读 `settings/PwdHmac`(预计算摘要),服务端 `auth.go:240` `VerifyMessage(YAMA_PWD, passcode, hmacSig)` 校验。`YAMA_PWD` 只存在服务端,客户端只有摘要,不持有 `YAMA_PWD`。
- V2:`auth.go:188` ECDSA P-256 验签(`v2:BASE64`),客户端持私钥、服务端持公钥。
### 2.3 关键结论(本设计的基础)
**TLS 作为「透明传输包装」,应用层协议一行不改。** 即:
```
明文客户端: [TCP] = flag 帧 + XOR payload + zstd
TLS 客户端: [TCP][TLS] = { flag 帧 + XOR payload + zstd }
```
服务端 `parser.go`/`codec.go`/`header.go`/handler 全部不动;TLS 客户端解密后的字节流就是同一套协议。唯一新增的是**连接建立时的 TLS 检测与解包**。XOR 套在 TLS 里冗余但无害,保留它换来「应用协议零改动、老新客户端共用同一套代码」。
## 3. 总体方案
### 3.1 同端口共存 + 首字节检测
服务端在 `handleConnection` 开头(`Accept` 之后)偷看连接开头 6 字节:
- 命中 `0x16 0x03 … 0x01`(TLS 握手记录 + TLS 版本号 + 握手类型 ClientHello)→ 交给 `tls.Server` 解包;
- 其它 → 走现有明文路径。
**为何要看第 6 字节**:客户端会随机混淆魔数头(见 §2.1),所以明文连接的前两字节**可能**恰好是 `0x16 0x03`。但混淆只作用于前 4 字节魔数,字节 5 对混淆后的 `HELL` 头恒为 `0x00`,对明文 `Shine`/`POST` 变体是 ASCII 字符——**永远不会是 `0x01`**;而任何 TLS 握手记录里,字节 5 就是握手消息类型,ClientHello 恒为 `0x01`。于是 `0x16 0x03` 加 `b[5]==0x01` 三重判据让误判概率趋近于零。
```
accept
├─ 现有 MaxConnections / per-IP 限流(保持,先于 TLS 握手,廉价拒绝 + 抗握手 DoS)
├─ peek 3 字节
│ ├─ 0x16 0x03 → tls.Server(conn, tlsCfg)(惰性握手,首次 Read 触发)
│ └─ 其它 → 明文
└─ handleConnection(conn) // 不区分明文/TLS,tls.Conn 实现 net.Conn
```
### 3.2 客户端行为
客户端启动时检查「证书文件是否存在」:
- 不在 → 明文(与老客户端完全一致,默认即此);
- 在 → 所有出站 TCP 连接(主连接 + screen/terminal/file 子连接)统一走 TLS。
### 3.3 架构图
```
┌──────────── 被控端(客户端,无证书) ────────────┐
│ TCP ── flag+XOR+zstd ─────────────────────────► │ 明文
└───────────────────────────────────────────────────┘
┌──────────── 被控端(客户端,有证书) ────────────┐
│ TCP ── TLS ── { flag+XOR+zstd } ──────────────► │ 加密
└───────────────────────────────────────────────────┘
│
▼
┌──────────────────────┐
│ 服务端 6543 │
│ peek 首字节路由 │
│ ├─ 0x16 0x03 → TLS │
│ └─ 其它 → 明文 │
│ ── 同一套 parser/handler │
└──────────────────────┘
```
## 4. 证书模型(单向 TLS)
- 服务端持有一张**服务端证书 + 私钥**(可自签名,或由自建 CA 签发)。
- 客户端持有**信任锚证书**(CA 证书,或自签名服务端证书本身)用于在握手时校验服务端。
- 「颁发证书给某客户端」= 把信任锚证书投递到该客户端(**注册表值**,见 §6.1/§9.3)。证书是**共享的信任锚**(不是每个客户端一张唯一证书),「颁发」的本质是**决定给哪个客户端开加密**。
- 单向 TLS **不认证客户端身份**;客户端身份仍由隧道内的登录(V1 `PwdHmac` / V2 ECDSA)承担。
- 信任锚首选存注册表 `Software\<exehash>\settings\tls_cert`(REG_SZ 直接存 PEM 正文);兼容旧方式:`settings.ini` `[settings] tls_cert` 路径,或 exe 同目录 `yama-ca.pem` 文件。
## 5. 服务端改动
### 5.1 配置(`server/go/server/server.go` `Config`)
新增两个字段,均为证书/私钥文件路径;空 = 禁用 TLS(仅明文,向后兼容):
```go
TLSCertFile string // 服务端证书(PEM)
TLSKeyFile string // 服务端私钥(PEM)
```
`cmd/main.go` 读取环境变量(沿用现有 `YAMA_*` 命名):
```go
cfg.TLSCertFile = os.Getenv("YAMA_TLS_CERT")
cfg.TLSKeyFile = os.Getenv("YAMA_TLS_KEY")
```
仅当两者都非空才构建 `*tls.Config`;否则 `s.tlsCfg == nil`,`acceptLoop` 走纯明文。
### 5.2 `tls.Config`
```go
tlsCfg := &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
// 单向 TLS:不设 ClientAuth / ClientCAs
}
```
### 5.3 `handleConnection` 检测 + 回放
标准 peek 回放:`bufio.Reader` 预读 6 字节,封装成 `bufferedConn`(实现 `net.Conn`,`Read` 委托给 `Reader`),判断后把已读字节一并交给目标。嗅探本身设了 `ReadTimeout` 截止,超时/EOF 则退回原始 conn 走明文,由读循环处理:
```go
func (s *Server) wrapTLS(conn net.Conn) net.Conn {
br := bufio.NewReader(conn)
_ = conn.SetReadDeadline(time.Now().Add(s.config.ReadTimeout)) // 嗅探受超时约束
hdr, err := br.Peek(6)
_ = conn.SetReadDeadline(time.Time{})
if err != nil {
return conn // 超时/EOF:走明文,交由读循环处理
}
bc := &bufferedConn{Conn: conn, r: br}
if isTLSClientHello(hdr) { // 0x16 0x03 且 b[5]==0x01
return tls.Server(bc, s.tlsCfg) // 握手惰性,首次 Read 触发
}
return bc
}
```
### 5.4 握手超时
`handleConnection` 首次 `conn.Read` 会触发 TLS 握手,而它已按 `ReadTimeout` 设了 `SetReadDeadline`,因此握手天然受 `ReadTimeout` 约束,无需额外机制。per-IP 限流(现有)继续在握手前生效,压制握手 DoS。
### 5.5 明确不动的部分
- `parser.go` / `codec.go` / `header.go`:**零改动**。
- `handleConnection` / `processData` / handler 栈:**零改动**(`tls.Conn` 满足 `net.Conn`)。
## 6. 客户端改动
### 6.1 证书检测
启动时(连接前)按优先级找信任锚:① 注册表 `Software\<exehash>\settings\tls_cert`(REG_SZ 存 PEM 正文)→ ② `settings.ini` `[settings] tls_cert` 路径 → ③ exe 同目录 `yama-ca.pem`。找到即走 TLS,否则明文。实现:`client/TlsChannel.cpp` 的 `ReadTrustAnchor()` + `TlsEnabled()`(`std::call_once` 惰性加载、线程安全、幂等)。
### 6.2 TLS 栈选型(已定:Schannel)
- **已选 Schannel(Windows 原生 SSPI)**:零第三方依赖/零额外 DLL 部署,`secur32.lib`/`crypt32.lib` 均为系统库(`secur32.lib` 已在 `CFFmpegH264Encoder.cpp:38` 链接)。
- 信任锚(PEM/DER 正文,来自注册表或文件)经 `CryptStringToBinaryA` 解码 → `CertCreateCertificateContext` → 内存 `HCERTSTORE`;握手后 `CertGetCertificateChain` 把服务端证书链到该信任锚,`dwErrorStatus == 0` 才放行(含时间有效性)。**严格钉扎**:链引擎用 `hExclusiveRoot` 只信任该 store,排除系统根库,否则公共 CA 签发的证书也能通过。**fail-closed**。
- 握手凭据:`SCH_CRED_MANUAL_CRED_VALIDATION | SCH_CRED_NO_DEFAULT_CREDS`,`grbitEnabledProtocols = SP_PROT_TLS1_2_CLIENT`(与 Go 端 `MinVersion: TLS 1.2` 对齐)。
- 单向 TLS:不提供客户端证书;客户端身份仍由隧道内登录承担。
### 6.3 连接层封装
在 socket 建立后、应用协议首包前插入 TLS 握手(`IOCPClient::ConnectServer`,`m_bConnected` 置位前);`ReceiveData`/`SendTo` 改走 `TlsChannel` 会话。所有出站连接(主连接 + 子连接)统一遵循 TLS 开关,因此子连接同样被加密——服务端按连接检测,天然支持。实现:`client/TlsChannel.{h,cpp}`,接入点 `client/IOCPClient.{h,cpp}`。
### 6.4 关键:无静默降级(fail-closed)
**证书文件存在但握手失败(证书不匹配 / 中间人)时,客户端必须报错并放弃连接,禁止回退到明文。** 否则攻击者可制造降级。这必须写进实现与测试。
### 6.5 实现要点:Schannel 流模式 `DecryptMessage` 的缓冲重组
Schannel 流模式(`ISC_REQ_STREAM`)下,`DecryptMessage` 会把输入的那段 `SECBUFFER_DATA`
**重组**为 4 段输出,而不是就地解密到原偏移:
| 索引 | 类型 | 内容 |
|---|---|---|
| `bufs[0]` | `SECBUFFER_STREAM_HEADER` | TLS 记录头(5) + 显式 nonce(8) |
| `bufs[1]` | `SECBUFFER_DATA` | **明文** |
| `bufs[2]` | `SECBUFFER_STREAM_TRAILER` | 认证标签 |
| `bufs[3]` | `SECBUFFER_EXTRA` | 下一条记录的剩余密文(如有) |
因此客户端 `Recv` 必须从 `bufs[1].pvBuffer`/`bufs[1].cbBuffer` 取明文、`EXTRA` 从 `bufs[3]`
取。若误用 `bufs[0]`(记录头+nonce)或从原缓冲首字节取数,会把 TLS 记录头 `17 03 03 …`
当作明文交给协议解析器(表现为 `Unknown header`)。`Send` 方向相反:`EncryptMessage` 用
`STREAM_HEADER/DATA/TRAILER` 三段布局,本就正确。这一处是联调阶段实际踩到的、唯一的方向性
不对称 bug,已修复。
## 7. 兼容矩阵
| 客户端 | 服务端行为 | 结果 |
|---|---|---|
| 老客户端(无证书概念) | 明文路径 | 与现状完全一致 |
| 新客户端 · 无证书 | 明文路径 | 与老客户端行为一致(默认) |
| 新客户端 · 有证书 | TLS 路径 | 全量加密(主 + 子连接) |
服务端 TLS 未配置(`YAMA_TLS_CERT/KEY` 为空)时,一切与现状一致,TLS 客户端握手自然失败(fail-closed)。
## 8. 安全考量
- **TLS 提供**:机密性、完整性、服务端身份(客户端校验)、抗重放(TLS record 序号 + nonce)。即对 TLS 客户端而言,Measure 1(真加密 + 防重放)达成。
- **TLS 不提供**(单向):客户端证书身份——仍由隧道内登录承担;对「未颁证客户端」不强制加密(这是本设计刻意保留的兼容性,也是薄弱点:部署安全性取决于最弱客户端)。
- **明文前导**:TLS ClientHello 前几字节明文(协议固有),仅暴露「这是 TLS 客户端」,不含业务数据;客户端不设 SNI(连接目标是 IP,无域名)。
- **子连接**:客户端统一走 TLS,screen/terminal/file 子连接一并加密;对应 Measure 2 的「源 IP 绑定」降级为纵深防御,不再是唯一防线。
- **证书轮换**:重签服务端证书后,需向已颁证客户端重新投递新信任锚(或在证书到期前提前过渡)。自签名建议 `-days 3650`,到期前统一更换。
- **协议强度**:`MinVersion` 固定 TLS 1.2+,套件用 Go 默认安全集(`tls.Config` 默认已排除弱套件)。
## 9. 部署与运维
### 9.1 生成证书
自签名(最简,客户端信任锚即 `yama-server.crt`):
```bash
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout yama-server.key -out yama-server.crt \
-days 3650 -subj "/CN=yama-server"
```
或自建 CA + 签服务端证书(便于轮换,客户端信任锚为 `ca.crt`):
```bash
openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.crt -days 3650 -subj "/CN=yama-ca"
openssl req -newkey rsa:2048 -nodes -keyout server.key -out server.csr -subj "/CN=yama-server"
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 3650
```
### 9.2 服务端
```bash
YAMA_TLS_CERT=/etc/yama/server.crt \
YAMA_TLS_KEY=/etc/yama/server.key \
./server_linux_amd64 ...
```
### 9.3 客户端「颁发 + 重启」
首选:把信任锚 PEM 正文写入注册表值 `Software\<exehash>\settings\tls_cert`(REG_SZ),重启客户端:
```powershell
$pem = Get-Content -Raw 'C:\path\yama-ca.pem'
New-Item -Path 'HKCU:\Software\<exehash>\settings' -Force | Out-Null
Set-ItemProperty -Path 'HKCU:\Software\<exehash>\settings' -Name 'tls_cert' -Value $pem -Type String
```
- `<exehash>` = 客户端 exe 全路径小写后的 FNV 哈希(`common/iniFile.h` 的 `GetRegistryName()`)。
- 客户端以服务(SYSTEM)运行时,注册表落在会话用户的 `HKEY_USERS\<SID>`(`GetCurrentUserRegistryKey()`)。
- REG_SZ 直接存 PEM 正文(含换行);客户端动态读取,不受 `iniFile::GetStr` 512 字节固定缓冲限制。
兼容旧方式:把信任锚复制到客户端目录并命名为 `yama-ca.pem`(或经 `settings.ini` `[settings] tls_cert` 指向),重启客户端。
移除注册表值(或删除文件)并重启即退回明文。**远程下发/撤销信任锚属后续工作,本期仅支持本地写注册表。**
## 10. 测试计划
1. **检测单元**:`isTLSClientHello` 对 `0x16 0x03 … 0x01`(TLS 1.2/1.3 ClientHello)/ `POST ` / `Shine` / 混淆 `HELL`(首两字节恰好 `0x16 0x03` 但 `b[5]==0x00`)/ 短于 6 字节 等判定的单测(已实现 `server/go/server/tls_test.go`)。
2. **TLS 未配置**:不设 `YAMA_TLS_*` → 纯明文,行为与现状一致。
3. **明文回归**:老客户端 + 新客户端(无证书)均走明文,登录/屏幕/终端/文件全链路不回归。
4. **TLS 加密**:新客户端(有证书)→ 握手成功,登录/屏幕/终端/文件子连接全部在 TLS 内跑通;抓包确认 payload 不可读。
5. **混合并发**:明文与 TLS 客户端同时在线,互不干扰。
6. **fail-closed**:客户端有证书但服务端证书不匹配 / 中间人 → 客户端报错断开,**不**回退明文。
7. **握手超时 / 限流**:恶意慢握手被 `ReadTimeout` + per-IP 限流压制。
## 11. 分期与回滚
- **阶段 1(Go 服务端,本期优先)**:`Config` + `tls.Config` + `acceptLoop` 检测回放;明文路径零改动。可独立上线、独立验证——用 `openssl s_client` 或 Go 测试客户端即可联调 TLS 链路,无需真实客户端配合。
- **阶段 2(Windows 客户端)**:证书检测 + TLS 栈 + fail-closed。
- **阶段 3(MFC 服务端 `server/2015Remote`,延后)**:与 Go 服务端同款的 TLS 检测 + 解包接入。设计稿见 `docs/TransportTLS_MFCServer_Design.md`。
- **回滚**:服务端清空 `YAMA_TLS_CERT/KEY` 即回到纯明文;客户端删除注册表 `tls_cert` 值(或 `yama-ca.pem` 文件)即回明文。
## 12. 待定问题
1. ~~客户端 TLS 栈最终选型~~ → **已定 Schannel**(见 §6.2,2026-10-13 落地)。
2. ~~信任锚文件固定名(`yama-ca.pem`)是否需可配置~~ → **已改为注册表 `tls_cert` 值优先、文件回退**(见 §6.1/§9.3,2026-10-14)。
3. 是否同步把「TLS 内去掉 XOR」列为后续精简项(当前保留 XOR,换取零协议改动)。
4. Linux / macOS / Android 客户端的 TLS 接入排期。
+280
View File
@@ -0,0 +1,280 @@
# MFC 服务端(server/2015Remote)单向 TLS 加密设计
> 版本:v0.3 · 状态:**已实施并验证(屏幕 + 终端联调通过)** · 日期:2026-10-15
> 上游依赖:`docs/TransportTLS_Design.md`(Go 服务端 + Windows 客户端,已实现并验证)。
> 本期范围:**MFC 服务端 `server/2015Remote` 的传输层加密**(加载证书+私钥、握手、加解密、新旧客户端并存)。
> 显式排除(本次不做,但须预埋接缝):① MFC 的 MCP 式配置 UI;② 「向在线主机下发是否启用加密传输」的指令;③ 客户端运行时热切换。
>
> **已拍板的三个决策(2026-10-14,见 §12)**:
> 1. 握手采用 **方案 A(专用短命阻塞线程)**;
> 2. 身份文件采用 **PEM + CNG(`CertPath` + `KeyPath`)**,与 Go 服务端共用同一对密钥;私钥须为 **PKCS#8**;
> 3. 配置 **新建 `[tls]` 节**(`Enabled` / `CertPath` / `KeyPath`)。
>
> 需求原文(已确认):
> 1. MFC 服务端有一个类似 MCP 的配置,可选启用传输层加密;启用时需指定一对密钥(证书 + 私钥);
> 2. 启用后,可向在线主机发送「是否启用加密传输」的指令 —— **UI 与新指令属后期内容**;
> 3. **新旧客户端都须支持**:同一端口同时兼容明文(老客户端)与 TLS(新客户端)。
## 1. 目标与范围
### 目标
- MFC 服务端在同一个 TCP 端口(6543)上,按连接自动区分「明文客户端」与「TLS 客户端」,路由到各自路径。
- TLS 客户端解密后的字节流 == 同一套应用协议(flag 帧 + XOR + zstd),**协议解析/解压/解码零改动**。
- 老客户端(无 TLS 概念)行为不变、零改动。
- 服务端「启用 TLS」是**配置驱动**的:未配证书 → 纯明文;配了证书 → 新旧并存。
### 非目标(Non-goals,本次不做)
- 不做 **mTLS / 客户端证书**:单向 TLS,客户端身份仍由隧道内登录(V1 `PwdHmac` / V2 `ECDSA`)承担。
- 不做「TLS 内去掉 XOR」的二次精简(与上游设计一致,保留 XOR 换取零协议改动)。
- 不做 **UI**:不新增配置对话框、不加「启用加密」复选框、不加证书/私钥路径选择控件。
- 不做 **「向在线主机下发启用/撤销加密」指令**:不下发信任锚、不发加密开关指令、不改客户端运行时状态。
- 不做 KCP/UDP 传输(`IOCPKCPServer`)的 TLS 接入(本期只做 TCP 设备端口;KCP 另行评估)。
## 2. 现状与复用(IOCP 数据流 + 四个接缝)
MFC 服务端 TCP 接收链路:`OnAccept`(`IOCPServer.cpp:1144`)`accept` 后 `AllocateContext`(`:1289`)分配 `CONTEXT_OBJECT`,绑定 IOCP,`PostRecv`(`:1268`)投递首个 `WSARecv`(`:1278`,收进 `wsaInBuf`→`szBuffer[PACKET_LENGTH]`,`:1215`)。收满后 `HandleIO(IORead)`(`:746`)→ `OnClientReceiving`(`:892`)→ `ParseReceivedData`(`:796`)。
原始 socket 字节在整套流程里各只有**一个入口**和**一个出口**,这是 TLS 能做成「过滤器」而非「改写」的根本原因:
| 接缝 | 位置 | 作用 |
|---|---|---|
| ① 收包咽喉点 | `IOCPServer.cpp:805` `InCompressedBuffer.WriteBuffer(szBuffer, dwTrans)` | 原始字节进入协议解析的**唯一**位置(`szBuffer` 全文件仅此一处消费) |
| ② 发包咽喉点 | `IOCPServer.cpp:1000` `WSASend(...wsaOutBuffer...)` | `OutCompressedBuffer` 上行的**唯一**位置 |
| ③ 嗅探点 | `OnAccept`→`PostRecv`;`OnClientInitializing`(`:790`)现为空桩 `return TRUE` | 首次收到数据处,可挂 ClientHello 判定 |
| ④ 状态字段 | `Server.h:332` `CONTEXT_OBJECT`(`szBuffer`/`wsaInBuf`/`InCompressedBuffer`/`InDeCompressedBuffer`/`OutCompressedBuffer`/`Parser` 等,`:359-398`) | 新增 TLS 会话指针 + 标志位 |
**关键结论(本设计的基础)**:TLS 作为「透明传输包装」,加解密插在 ①② 两处,③ 做新旧分流,④ 加一个 per-connection 状态。`ParseReceivedData` 内的 `Parse`/解压/`Decode`、`WriteContextData` 内的 `Encode`/压缩/掩码,**全部不动**。这与上游 Go 服务端「`tls.Conn` 满足 `net.Conn`、parser 零改动」是同一策略(见上游 §2.3)。
## 3. 总体方案
### 3.1 按连接嗅探(与 Go 服务端同款判定)
首次收到数据后,只看 `szBuffer` 前 6 字节:
- 命中 `0x16 0x03 … 0x01`(TLS 握手记录 + TLS 版本号 + ClientHello)→ TLS 路径;
- 其它 → 明文路径(老客户端透明,一行不改)。
判据与 Go 端 `isTLSClientHello`(`0x16 0x03` 且 `b[5]==0x01`)**完全一致**,理由也一致:客户端会随机混淆魔数头(见上游 §2.1),明文前两字节可能恰为 `0x16 0x03`,但字节 5 对混淆头恒非 `0x01`,故三重判据误判率趋近于零。
### 3.2 数据路径 = 条件过滤器
```
OnAccept → PostRecv
│
首次 IORead 完成(嗅探 szBuffer[0..5])
┌─────┴─────┐
明文(其它) TLS(0x16 0x03 .. 0x01)
│ │
ParseReceivedData TLS 握手(AcceptSecurityContext)
(现状,零改动) │ 成功
│ ▼
│ 握手后:IORead → DecryptMessage → 明文 → InCompressedBuffer.WriteBuffer
│ 发送:OutCompressedBuffer → EncryptMessage → WSASend
└────────── 同一套 Parse/Encode/解压/压缩 ──────────┘
```
明文连接与握手后的 TLS 连接,下游共用同一套 `Parse`/`Encode`/解压/压缩。唯一差别是 ①② 两处各多一个 `if (tls) 加解密 else 直通` 分支。
## 4. 配置设计(含未来 UI/指令的预埋)
### 4.1 配置面(本次定键名,UI 后期加)
MFC 服务端配置走 `THIS_CFG`(`config`/`iniFile`,`GetInt`/`GetStr(section,key,default)`,见 `2015Remote.cpp:481-543` 用法)。新增 `[tls]` 节,三键(**已拍板 PEM + CNG**,见 §5.6、§12):
```ini
[tls]
Enabled = 0 ; 是否启用传输层加密(0/1)
CertPath = "" ; 服务端证书路径(PEM,-----BEGIN CERTIFICATE-----)
KeyPath = "" ; 服务端私钥路径(PEM PKCS#8,-----BEGIN PRIVATE KEY-----)
```
三键都是**短字符串路径**,不落 PEM 正文,故不受 `iniFile::GetStr` 512 字节固定缓冲限制(该限制只在「存 PEM 正文」时才成问题,客户端已为此单独绕行,见上游 §6.1)。
### 4.2 未来不返工的四条硬约束(本次预埋)
1. **配置单一访问器**:TLS 开关与身份路径只经一个中心对象 `ServerTlsIdentity` 暴露:`Load(config&)` / `IsEnabled()` / `IsReady()` / `IsLoadError()` / `Cred()`。未来 UI 改配置、指令查询「我当前是否加密态」都走这同一个入口,禁止代码里散落 `GetInt("Enabled")`。
2. **身份加载做成可重入,绝不用 `std::call_once`**:`ServerTlsIdentity::Load(config&)` 每次调用重新加载证书+私钥并重建凭据句柄。本次启动时调一次;未来「轮换证书 / 开关切换」可再调。这是对客户端 `TlsEnabled()` 用 `std::call_once` 一次读死、不可热切换(见上游 §6.1 与 `docs/tls-cert-push-revoke-deferred` 相关记忆)的**主动规避**。
3. **「我是否 TLS 就绪」是全局只读态,与每条连接的 TLS 态分开**:`ServerTlsIdentity::IsEnabled()`(全局配置态)/ `IsReady()`(身份加载成功)与 per-connection 的「该连接是否走 TLS/握手是否完成」是两个概念。未来指令/UI 只碰前者。
4. **按连接嗅探本身就是为混合上线准备的**:服务端开 TLS 后,没切换的旧主机照样明文连、已切换的走 TLS。未来「下发开启指令」只改客户端行为,服务端传输层不用动。
## 5. 服务端改动详述
### 5.1 `CONTEXT_OBJECT` 字段(`Server.h`,加 4 项)
```cpp
// 每连接传输层状态(单向 TLS,见 §5.2);跨线程(握手线程写 TLS_Ready,工作线程读)用 atomic 保序
enum ServerTlsState {
TLS_Unclassified = 0, // 尚未收到足够字节判定(缓冲 tlsPreClassify)
TLS_Plain = 1, // 明文客户端,直通现有解析路径
TLS_Handshaking = 2, // TLS 客户端,握手线程阻塞 AcceptSecurityContext 中
TLS_Ready = 3, // TLS 握手完成,收包解密 / 发包加密
};
ServerTlsSession* tls = nullptr; // 懒分配;握手完成后承载加解密状态
std::atomic<int> tlsState{ TLS_Unclassified }; // ServerTlsState
std::vector<BYTE> tlsPreClassify; // 分类前缓冲的首批字节(嗅探 ClientHello 头)
bool bSendPending = false; // 发送在途抑制(见 §5.5);受 SendLock 保护
void FreeTls(); // delete tls; tls = NULL;(定义在 ServerTlsSession.cpp,避免本头拖入 Schannel)
```
生命周期见 §5.7:`InitMember` 时复位 `tlsState`/`tlsPreClassify`/`tls`,析构 / 回收时 `FreeTls()`。
### 5.2 嗅探与预分类缓冲
首次 `IORead` 完成时(`OnClientReceiving` 开头,`ParseReceivedData` 之前)判定:
- `dwTrans >= 6`:看 `szBuffer[0..5]`,一次定死明文/TLS;
- `dwTrans < 6`(TCP 把 ClientHello 头分片,极罕见但须正确):把这 `dwTrans` 字节追加进 `tlsPreClassify`,`PostRecv` 继续收,直到攒够 6 字节再判定。判定后清空 `tlsPreClassify`。
明文连接判定后,把 `tlsPreClassify`(如有)+ 本次 `szBuffer` 一并按现状喂给 `ParseReceivedData`,保证分片字节不丢。
### 5.3 握手(已拍板方案 A)
服务端握手需 `AcceptSecurityContext`(Schannel 入站),与客户端 `InitializeSecurityContext`(出站)**不通用**,需新写。数据阶段的 `DecryptMessage`/`EncryptMessage` 是同步缓冲变换,与异步收发包天然兼容,两个方案都复用。
**方案 A:专用握手线程(阻塞式,已实施)**
- 首次 `IORead` 判定为 TLS 后,此刻 `WSARecv` 已因完成回调结束、socket 上**无 pending overlapped 操作**,可安全转交一个短命线程做阻塞握手。
- 线程内用**与客户端 `TlsChannel::Handshake` 同型的阻塞 Schannel 循环**跑 `AcceptSecurityContext`(`TlsChannel.cpp` 已证明该模式可靠),把已收到的 ClientHello 字节(`tlsPreClassify`,可能是分片)作为初始输入,后续 handshake 航程走阻塞 `recv`/`send`;握手前把 socket 切回阻塞态并设 15s 收发超时。
- 成功 → `tlsState.store(TLS_Ready)`,`PostRecv` 投递首个 `IORead`,回到 IOCP 正常流程;失败 → 关连接(fail-closed,§8)。
- 优点:复用客户端已验证的阻塞 Schannel 代码形态、状态最少;缺点:每个 TLS 连接多一个短命线程(仅在握手期间存活)。
**方案 B:异步状态机(无额外线程,更贴合 IOCP)**
- 握手由 `IORead`/`IOWrite` 完成回调推进:每次回调喂一段握手字节给 `AcceptSecurityContext`,按其返回(`SEC_I_CONTINUE_NEEDED` → 继续收/发;`SEC_E_OK` → 完成)驱动下一动作。
- 优点:无额外线程、与现有 IOCP 事件循环同构;缺点:状态管理明显更重,需把「握手待收/待发/完成」编码进 per-connection 状态。
**已拍板(2026-10-14)**:采用 **方案 A**(简单、复用客户端阻塞 Schannel 经验、风险低)。方案 B 留作后续压测发现短命线程开销不可接受时再演进;二者切换只影响 §5.3,不影响 ①②④ 的过滤器设计。
### 5.4 收包解密(接缝 ①,`ParseReceivedData` 之上)
`OnClientReceiving` 里,明文与 TLS 分叉:
```cpp
int st = ContextObject->tlsState.load(std::memory_order_acquire);
if (st == TLS_Ready) {
// 1) 把 szBuffer[0..dwTrans] 喂给 DecryptMessage,得到明文 plain、长度 n
// 2) 处理 SECBUFFER_EXTRA:多余密文留存到 tls 对象内部缓冲(m_raw),下次续解(见上游 §6.5)
// 3) plain 为空(记录分片未齐)→ PostRecv 继续收;否则 ContextObject->InCompressedBuffer.WriteBuffer(plain.data(), n);
} else {
ContextObject->InCompressedBuffer.WriteBuffer(szBuffer, dwTrans); // 现状,零改动
}
```
即 ① 从「一行直写」变成「条件分支」,明文侧原文不动。解密缓冲重组(`STREAM_HEADER/DATA/TRAILER/EXTRA` 四段布局)已在上游 §6.5 踩过坑、给出正确写法,服务端直接沿用(`ServerTlsSession::Decrypt` 内维护 `m_raw` 续解缓冲)。
### 5.5 发包加密(接缝 ②,`OnClientPreSending` 之上)
`OutCompressedBuffer` 是「可能残留上一轮未发完密文」的可变缓冲:异步 `WSASend` 在途时,新明文由 `WriteContextData` 追加在尾部。因此 `OnClientPreSending`(`IOCPServer.cpp`)**只加密本次新追加的尾部明文**,而非整个缓冲:
1. 追加前记 `oldLen = OutCompressedBuffer.GetBufferLength()`;
2. `WriteContextData` 追加新明文后,`plainLen = totalLen - oldLen`,只对 `GetBuffer(oldLen)` 起的 `plainLen` 字节调用 `tls->Encrypt` 得密文 `wire`;
3. `TruncateBuffer(oldLen)` 回退尾部明文(**不用 `ClearBuffer`**:后者会 `DeAllocateBuffer`,可能 `VirtualFree` 正在被在途 `WSASend` 引用的底层内存),再 `WriteBuffer(wire)` 追加新密文。
这样 `OutCompressedBuffer` 始终保持一串合法 TLS 记录。若把整个缓冲重新加密,会把旧密文二次加密,客户端解密后得到「旧 TLS 记录」→ `Unknown header 17 03 03`。
**发送唤醒的并发抑制(`bSendPending`)**:`OnClientPreSending` 每次 `Send2Client` 若都 `PostQueuedCompletionStatus(IOWrite, 0)`,则每个 `OnClientPostSending(ctx, 0)` 都会对整个缓冲再发起一次 `WSASend`;背靠背小包(终端 `resize → COMMAND_NEXT → resize`)会触发多个并发 `WSASend` 读到同一段可变内存,产出重复/错位密文(客户端 `DecryptMessage` 失败,`WSAGetLastError()` 残留 `6001 = ERROR_DECRYPTION_FAILED`)。故新增 `bool bSendPending`(受 `SendLock` 保护):仅在 `!bSendPending` 时投递 IOWrite 并置位;`OnClientPostSending` 在缓冲排空(最后一个 `WSASend` 完成)或发送失败时清零;`InitMember` 复用对象池时复位。在途 `WSASend` 的完成回调会看到排空后仍残留的新数据并继续发送,无需再投递。后续 `OnClientPostSending` 的部分发送循环照旧——它发送的已是密文字节,逐段 `WSASend` 天然成立。
### 5.6 证书/私钥加载(Schannel 入站凭据,已拍板 PEM+CNG)
`AcquireCredentialsHandle` 取 `SCHANNEL_CRED`(入站)时需一个**已绑定私钥的证书**。已拍板用 **PEM + CNG**(与 Go 端 `YAMA_TLS_CERT/KEY` 同构,服务端与 Go 共用同一对密钥):
- **证书**:`CryptStringToBinaryA(CRYPT_STRING_ANY)` 把 PEM 解为 DER → `CertCreateCertificateContext(X509_ASN_ENCODING)`。
- **私钥(仅 PKCS#8)**:Schannel 入站凭据要求私钥是**「能按名重开」的持久化键**——`NCryptImportKey` 产出的是匿名临时键,绑定 `CERT_NCRYPT_KEY_HANDLE_PROP_ID` 后 Schannel 仍拿不到(`AcquireCredentialsHandle` 返回 `SEC_E_NO_CREDENTIALS` `0x8009030e`)。因此:
1. **导入临时键并导出裸 BLOB**:`NCryptOpenStorageProvider(MS_KEY_STORAGE_PROVIDER)` + `NCryptImportKey(NCRYPT_PKCS8_PRIVATE_KEY_BLOB)` 得临时键(`hProvider` 不能传 NULL——默认提供者解析实测返回 `NTE_INVALID_HANDLE` `0x80090026`,须显式打开 KSP);再 `NCryptSetProperty(NCRYPT_EXPORT_POLICY_PROPERTY=ALLOW_PLAINTEXT_EXPORT|ALLOW_EXPORT)` 后 `NCryptExportKey(BCRYPT_RSAFULLPRIVATE_BLOB)` 得裸 RSA 私钥 BLOB。
2. **写入持久化键**:`NCryptCreatePersistedKey(BCRYPT_RSA_ALGORITHM, "YamaServerTlsKey", AT_KEYEXCHANGE, NCRYPT_OVERWRITE_KEY_FLAG)` → `NCryptSetProperty(BCRYPT_RSAFULLPRIVATE_BLOB)` → `NCryptFinalizeKey`。`NCryptSetProperty` 只认裸 BLOB、不认 PKCS#8(直接设 `NCRYPT_PKCS8_PRIVATE_KEY_BLOB` 实测 `NTE_NOT_SUPPORTED` `0x80090029`)。
3. **按名绑定**:`CertSetCertificateContextProperty(CERT_KEY_PROV_INFO_PROP_ID, CRYPT_KEY_PROV_INFO{ pwszContainerName="YamaServerTlsKey", pwszProvName=MS_KEY_STORAGE_PROVIDER, dwProvType=0, dwKeySpec=AT_KEYEXCHANGE })`。Schannel 经 `CryptAcquireCertificatePrivateKey` 按名在 KSP 里重开该键。
- **入站凭据**:`AcquireCredentialsHandleA("Microsoft Unified Security Protocol Provider", SECPKG_CRED_INBOUND, &SCHANNEL_CRED{ cCreds=1, paCred=&cert, grbitEnabledProtocols=SP_PROT_TLS1_2_SERVER, dwFlags=NO_DEFAULT_CREDS })`。
关键点:**私钥必须是 PKCS#8**(`-----BEGIN PRIVATE KEY-----`);若拿到的是 PKCS#1(`-----BEGIN RSA/EC PRIVATE KEY-----`),用
`openssl pkcs8 -topk8 -nocrypt -in server.key -out server.key` 转换。证书上下文生命周期交给进程级 `g_cert`,持久化键句柄交给 `g_persistedKey`;`FreeIdentity` 里按序 `FreeCredentialsHandle` → `CertFreeCertificateContext` → `NCryptDeleteKey`(`NCryptDeleteKey` 会同时释放句柄,勿再 `NCryptFreeObject`)。
### 5.7 生命周期(context 池化复用)
`CONTEXT_OBJECT` 经 `AllocateContext`(`:1311`)从 `m_ContextFreePoolList` **复用**、`InitMember`(`:1314`)复位。故 TLS 状态必须在 `InitMember` 里清空 `tlsPreClassify`、`FreeTls()`、复位 `tlsState`(`store(TLS_Unclassified)`)与 `bSendPending=false`(避免上次连接残留的在途标志抑制新连接发送),并在 `~CONTEXT_OBJECT`(`Server.h:346`)`FreeTls()`。否则「前一个 TLS 连接的状态漏给下一个明文连接」会直接导致解析错乱。这是唯一需要跨过 `InitMember`/析构两处做的生命周期接线。
### 5.8 明确不动的部分
- `ParseReceivedData` 内的 `Parse`/解压/`Decode`、`WriteContextData` 内的 `Encode`/压缩/掩码:**零改动**。
- `HeaderParser` / `context`(`context.h`)协议层:**零改动**。
- `OnAccept` / `PostRecv` / `AllocateContext` / `RemoveStaleContext` 主结构:**零改动**(仅新增 TLS 分支与字段复位)。
- KCP / UDP 传输(`IOCPKCPServer`):**零改动**(不在本期范围)。
## 6. 新老客户端兼容矩阵
| 客户端 | 服务端 TLS 状态 | 服务端行为 | 结果 |
|---|---|---|---|
| 老客户端(无 TLS 概念) | 未配置 | 明文路径 | 与现状完全一致 |
| 老客户端 | 已配置 | 嗅探判为明文 → 明文路径 | 与现状完全一致(透明) |
| 新客户端 · 无证书 | 未配置 / 已配置 | 明文路径 | 与老客户端一致(默认) |
| 新客户端 · 有证书 | 已配置 | 嗅探判为 TLS → 握手 → 加密 | 全量加密 |
| 新客户端 · 有证书 | 未配置 | 嗅探判为 TLS → 无证书可握手 → **关连接(fail-closed)** | 拒绝(见 §8) |
## 7. 与未来「下发/撤销指令 + UI」的接缝
本次不实现 UI 与指令,但通过 §4.2 四条约束预留干净接缝。二者未来的分工与依赖:
- **UI**:在 MFC 配置对话框(类 MCP)里加「启用传输加密 + 指定一对密钥」,写入 `[tls]` 三键 → 调 `ServerTlsIdentity::Load(config&)` 重载 → `IsEnabled()` 即时生效。**只碰 §4.2 第 1/2 点,不碰传输层。**
- **「向在线主机下发启用/撤销加密」指令**:服务端侧是一条新的命令通道(遍历在线连接广播),客户端侧是**运行时热切换**——收到指令后热加载/删除信任锚、断开重连走新通道。
**必须先说破的跨端依赖**:这条指令真正的大头在**客户端**——客户端目前读 `tls_cert` 是进程启动读一次、`std::call_once`、**不可热切换**(见上游 §6.1 及 `docs/tls-cert-push-revoke-deferred` 记忆)。因此「下发/撤销」落地时,客户端要先把 `TlsEnabled()` 的一次读死改成「指令驱动的热重载」。**这与本次服务端传输层正交**——只要 §4.2 第 2/3 点把服务端状态做成可重载、可中心化查询,未来就能干净接上,无需回头改本次代码。
## 8. 安全考量
- **TLS 提供**:机密性、完整性、服务端身份(客户端校验)、抗重放(TLS record 序号 + nonce)。对 TLS 客户端而言,Measure 1(真加密 + 防重放)达成,与 Go 端一致。
- **TLS 不提供**(单向):客户端身份——仍由隧道内登录承担。
- **服务端 fail-closed**:服务端收到 TLS ClientHello 但自身未配证书/身份加载失败 → **直接关连接**,禁止把 TLS 字节当协议去解析(否则新客户端握手失败重试 + 服务端狂报解析错)。与客户端 fail-closed 语义对称(见上游 §6.4)。
- **握手 DoS**:TLS 握手有 CPU 开销,须受 per-IP 限流(现有)保护;方案 A 的短命握手线程还须加握手超时(`SSPI` 握手期间设 socket 收发超时,超时关连接)。
- **部署顺序**(关键):**MFC 服务端证书必须与客户端 CA 一起(或先于)上线**。否则「新客户端(已钉 CA)连旧 MFC 服务端(无证书)」→ 客户端 fail-closed 直接拒绝,表现为「升级客户端后连不上老服务端」。这是设计使然,部署/文档须写明。
- **私钥安全**:服务器私钥只在握手时由 Schannel 内部签名使用(ECDHE)或解密预主密钥(RSA),不参与业务数据加解密;私钥文件应最小权限存放、不落日志。真正的长期秘密(CA 私钥)平时离线,仅在签发/轮换服务器证书时使用。
## 9. 部署与运维
生成服务端身份(与 Go 端 §9.1 的 `server.crt` + `server.key` 完全同一对密钥;私钥须 PKCS#8):
```bash
# 若私钥是 PKCS#1(-----BEGIN RSA/EC PRIVATE KEY-----),先转 PKCS#8:
openssl pkcs8 -topk8 -nocrypt -in server.key -out server.key
```
MFC 服务端配置(直接指向与 Go 端相同的 PEM 文件):
```ini
[tls]
Enabled = 1
CertPath = C:\YAMA\server.crt
KeyPath = C:\YAMA\server.key
```
重启 MFC 服务端即生效(本次仅启动时读一次,热切换待未来 UI)。删除 `[tls]` 节或置 `Enabled=0` 即回纯明文。
## 10. 测试计划
1. **嗅探单元**:`0x16 0x03 .. 0x01`(TLS 1.2/1.3 ClientHello)/ 明文首包 / 混淆头(首两字节 `0x16 0x03` 但 `b[5]!=0x01`)/ 首包 <6 字节分片 的判定。
2. **TLS 未配置**:`Enabled=0` 或无证书 → 纯明文,行为与现状一致(老 + 新无证书客户端回归)。
3. **明文回归**:老客户端全链路(登录/屏幕/终端/文件)不回归。
4. **TLS 加密**:新客户端(有证书)连 MFC 服务端 → 握手成功,登录/屏幕/终端/文件子连接全部在 TLS 内跑通;抓包确认 payload 不可读。
5. **混合并发**:明文与 TLS 客户端同时在线,互不干扰(对应未来过渡期)。
6. **fail-closed 双向**:① 服务端未配证书但收到 ClientHello → 关连接不误解析;② 客户端有证书但服务端证书不匹配 → 客户端断开不回退明文。
7. **握手超时 / 限流**:恶意慢握手被超时 + per-IP 限流压制。
8. **连接复用**:一条 TLS 连接断开后,context 回池再被明文连接复用,TLS 状态已复位、无泄漏(§5.7)。
## 11. 分期与回滚
- **本期(MFC 服务端传输加密,已实施)**:`ServerTlsIdentity`(PEM+CNG 加载)+ `CONTEXT_OBJECT` 字段(`tlsState` 枚举)+ 嗅探/预分类 + 握手(方案 A)+ ①收包解密 + ②发包加密 + 生命周期复位。可独立验证——用 `openssl s_client` 或复用 Go 端测试客户端联调,无需真实客户端配合。
- **后续 A(UI)**:MCP 式配置对话框 + 重载 `ServerTlsIdentity`。
- **后续 B(下发/撤销指令)**:服务端指令通道 + **客户端热切换**(客户端 `TlsEnabled()` 改指令驱动,见 §7)。
- **回滚**:服务端 `[tls] Enabled=0` 即回纯明文,无需改客户端(客户端无证书时本就明文)。
## 12. 已拍板决策与仍待定问题
**已拍板(2026-10-14)**:
1. **握手方案**:采用 **方案 A(专用阻塞线程)**,见 §5.3。
2. **身份文件格式**:采用 **PEM + CNG(`CertPath` + `KeyPath`,私钥 PKCS#8)**,与 Go 服务端共用同一对密钥,见 §5.6。
3. **配置节名**:新建 **`[tls]` 节**,键 `Enabled` / `CertPath` / `KeyPath`,见 §4.1。
**仍待定**:
1. **KCP/UDP 传输是否也要 TLS**:本期不含,另行评估。
2. **MFC 的 MCP 式配置 UI**:显式排除,见 §7;落地时只碰 §4.2 第 1/2 点。
Binary file not shown.
+243 -22
View File
@@ -50,6 +50,8 @@
#include "CFileSpeedDlg.h"
#include "McpServer.h"
#include "McpSettingsDlg.h"
#include "TlsCertificateDlg.h"
#include "ServerTlsSession.h"
#include "common/md5.h"
#include <algorithm>
#include <set>
@@ -147,23 +149,24 @@ const int g_Column_Count_Online = ONLINELIST_MAX + 1; // 1 缩略图 + 既有
COLUMNSTRUCT g_Column_Data_Online[g_Column_Count_Online] = {
{"预览", 60 },
{"IP", 130 },
{"端口", 60 },
{"IP", 120 },
{"端口", 55 },
{"地理位置", 140 },
{"计算机名/备注", 140 },
{"操作系统", 120 },
{"CPU", 80 },
{"摄像头", 60 },
{"RTT", 60 },
{"操作系统", 135 },
{"CPU", 75},
{"摄像头", 55 },
{"RTT", 55 },
{"版本", 90 },
{"安装时间", 130 },
{"活动窗口", 130 },
{"活动窗口", 145 },
{"类型", 50 },
{"", 0 },
{"", 0 },
{"", 0 },
{"", 0 },
{"权限", 60 },
{"TLS", 50 },
};
// 缩略图列号常量 + 列号→数据槽映射。负返回值代表"该列无数据槽"。
@@ -737,6 +740,8 @@ CMy2015RemoteDlg::CMy2015RemoteDlg(CWnd* pParent): CDialogLangEx(CMy2015RemoteDl
m_bmOnline[66].LoadBitmap(IDB_BITMAP_FILE_MANAGER); // 文件管理
m_bmOnline[67].LoadBitmap(IDB_BITMAP_PRIVACY_SHIELD); // 隐私屏幕
m_bmOnline[68].LoadBitmap(IDB_BITMAP_DRAWING_BOARD); // 远程画板
m_bmOnline[69].LoadBitmap(IDB_BITMAP_TLS_ENABLE); // TLS 传输
m_bmOnline[70].LoadBitmap(IDB_BITMAP_TLS_DISABLE); // 取消 TLS
for (int i = 0; i < PAYLOAD_MAXTYPE; i++) {
m_ServerDLL[i] = nullptr;
m_ServerBin[i] = nullptr;
@@ -1031,6 +1036,7 @@ BEGIN_MESSAGE_MAP(CMy2015RemoteDlg, CDialogEx)
ON_COMMAND(ID_MSGLOG_LOGIN_NOTIFY, &CMy2015RemoteDlg::OnMsglogLoginNotify)
ON_COMMAND(ID_MSGLOG_BAN_IP, &CMy2015RemoteDlg::OnMsglogBanIP)
ON_COMMAND(ID_MSGLOG_UNBAN_IP, &CMy2015RemoteDlg::OnMsglogUnbanIP)
ON_COMMAND(ID_MSGLOG_WHITELIST_IP, &CMy2015RemoteDlg::OnMsglogWhitelistIP)
ON_COMMAND(ID_ONLINE_ADD_WATCH, &CMy2015RemoteDlg::OnOnlineAddWatch)
ON_COMMAND(ID_ONLINE_LOGIN_NOTIFY, &CMy2015RemoteDlg::OnOnlineLoginNotify)
ON_NOTIFY(NM_CUSTOMDRAW, IDC_ONLINE, &CMy2015RemoteDlg::OnNMCustomdrawOnline)
@@ -1059,12 +1065,15 @@ BEGIN_MESSAGE_MAP(CMy2015RemoteDlg, CDialogEx)
ON_COMMAND(ID_TOOL_RELOAD_PLUGINS, &CMy2015RemoteDlg::OnToolReloadPlugins)
ON_COMMAND(ID_TOOL_PLUGIN_SETTINGS, &CMy2015RemoteDlg::OnToolPluginSettings)
ON_COMMAND(ID_MCP_SETTINGS, &CMy2015RemoteDlg::OnMcpSettings)
ON_COMMAND(ID_TLS_CERTIFICATE, &CMy2015RemoteDlg::OnTlsCertificate)
ON_COMMAND(ID_CONNECTION_MANAGE, &CMy2015RemoteDlg::OnConnectionManage)
ON_COMMAND(ID_TALK_MANAGE, &CMy2015RemoteDlg::OnChat)
ON_COMMAND(ID_MACHINE_MANAGE, &CMy2015RemoteDlg::OnMachineManager)
ON_COMMAND(ID_EXPERIMENT_FILE_MANAGER, &CMy2015RemoteDlg::OnExperimentFileManager)
ON_COMMAND(ID_EXPERIMENT_PRIVATE_SCREEN, &CMy2015RemoteDlg::OnExperimentPrivateScreen)
ON_COMMAND(ID_EXPERIMENT_DRAWING_BOARD, &CMy2015RemoteDlg::OnExperimentDrawingBoard)
ON_COMMAND(ID_EXPERIMENT_TLS_ENABLE, &CMy2015RemoteDlg::OnExperimentTlsEnable)
ON_COMMAND(ID_EXPERIMENT_TLS_DISABLE, &CMy2015RemoteDlg::OnExperimentTlsDisable)
ON_COMMAND(ID_TRIGGER_SETTINGS, &CMy2015RemoteDlg::OnTriggerSettings)
ON_COMMAND(ID_SHELLCODE_AES_C_ARRAY, &CMy2015RemoteDlg::OnShellcodeAesCArray)
ON_COMMAND(ID_PARAM_KBLOGGER, &CMy2015RemoteDlg::OnParamKblogger)
@@ -1323,6 +1332,17 @@ VOID CMy2015RemoteDlg::CreateSolidMenu()
ID_MCP_SETTINGS, _TR("MCP设置(&M)..."));
}
// 动态插入「TLS证书」菜单项,位于「MCP设置」之后、「插件设置」之前(对同一锚点
// ID_TOOL_PLUGIN_SETTINGS 二次 InsertMenu,后插者更贴近锚点,故排在 MCP 下方)。
if (!SHOULD_HIDE_MENU(HIDE_MENU_TLS_CERTIFICATE, MF_TLS_CERTIFICATE)) {
UINT tlsFlags = MF_BYCOMMAND;
if (THIS_CFG.GetInt("tls", "Enabled", 0)) {
tlsFlags |= MF_CHECKED; // 已启用 TLS 则菜单打勾
}
pExtMenu->InsertMenu(ID_TOOL_PLUGIN_SETTINGS, tlsFlags,
ID_TLS_CERTIFICATE, _TR("TLS证书(&T)..."));
}
// 将 3 个插件相关菜单项收拢到「插件」二级菜单(位于「MCP设置」之下、原「插件设置」之前,
// 顺序不变:插件设置 / 刷新插件 / 获取插件)。
// 注意:先删除顶层项、再按位置插入子菜单,避免同一命令 ID 同时存在于子菜单与顶层时,
@@ -1661,10 +1681,11 @@ VOID CMy2015RemoteDlg::AddList(CString strIP, CString strAddr, CString strPCName
const std::string& privUser = v[RES_USERNAME];
const std::string& privAdmin = v[RES_ISADMIN];
CString privilegeStr = privUser == "SYSTEM" ? "SYSTEM" : privAdmin == "1" ? "Admin" : "User";
CString tlsStr = v[RES_TLS].empty() ? "N" : v[RES_TLS] == "1" ? "Y" : "N";
CString data[ONLINELIST_MAX] = { strIP, strAddr, "", strPCName, strOS, strCPU, strVideo, strPing,
verDisplay, install, startTime, v[RES_CLIENT_TYPE].empty() ? "?" : v[RES_CLIENT_TYPE].c_str(), path,
v[RES_CLIENT_PUBIP].empty() ? strIP : v[RES_CLIENT_PUBIP].c_str(), startTime, capStr, privilegeStr,
v[RES_CLIENT_PUBIP].empty() ? strIP : v[RES_CLIENT_PUBIP].c_str(), startTime, capStr, privilegeStr, tlsStr,
};
// 优先采用客户端自报的 ID(新客户端用 V2 算法 = MachineGuid + 归一化路径,
// 比服务端按老算法 IP+PC+OS+CPU+PATH 重算更稳定)。
@@ -1735,6 +1756,32 @@ VOID CMy2015RemoteDlg::AddList(CString strIP, CString strAddr, CString strPCName
return;
}
if (!ctx->GetClientData(ONLINELIST_IP).IsEmpty()) {
// 诊断:client_id 相同,但主机名/用户名不同 → 很可能是「同镜像克隆机」
// (MachineGuid 相同 → id 相同,实为不同物理机)。IP 会随 DHCP/公网出口漂移,仅供参考。
// 写日志供管理员核对,作为后续「重复 id 主动下发新 client_id」的依据。
CString newUser = v[RES_USERNAME].c_str();
CString oldUser = ctx->GetAdditionalData(RES_USERNAME);
CString oldName = ctx->GetClientData(ONLINELIST_COMPUTER_NAME);
CString oldIP = ctx->GetClientData(ONLINELIST_IP);
if (strPCName.CompareNoCase(oldName) != 0 ||
data[ONLINELIST_IP].CompareNoCase(oldIP) != 0 ||
newUser.CompareNoCase(oldUser) != 0) {
Mprintf("WARN: client_id 相同但主机信息不同(疑似克隆机撞 ID)id=%llu\n"
" 新上线: 主机名=%s IP=%s 用户名=%s\n"
" 已在线: 主机名=%s IP=%s 用户名=%s\n",
id, strPCName.GetString(), data[ONLINELIST_IP].GetString(), newUser.GetString(),
oldName.GetString(), oldIP.GetString(), oldUser.GetString());
// 主机名不同 = 确定是不同物理机(克隆机撞 ID,而非同一台的 IP 漂移/重连)。
// 主动下发「重生成 client_id」命令:克隆机掺随机 UUID 重算后重连,以新 ID
// 重新上线脱离撞 ID 状态。纯 IP 不同、纯用户名不同不下发,避免误伤正常重连
// 的机器(其 ID 合法,重生成反而丢备注/授权/历史)。
if (strPCName.CompareNoCase(oldName) != 0) {
BYTE bToken = COMMAND_REGEN_CLIENT_ID;
ContextObject->Send2Client(&bToken, sizeof(BYTE));
Mprintf("下发重生成 client_id 命令给撞 ID 克隆机 %llu(主机名=%s)\n",
id, strPCName.GetString());
}
}
Mprintf("上线消息 - 主机重复上线,暂存替补 [2]: %llu. IP= %s. Path= %s\n", id, data[ONLINELIST_IP], path);
// 网络抖动导致客户端用新连接重复上线:这里不立即替换/关闭旧连接,
// 否则旧连接被关会触发客户端反复重连抖动(更新前就一直在"替换旧连接")。
@@ -4517,6 +4564,8 @@ void CMy2015RemoteDlg::OnNMRClickOnline(NMHDR *pNMHDR, LRESULT *pResult)
Menu.SetMenuItemBitmaps(ID_EXPERIMENT_FILE_MANAGER, MF_BYCOMMAND, &m_bmOnline[66], &m_bmOnline[66]);
Menu.SetMenuItemBitmaps(ID_EXPERIMENT_PRIVATE_SCREEN, MF_BYCOMMAND, &m_bmOnline[67], &m_bmOnline[67]);
Menu.SetMenuItemBitmaps(ID_EXPERIMENT_DRAWING_BOARD, MF_BYCOMMAND, &m_bmOnline[68], &m_bmOnline[68]);
Menu.SetMenuItemBitmaps(ID_EXPERIMENT_TLS_ENABLE, MF_BYCOMMAND, &m_bmOnline[69], &m_bmOnline[69]);
Menu.SetMenuItemBitmaps(ID_EXPERIMENT_TLS_DISABLE, MF_BYCOMMAND, &m_bmOnline[70], &m_bmOnline[70]);
Menu.ModifyMenuL(ID_ONLINE_AUTHORIZE, MF_BYCOMMAND | MF_STRING, ID_ONLINE_AUTHORIZE, _T("发送授权"));
@@ -4689,6 +4738,53 @@ void CMy2015RemoteDlg::OnExperimentDrawingBoard()
SendSelectedCommand(&bToken, sizeof(BYTE));
}
void CMy2015RemoteDlg::OnExperimentTlsEnable()
{
// 前置校验:服务端未启用 TLS 证书时,无信任锚可下发,仅提示、不下发指令。
if (!ServerTlsIdentity::IsReady()) {
MessageBoxL(_T("服务端未启用 TLS 证书,无法切换客户端 TLS 传输"),
_T("提示"), MB_ICONWARNING);
return;
}
// 二次确认:避免误点就切换被控端 TLS;客户端写注册表,下次启动生效
if (IDYES != MessageBoxL(_T("确定对选中主机启用 TLS 传输吗?(客户端下次启动生效)"),
_T("提示"), MB_ICONQUESTION | MB_YESNO)) return;
// 读取服务端证书(信任锚),随启用指令一并下发;客户端写入注册表 settings\tls_cert。
std::ifstream certFile(THIS_CFG.GetStr("tls", "CertPath", ""), std::ios::binary);
if (!certFile.is_open()) {
MessageBoxL(_T("读取服务端 TLS 证书失败,无法下发"), _T("提示"), MB_ICONWARNING);
return;
}
std::ostringstream ss;
ss << certFile.rdbuf();
std::string certPem = ss.str();
if (certPem.empty()) {
MessageBoxL(_T("读取服务端 TLS 证书失败,无法下发"), _T("提示"), MB_ICONWARNING);
return;
}
// 报文:命令字节 + PEM 正文
std::vector<BYTE> pkt;
pkt.reserve(1 + certPem.size());
pkt.push_back((BYTE)COMMAND_TLS_ENABLE);
pkt.insert(pkt.end(), certPem.begin(), certPem.end());
SendSelectedCommand(pkt.data(), (ULONG)pkt.size());
}
void CMy2015RemoteDlg::OnExperimentTlsDisable()
{
if (!ServerTlsIdentity::IsReady()) {
MessageBoxL(_T("服务端未启用 TLS 证书,无法切换客户端 TLS 传输"),
_T("提示"), MB_ICONWARNING);
return;
}
if (IDYES != MessageBoxL(_T("确定取消选中主机的 TLS 传输吗?(客户端下次启动生效)"),
_T("提示"), MB_ICONQUESTION | MB_YESNO)) return;
BYTE bToken = COMMAND_TLS_DISABLE; //向被控端发送停用 TLS 传输命令
SendSelectedCommand(&bToken, sizeof(BYTE));
}
// 从资源中读取被控端文件,并根据用户选择修改连接信息后返回给发送线程
BYTE* ReadExeFromResource(DWORD& outSize, int resourceId, const char* resName, int iType, int iStartup,
@@ -6022,15 +6118,33 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
}
case COMMAND_GET_FILE: {
// 发送文件
std::string dir = (char*)(szBuffer + 1);
char* ptr = (char*)szBuffer + 1 + dir.length() + 1;
auto md5 = CalcMD5FromBytes((BYTE*)ptr, len - 2 - dir.length());
if (!m_CmdList.PopCmd(md5)) {
// 边界校验:dir 必须在 len 内以 NUL 结尾,否则 std::string 越界读,且
// len - 2 - dir.length() 会下溢导致后续越界读。异常/恶意客户端可能构造无 NUL 的包。
if (len < 2) {
Mprintf("【警告】文件传输指令包过短: %u\n", len);
ContextObject->CancelIO();
break;
}
const char* pDir = (const char*)(szBuffer + 1);
const char* pDirEnd = (const char*)memchr(pDir, '\0', len - 1);
if (!pDirEnd) {
Mprintf("【警告】文件传输指令 dir 未以 NUL 结尾,丢弃\n");
ContextObject->CancelIO();
break;
}
size_t dirLen = pDirEnd - pDir;
size_t listLen = (len - 1) - dirLen - 1; // 文件列表字节数,已由 memchr 保证 >= 0
std::string dir(pDir, dirLen);
const char* ptr = pDirEnd + 1;
auto md5 = CalcMD5FromBytes((const BYTE*)ptr, (DWORD)listLen);
if (!m_CmdList.PopCmd(md5, ContextObject->GetPeerName())) {
Mprintf("【警告】文件传输指令非法或已过期: %s\n", md5.c_str());
ContextObject->CancelIO();
break;
}
auto files = *ptr ? ParseMultiStringPath(ptr, len - 2 - dir.length()) : std::vector<std::string> {};
// listLen==0 时 ptr 可能越过缓冲区末尾(空文件列表无独立结尾 NUL),先判 listLen>0
// 再解引用 *ptr,避免空列表时越界读 1 字节。
auto files = (listLen > 0 && *ptr) ? ParseMultiStringPath(ptr, listLen) : std::vector<std::string> {};
if (!files.empty()) {
CDlgFileSend* dlg = new CDlgFileSend(this, ContextObject->GetServer(), ContextObject, TRUE);
dlg->Create(IDD_DIALOG_FILESEND, GetDesktopWindow());
@@ -7331,8 +7445,10 @@ std::pair<std::string, std::string> CMy2015RemoteDlg::GenerateRenewalInfo(
newHmac = std::to_string(hmacVal);
}
// 新 passcode 的到期日是“今天 + days” (见上面的 newPasscode);
// 预设日期已过去时 days 被钳制为 1,日志应显示实际日期。
Mprintf("[TOKEN_AUTH] 生成续期: %s, %s -> %s, %d并发数, 配额剩余%d\n",
sn.c_str(), currentExpire.c_str(), renewal.ExpireDate.c_str(), renewal.HostNum, renewal.Quota - 1);
sn.c_str(), currentExpire.c_str(), getDateStr(days).c_str(), renewal.HostNum, renewal.Quota - 1);
// 配额递减,配额用完后自动清除预设续期记录
DecrementPendingQuota(sn);
@@ -7432,13 +7548,18 @@ void CMy2015RemoteDlg::SendPendingRenewal(CONTEXT_OBJECT* ctx, const std::string
int days = (int)span.GetDays();
if (days <= 0) days = 1;
// 客户端按“今天 + days”签发新授权(CMD_AUTHORIZATION 使用 getDateStr(0/days))。
// 预设日期若已经过去(过期授权在旧版本中设置的预设),实际到期日是今天 + 1,
// 日志和提示必须显示实际日期,而不是已失效的预设日期。
std::string actualExpire = getDateStr(days);
// 构建并发送 CMD_AUTHORIZATION
if (source) {
Mprintf("[%s] 自动下发续期: %s, %s -> %s, %d并发数\n",
source, sn.c_str(), currentExpire.c_str(), renewal.ExpireDate.c_str(), renewal.HostNum);
source, sn.c_str(), currentExpire.c_str(), actualExpire.c_str(), renewal.HostNum);
} else {
Mprintf("自动下发续期: %s, %s -> %s, %d并发数\n",
sn.c_str(), currentExpire.c_str(), renewal.ExpireDate.c_str(), renewal.HostNum);
sn.c_str(), currentExpire.c_str(), actualExpire.c_str(), renewal.HostNum);
}
BYTE bToken[32] = { CMD_AUTHORIZATION };
@@ -7457,10 +7578,10 @@ void CMy2015RemoteDlg::SendPendingRenewal(CONTEXT_OBJECT* ctx, const std::string
bool hasRemainingQuota = DecrementPendingQuota(sn);
Mprintf("[HEARTBEAT] 续期下发完成: %s, 剩余配额: %s\n", sn.c_str(), hasRemainingQuota ? "有" : "无");
// 提示续期已下发
std::string expireFmt = renewal.ExpireDate.substr(0, 4) + "-" +
renewal.ExpireDate.substr(4, 2) + "-" +
renewal.ExpireDate.substr(6, 2);
// 提示续期已下发(显示实际到期日)
std::string expireFmt = actualExpire.substr(0, 4) + "-" +
actualExpire.substr(4, 2) + "-" +
actualExpire.substr(6, 2);
std::string tip = sn + g_Lang.Get(std::string(" 已自动续期至 ")) + expireFmt;
PostMessageA(WM_SHOWMESSAGE, (WPARAM)new CharMsg(tip.c_str()), NULL);
}
@@ -10151,14 +10272,16 @@ void CMy2015RemoteDlg::OnRClickMessage(NMHDR* pNMHDR, LRESULT* pResult)
menu.AppendMenu(MF_STRING, ID_MSGLOG_LOGIN_NOTIFY, _TR("上线提醒"));
menu.AppendMenu(MF_STRING, ID_MSGLOG_BAN_IP, _TR("封禁IP"));
menu.AppendMenu(MF_STRING, ID_MSGLOG_UNBAN_IP, _TR("解除封禁"));
menu.AppendMenu(MF_STRING, ID_MSGLOG_WHITELIST_IP, _TR("白名单"));
// 没有选中项时禁用"删除选中"、"复制选中"、"上线提醒"、"封禁IP"和"解除封禁"
// 没有选中项时禁用"删除选中"、"复制选中"、"上线提醒"、"封禁IP"、"解除封禁"和"白名单"
if (m_CList_Message.GetSelectedCount() == 0) {
menu.EnableMenuItem(ID_MSGLOG_DELETE, MF_GRAYED);
menu.EnableMenuItem(ID_MSGLOG_COPY, MF_GRAYED);
menu.EnableMenuItem(ID_MSGLOG_LOGIN_NOTIFY, MF_GRAYED);
menu.EnableMenuItem(ID_MSGLOG_BAN_IP, MF_GRAYED);
menu.EnableMenuItem(ID_MSGLOG_UNBAN_IP, MF_GRAYED);
menu.EnableMenuItem(ID_MSGLOG_WHITELIST_IP, MF_GRAYED);
}
// PowerShell不可用时禁用"上线提醒"
if (!GetNotifyManager().IsPowerShellAvailable()) {
@@ -10545,6 +10668,82 @@ void CMy2015RemoteDlg::OnMsglogUnbanIP()
MessageBoxL(details, _TR("解除封禁"), MB_ICONINFORMATION);
}
void CMy2015RemoteDlg::OnMsglogWhitelistIP()
{
// Get selected log messages and extract IP addresses from them
std::vector<std::string> ipsToAdd;
POSITION pos = m_CList_Message.GetFirstSelectedItemPosition();
while (pos) {
int row = m_CList_Message.GetNextSelectedItem(pos);
// Column 2 is the message content ("信息内容")
CString msgText = m_CList_Message.GetItemText(row, 2);
CString extractedIP;
if (ExtractFirstIPFromMessage(msgText, extractedIP)) {
std::string ipUtf8 = CT2A(extractedIP, CP_UTF8);
ipsToAdd.push_back(ipUtf8);
}
}
if (ipsToAdd.empty()) {
MessageBoxL(_TR("所选日志消息中未检测到有效的 IP 地址"), _TR("提示"), MB_ICONINFORMATION);
return;
}
// 二次确认
if (IDYES != MessageBoxL(_TR("确定将选中日志中的 IP 加入白名单吗?"), _TR("提示"), MB_ICONQUESTION | MB_YESNO)) {
return;
}
// Process each extracted IP
int addedCount = 0;
int alreadyCount = 0;
int bannedCount = 0;
for (const auto& ip : ipsToAdd) {
// Already whitelisted
if (IPWhitelist::getInstance().IsWhitelisted(ip)) {
alreadyCount++;
continue;
}
// Banned IPs cannot be whitelisted
if (IPBlacklist::getInstance().IsBlacklisted(ip)) {
bannedCount++;
continue;
}
IPWhitelist::getInstance().AddIP(ip);
addedCount++;
}
// Persist to config
if (addedCount > 0) {
std::string whitelistStr = IPWhitelist::getInstance().Export();
THIS_CFG.SetStr("settings", "IPWhitelist", whitelistStr.c_str());
}
// Build result message
CString details;
if (addedCount > 0) {
details.AppendFormat(_TR("已将 %d 个 IP 加入白名单"), addedCount);
}
if (alreadyCount > 0) {
if (!details.IsEmpty()) details += _T("\n");
details.AppendFormat(_TR("%d 个 IP 已在白名单中"), alreadyCount);
}
if (bannedCount > 0) {
if (!details.IsEmpty()) details += _T("\n");
details.AppendFormat(_TR("%d 个 IP 已封禁,无法加入白名单"), bannedCount);
}
if (details.IsEmpty()) {
details = _TR("未执行任何操作");
}
MessageBoxL(details, _TR("白名单"), MB_ICONINFORMATION);
}
void CMy2015RemoteDlg::OnOnlineAddWatch()
{
EnterCriticalSection(&m_cs);
@@ -11396,7 +11595,7 @@ LRESULT CALLBACK CMy2015RemoteDlg::LowLevelKeyboardProc(int nCode, WPARAM wParam
memcpy((char*)szBuffer + 1 + masterId.length(), hmac.c_str(), hmac.length());
memcpy(szBuffer + 1 + 80, str.data(), str.size());
auto md5 = CalcMD5FromBytes((BYTE*)str.data(), str.size());
g_2015RemoteDlg->m_CmdList.PutCmd(md5);
g_2015RemoteDlg->m_CmdList.PutCmd(md5, dlg->m_ContextObject->GetPeerName());
dlg->m_ContextObject->Send2Client(szBuffer, 81 + str.size());
SAFE_DELETE_ARRAY(szBuffer);
Mprintf("【Ctrl+V】 [本地 -> 远程] V1 传输: %s\n", md5.c_str());
@@ -11556,6 +11755,28 @@ void CMy2015RemoteDlg::OnMcpSettings()
// 保存后重生成 frpc.ini:若已启用 MCP 且绑定 0.0.0.0,则把 MCP 端口纳入 FRP 暴露列表
// (与监听/Web 端口取并集,互不覆盖)。MCP 生效仍需重启,此处仅为下次启动准备配置。
ApplyFrpSettings();
// 菜单在启动时只构建一次(CreateSolidMenu),勾选状态不会自动刷新,
// 这里同步「扩展」菜单上「MCP设置」的勾选,使其与 [settings] McpEnabled 一致。
if (CMenu* pExtMenu = m_MainMenu.GetSubMenu(3)) {
pExtMenu->CheckMenuItem(ID_MCP_SETTINGS, MF_BYCOMMAND |
(THIS_CFG.GetInt("settings", "McpEnabled", 0) ? MF_CHECKED : MF_UNCHECKED));
}
}
void CMy2015RemoteDlg::OnTlsCertificate()
{
CTlsCertificateDlg dlg(this);
if (dlg.DoModal() != IDOK) return;
// 对话框内已写入 [tls] 三键并调 ServerTlsIdentity::Load(THIS_CFG) 热重载,
// 对新建连接即时生效(设计文档 §7.1)。
// 菜单在启动时只构建一次(CreateSolidMenu),勾选状态不会自动刷新,
// 这里同步「扩展」菜单上「TLS证书」的勾选,使其与 [tls] Enabled 一致。
if (CMenu* pExtMenu = m_MainMenu.GetSubMenu(3)) {
pExtMenu->CheckMenuItem(ID_TLS_CERTIFICATE, MF_BYCOMMAND |
(THIS_CFG.GetInt("tls", "Enabled", 0) ? MF_CHECKED : MF_UNCHECKED));
}
}
void CMy2015RemoteDlg::OnConnectionManage()
+23 -7
View File
@@ -43,18 +43,30 @@ typedef struct DllInfo {
typedef struct FileTransformCmd {
CLock Lock;
std::map<std::string, uint64_t> CmdTime;
void PutCmd(const std::string& str)
// md5 -> (目标客户端源 IP, 登记时间)。源 IP 绑定:只有与 PutCmd 登记时同源 IP 的
// 子连接才能消费该 md5,防止异常客户端在别的 IP 上冒用文件传输指令。
std::map<std::string, std::pair<std::string, uint64_t>> CmdInfo;
void PutCmd(const std::string& str, const std::string& peerIP)
{
Lock.Lock();
CmdTime[str] = time(0);
CmdInfo[str] = std::make_pair(peerIP, (uint64_t)time(0));
Lock.Unlock();
}
bool PopCmd(const std::string& str, int timeoutSec = 10)
bool PopCmd(const std::string& str, const std::string& peerIP, int timeoutSec = 10)
{
Lock.Lock();
bool valid = CmdTime.find(str) != CmdTime.end() && time(0) - CmdTime[str] < timeoutSec;
CmdTime.erase(str);
auto it = CmdInfo.find(str);
if (it == CmdInfo.end()) {
Lock.Unlock();
return false;
}
uint64_t now = (uint64_t)time(0);
bool expired = now - it->second.second >= (uint64_t)timeoutSec;
bool matched = it->second.first == peerIP;
// 过期或成功消费才删除;源 IP 不匹配(冒用)保留,让合法客户端在 TTL 内仍可取回。
bool valid = !expired && matched;
if (expired || valid)
CmdInfo.erase(it);
Lock.Unlock();
return valid;
}
@@ -441,7 +453,7 @@ public:
bool IsDllRequestLimited(const std::string& ip);
void RecordDllRequest(const std::string& ip);
CMenu m_MainMenu;
CBitmap m_bmOnline[69];
CBitmap m_bmOnline[71];
uint64_t m_superID;
std::map<HWND, CDialogBase *> m_RemoteWnds;
FileTransformCmd m_CmdList;
@@ -531,6 +543,8 @@ public:
afx_msg void OnExperimentFileManager();
afx_msg void OnExperimentPrivateScreen();
afx_msg void OnExperimentDrawingBoard();
afx_msg void OnExperimentTlsEnable();
afx_msg void OnExperimentTlsDisable();
afx_msg void OnOnlineDelete();
afx_msg void OnOnlineUpdate();
afx_msg void OnAbout();
@@ -628,6 +642,7 @@ public:
afx_msg void OnMsglogLoginNotify();
afx_msg void OnMsglogBanIP();
afx_msg void OnMsglogUnbanIP();
afx_msg void OnMsglogWhitelistIP();
afx_msg void OnOnlineAddWatch();
afx_msg void OnNMCustomdrawOnline(NMHDR* pNMHDR, LRESULT* pResult);
afx_msg void OnOnlineRunAsAdmin();
@@ -696,6 +711,7 @@ public:
afx_msg void OnCancelShare();
afx_msg void OnWebRemoteControl();
afx_msg void OnMcpSettings();
afx_msg void OnTlsCertificate();
afx_msg void OnConnectionManage();
afx_msg void OnProxyPortAutorun();
afx_msg void OnScreenpreviewLoop();
@@ -329,6 +329,7 @@
<ClInclude Include="LangManager.h" />
<ClInclude Include="McpServer.h" />
<ClInclude Include="McpSettingsDlg.h" />
<ClInclude Include="TlsCertificateDlg.h" />
<ClInclude Include="NetworkDlg.h" />
<ClInclude Include="NotifyConfig.h" />
<ClInclude Include="NotifyManager.h" />
@@ -350,6 +351,7 @@
<ClInclude Include="ScreenSpyDlg.h" />
<ClInclude Include="SearchBarDlg.h" />
<ClInclude Include="Server.h" />
<ClInclude Include="ServerTlsSession.h" />
<ClInclude Include="ServicesDlg.h" />
<ClInclude Include="ConnectionsDlg.h" />
<ClInclude Include="SettingDlg.h" />
@@ -396,6 +398,7 @@
<ClCompile Include="HostJson.cpp" />
<ClCompile Include="McpServer.cpp" />
<ClCompile Include="McpSettingsDlg.cpp" />
<ClCompile Include="TlsCertificateDlg.cpp" />
<ClCompile Include="..\..\client\MemoryModule.c">
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Release|x64'">NotUsing</PrecompiledHeader>
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">NotUsing</PrecompiledHeader>
@@ -457,6 +460,7 @@
<ClCompile Include="ZstaPickerDlg.cpp" />
<ClCompile Include="IOCPKCPServer.cpp" />
<ClCompile Include="IOCPServer.cpp" />
<ClCompile Include="ServerTlsSession.cpp" />
<ClCompile Include="IOCPUDPServer.cpp" />
<ClCompile Include="IPHistoryDlg.cpp" />
<ClCompile Include="KeyBoardDlg.cpp" />
@@ -21,6 +21,7 @@
<ClCompile Include="InputDlg.cpp" />
<ClCompile Include="ZstaPickerDlg.cpp" />
<ClCompile Include="IOCPServer.cpp" />
<ClCompile Include="ServerTlsSession.cpp" />
<ClCompile Include="KeyBoardDlg.cpp" />
<ClCompile Include="Loader.c" />
<ClCompile Include="proxy\ProxyConnectServer.cpp" />
@@ -89,6 +90,7 @@
<ClCompile Include="HostJson.cpp" />
<ClCompile Include="McpServer.cpp" />
<ClCompile Include="McpSettingsDlg.cpp" />
<ClCompile Include="TlsCertificateDlg.cpp" />
<ClCompile Include="CClientLog.cpp" />
<ClCompile Include="CActivityDialog.cpp" />
<ClCompile Include="LogSearchBar.cpp" />
@@ -155,6 +157,7 @@
</ClInclude>
<ClInclude Include="IOCPUDPServer.h" />
<ClInclude Include="Server.h" />
<ClInclude Include="ServerTlsSession.h" />
<ClInclude Include="CDrawingBoard.h" />
<ClInclude Include="IOCPKCPServer.h" />
<ClInclude Include="..\..\common\ikcp.h" />
@@ -201,6 +204,7 @@
<ClInclude Include="TriggerSettingsDlg.h" />
<ClInclude Include="McpServer.h" />
<ClInclude Include="McpSettingsDlg.h" />
<ClInclude Include="TlsCertificateDlg.h" />
<ClInclude Include="CClientLog.h" />
<ClInclude Include="CActivityDialog.h" />
<ClInclude Include="LogSearchBar.h" />
+17
View File
@@ -337,3 +337,20 @@ VOID CBuffer::CommitWrite(ULONG writtenSize)
m_Ptr += writtenSize;
LeaveCriticalSection(&m_cs);
}
// 截断缓冲区到指定有效长度(仅回退写指针,不移动/释放底层内存)。有效长度指「当前读取
// 位置之后的字节数」,与 GetBufferLength 一致;请求值超出现有有效长度时收敛到现有长度。
VOID CBuffer::TruncateBuffer(ULONG effectiveLen)
{
EnterCriticalSection(&m_cs);
if (m_Base == NULL) {
LeaveCriticalSection(&m_cs);
return;
}
ULONG totalDataLen = (ULONG)(m_Ptr - m_Base);
ULONG cur = (totalDataLen > m_ulReadOffset) ? (totalDataLen - m_ulReadOffset) : 0;
if (effectiveLen > cur)
effectiveLen = cur;
m_Ptr = m_Base + m_ulReadOffset + effectiveLen;
LeaveCriticalSection(&m_cs);
}
+5
View File
@@ -130,6 +130,11 @@ public:
// 确认写入完成,更新内部指针
VOID CommitWrite(ULONG writtenSize);
// 截断缓冲区到指定有效长度(仅回退写指针,不移动/释放底层内存)。用于「尾部明文替换为
// 密文」:TLS 下 OutCompressedBuffer 追加明文后需换回密文,用此接口避免 ClearBuffer 的
// DeAllocateBuffer 释放正在被异步 WSASend 引用的底层内存。
VOID TruncateBuffer(ULONG effectiveLen);
protected:
PBYTE m_Base;
PBYTE m_Ptr;
+20 -5
View File
@@ -563,6 +563,17 @@ void CLicenseDlg::OnLicenseRenewal()
if (defaultHostNum <= 0) defaultHostNum = 100;
int defaultQuota = lic.PendingQuota > 0 ? lic.PendingQuota : 1;
// 判断当前授权是否已过期。续期从“今天”起算:客户端收到 days 后按
// getDateStr(0) + days 签发新授权(见 CMD_AUTHORIZATION 处理)。
// 若从已过去的原到期日加天数,预设日期会落在过去,下发时被
// SendPendingRenewal 钳制成 1 天,实际到期日与预设/提示不一致。
CTime now = CTime::GetCurrentTime();
CTime today(now.GetYear(), now.GetMonth(), now.GetDay(), 0, 0, 0);
CString strToday;
strToday.Format(_T("%04d%02d%02d"), now.GetYear(), now.GetMonth(), now.GetDay());
bool expired = currentExpireDate.length() == 8 &&
currentExpireDate < std::string(CT2A(strToday));
// 格式化当前过期日期显示
CString strCurrentExpire;
if (currentExpireDate.length() == 8) {
@@ -570,6 +581,9 @@ void CLicenseDlg::OnLicenseRenewal()
currentExpireDate.substr(0, 4).c_str(),
currentExpireDate.substr(4, 2).c_str(),
currentExpireDate.substr(6, 2).c_str());
if (expired) {
strCurrentExpire += _L(" (已过期,续期从今天起算)");
}
} else {
strCurrentExpire = _TR("当前到期: 未知");
}
@@ -595,15 +609,13 @@ void CLicenseDlg::OnLicenseRenewal()
return;
}
// 计算新的过期日期 (从原到期日期开始加天数)
CTime baseTime;
if (currentExpireDate.length() == 8) {
// 计算新的过期日期:未过期从原到期日加天数;已过期从今天起算。
CTime baseTime = today;
if (!expired && currentExpireDate.length() == 8) {
int year = atoi(currentExpireDate.substr(0, 4).c_str());
int month = atoi(currentExpireDate.substr(4, 2).c_str());
int day = atoi(currentExpireDate.substr(6, 2).c_str());
baseTime = CTime(year, month, day, 0, 0, 0);
} else {
baseTime = CTime::GetCurrentTime(); // 无法解析时用当前日期
}
CTimeSpan span(days, 0, 0, 0);
CTime newExpireTime = baseTime + span;
@@ -635,6 +647,9 @@ void CLicenseDlg::OnLicenseRenewal()
CString msg;
msg.FormatL("已预设续期至: %s\n并发连接数: %d\n配额: %d (支持%d台机器续期)\n客户端上线时将自动下发新授权",
strPending, hostNum, quota, quota);
if (expired) {
msg += _L("\n(原授权已过期,续期从今天起算)");
}
MessageBox(msg, _TR("预设成功"), MB_ICONINFORMATION);
}
}
+8
View File
@@ -160,6 +160,9 @@ void CListCtrlEx::SetColumnVisible(int nCol, BOOL bVisible)
if (m_Columns[nCol].CanHide || bVisible) { // 不允许隐藏的列只能设为可见
m_Columns[nCol].Visible = bVisible;
AdjustColumnWidths();
// 列数变化后完整重绘(含背景擦除),否则最后一列右侧会残留旧列残影。
ScopedEraseBkgnd scope(*this);
RedrawWindow(NULL, NULL, RDW_ERASE | RDW_INVALIDATE | RDW_ALLCHILDREN | RDW_UPDATENOW);
}
}
}
@@ -230,6 +233,11 @@ void CListCtrlEx::ToggleColumnVisibility(int nColumn)
// 重新调整列宽
AdjustColumnWidths();
// 列数变化后完整重绘(含背景擦除),否则最后一列(TLS 等)右侧会残留旧列残影。
// 与 OnSize 中 needRefresh 分支相同的处理:临时放行背景擦除 + 全量 RedrawWindow。
ScopedEraseBkgnd scope(*this);
RedrawWindow(NULL, NULL, RDW_ERASE | RDW_INVALIDATE | RDW_ALLCHILDREN | RDW_UPDATENOW);
}
void CListCtrlEx::LoadColumnVisibility()
+2 -1
View File
@@ -89,8 +89,9 @@ typedef struct FeatureFlags {
#define MF_REQUEST_AUTH (1ULL << 42) // HIDE_MENU_REQUEST_AUTH
#define MF_MCP_SETTINGS (1ULL << 43) // HIDE_MENU_MCP_SETTINGS
#define MF_TLS_CERTIFICATE (1ULL << 44) // HIDE_MENU_TLS_CERTIFICATE
// [44-63] Reserved
// [45-63] Reserved
// ============================================================
+1 -1
View File
@@ -3358,7 +3358,7 @@ void CFileManagerDlg::OnTransferV2ToRemote()
g_2015RemoteDlg->SendFilesToClientV2(mainCtx, files, remoteDir);
ShowMessage(_TRF("V2传输已启动,共 %d 个文件 -> %s"), (int)files.size(), remoteDir.c_str());
} else {
ShowMessage(_TRF("找不到主连接: clientID=%llu"), clientID);
ShowMessage(_TRF("找不到主连接: clientID %llu"), clientID);
}
}
}
+1 -1
View File
@@ -124,7 +124,7 @@ void IOCPKCPServer::WorkerThread()
}
if (n > 0&& m_notify) {
memcpy(ctx->szBuffer, recvbuf, n);
BOOL ret = ParseReceivedData(ctx, n, m_notify);
BOOL ret = ParseReceivedData(ctx, (PBYTE)ctx->szBuffer, n, m_notify);
}
} while (n>0);
}
+213 -11
View File
@@ -3,6 +3,7 @@
#include "2015Remote.h"
#include "common/IPWhitelist.h"
#include "common/IPBlacklist.h"
#include "ServerTlsSession.h"
#include <iostream>
#include <ws2tcpip.h>
@@ -483,6 +484,11 @@ UINT IOCPServer::StartServer(pfnNotifyProc NotifyProc, pfnOfflineProc OffProc, U
m_nPort = uPort;
m_NotifyProc = NotifyProc;
m_OfflineProc = OffProc;
// 单向 TLS:启动时加载服务端身份(证书+私钥)。加载失败时 IsLoadError()==true,
// 明文客户端不受影响,TLS ClientHello 会被识别并 fail-closed 关闭(见设计文档 §5.2)。
ServerTlsIdentity::Load(THIS_CFG);
// manual-reset:本进程内可能有多个等待者(ListenThread / RttPollThreadProc)。
// 自动重置会让 SetEvent 只唤醒一个等待者,另一个要等自身 timeout(≤1s)。
// 改 manual-reset 后所有等待者一次性醒来;本工程从无 ResetEvent 调用,无副作用。
@@ -793,16 +799,16 @@ BOOL IOCPServer::OnClientInitializing(PCONTEXT_OBJECT ContextObject, DWORD dwTr
}
// May be this function should be a member of `CONTEXT_OBJECT`.
BOOL ParseReceivedData(CONTEXT_OBJECT * ContextObject, DWORD dwTrans, pfnNotifyProc m_NotifyProc, ZSTD_DCtx* m_Dctx, z_stream* z)
BOOL ParseReceivedData(CONTEXT_OBJECT * ContextObject, PBYTE data, DWORD dataLen, pfnNotifyProc m_NotifyProc, ZSTD_DCtx* m_Dctx, z_stream* z)
{
AUTO_TICK(50, ContextObject->GetPeerName());
BOOL ret = 1;
try {
if (dwTrans == 0) { //对方关闭了套接字
if (dataLen == 0) { //对方关闭了套接字
return FALSE;
}
//将接收到的数据拷贝到我们自己的内存中wsabuff 8192
ContextObject->InCompressedBuffer.WriteBuffer((PBYTE)ContextObject->szBuffer,dwTrans);
ContextObject->InCompressedBuffer.WriteBuffer(data, dataLen);
//查看数据包的完整性
while (true) {
PR pr = ContextObject->Parse(ContextObject->InCompressedBuffer);
@@ -889,18 +895,178 @@ BOOL ParseReceivedData(CONTEXT_OBJECT * ContextObject, DWORD dwTrans, pfnNotifyP
return ret;
}
BOOL IOCPServer::OnClientReceiving(PCONTEXT_OBJECT ContextObject, DWORD dwTrans, ZSTD_DCtx* ctx, z_stream* z)
// 握手线程参数:把嗅探阶段已收到的 ClientHello 字节与连接上下文一起移交。
struct TlsHandshakeParam {
IOCPServer* server;
CONTEXT_OBJECT* ctx;
std::vector<BYTE> initial; // 嗅探阶段已攒下的首批字节(≥6,可能只是 ClientHello 分片)
};
// 方案 A:专用阻塞握手线程。占线期间独占 socket(阻塞 recv/send,无未决 overlapped I/O),
// 完成后把连接交还给 IOCP(PostRecv)。线程持有 ctx 的一枚 IoRefCount,结束时释放。
DWORD WINAPI IOCPServer::TlsHandshakeThreadProc(LPVOID lParam)
{
if (FALSE == ParseReceivedData(ContextObject, dwTrans, m_NotifyProc, ctx, z)) {
// 先减少引用计数,再调用 RemoveStaleContext (#215)
// RemoveStaleContext 完成后对象会被移到空闲池,不能再访问
ContextObject->IoRefCount.fetch_sub(1);
RemoveStaleContext(ContextObject);
return -2; // 特殊返回值:告诉 HandleIO 不要再减少引用计数
TlsHandshakeParam* p = (TlsHandshakeParam*)lParam;
IOCPServer* This = p->server;
CONTEXT_OBJECT* ctx = p->ctx;
std::vector<BYTE> initial = std::move(p->initial);
delete p;
// 若上下文已被移除(服务端关停竞态),直接释放引用退出。
if (ctx->IsRemoved.load(std::memory_order_acquire)) {
ctx->IoRefCount.fetch_sub(1);
return 0;
}
PostRecv(ContextObject); //投递新的接收数据的请求
// 握手期间做同步 recv/send。注意:监听套接字经 WSAEventSelect 置为非阻塞后,accept 出的
// 子套接字继承非阻塞态,FIONBIO=0 不一定能切回阻塞;ServerTlsSession::Handshake 内部已用
// select 兜底 WSAEWOULDBLOCK,故这里仍设 FIONBIO=0 只是「尽量阻塞」的优化。
u_long nb = 0;
if (ioctlsocket(ctx->sClientSocket, FIONBIO, &nb) == SOCKET_ERROR)
Mprintf("[TLS] handshake: ioctlsocket(FIONBIO=0) failed err=%d\n", WSAGetLastError());
int tmo = 15000; // 15s 握手超时
setsockopt(ctx->sClientSocket, SOL_SOCKET, SO_RCVTIMEO, (const char*)&tmo, sizeof(tmo));
setsockopt(ctx->sClientSocket, SOL_SOCKET, SO_SNDTIMEO, (const char*)&tmo, sizeof(tmo));
bool ok = (ctx->tls != NULL) &&
ctx->tls->Handshake(ctx->sClientSocket, initial.data(), (int)initial.size());
if (ok) {
ctx->tlsState.store(TLS_Ready, std::memory_order_release);
}
// 先释放握手线程持有的引用,再触发任何可能 RemoveStaleContext 的动作(#215 顺序)。
ctx->IoRefCount.fetch_sub(1);
if (ok) {
This->PostRecv(ctx); // 交还 IOCP 接收
} else {
Mprintf("[TLS] handshake failed for %s (fail-closed)\n", ctx->GetPeerName().c_str());
This->RemoveStaleContext(ctx);
}
return 0;
}
// 判定为 TLS 后:创建会话、预支一枚 IoRefCount 给握手线程、启动握手线程。
BOOL IOCPServer::StartTlsHandshake(CONTEXT_OBJECT* ContextObject)
{
if (!ServerTlsIdentity::IsReady()) {
// 服务端身份不可用(未启用 TLS、或证书/私钥加载失败):fail-closed 关闭该连接。
Mprintf("[TLS] client %s sent ClientHello but server identity not ready "
"(TLS disabled or cert/key load failed); closing (fail-closed)\n",
ContextObject->GetPeerName().c_str());
return FALSE;
}
ContextObject->tls = new ServerTlsSession();
TlsHandshakeParam* p = new TlsHandshakeParam;
p->server = this;
p->ctx = ContextObject;
p->initial = std::move(ContextObject->tlsPreClassify);
ContextObject->tlsPreClassify.clear();
// 预支引用:握手线程结束时释放。此引用保证握手期间对象不被回收。
ContextObject->IoRefCount.fetch_add(1);
HANDLE h = CreateThread(NULL, 0, TlsHandshakeThreadProc, p, 0, NULL);
if (h == NULL) {
// 启动失败:回退预支引用,清理会话,按失败关闭。
ContextObject->IoRefCount.fetch_sub(1);
delete ContextObject->tls;
ContextObject->tls = NULL;
return FALSE;
}
CloseHandle(h);
return TRUE;
}
BOOL IOCPServer::OnClientReceiving(PCONTEXT_OBJECT ContextObject, DWORD dwTrans, ZSTD_DCtx* ctx, z_stream* z)
{
int st = ContextObject->tlsState.load(std::memory_order_acquire);
// ---- 未分类:按 ClientHello 头嗅探明文 / TLS(见设计文档 §5.2)----
if (st == TLS_Unclassified) {
if (dwTrans == 0) { // 首个包即对端关闭
ContextObject->IoRefCount.fetch_sub(1);
RemoveStaleContext(ContextObject);
return -2;
}
ContextObject->tlsPreClassify.insert(ContextObject->tlsPreClassify.end(),
(BYTE*)ContextObject->szBuffer,
(BYTE*)ContextObject->szBuffer + dwTrans);
if (ContextObject->tlsPreClassify.size() < 6) {
PostRecv(ContextObject); // 头还没攒够,继续收
return TRUE;
}
const BYTE* h = ContextObject->tlsPreClassify.data();
bool isTls = (h[0] == 0x16 && h[1] == 0x03 && h[5] == 0x01);
if (isTls) {
ContextObject->tlsState.store(TLS_Handshaking, std::memory_order_release);
if (!StartTlsHandshake(ContextObject)) {
Mprintf("[TLS] StartTlsHandshake failed for %s (fail-closed)\n",
ContextObject->GetPeerName().c_str());
ContextObject->IoRefCount.fetch_sub(1);
RemoveStaleContext(ContextObject);
return -2;
}
return TRUE; // 不 PostRecv;握手线程完成后交还接收
}
// 明文:把嗅探阶段攒下的字节回放给现有解析路径,然后清空缓冲。
ContextObject->tlsState.store(TLS_Plain, std::memory_order_release);
std::vector<BYTE> replay = std::move(ContextObject->tlsPreClassify);
ContextObject->tlsPreClassify.clear();
if (FALSE == ParseReceivedData(ContextObject, replay.data(), (DWORD)replay.size(), m_NotifyProc, ctx, z)) {
ContextObject->IoRefCount.fetch_sub(1);
RemoveStaleContext(ContextObject);
return -2;
}
PostRecv(ContextObject);
return TRUE;
}
// ---- 已分类 ----
if (st == TLS_Ready) {
if (dwTrans == 0) { // 明文 FIN:TLS 连接对端关闭
ContextObject->IoRefCount.fetch_sub(1);
RemoveStaleContext(ContextObject);
return -2;
}
std::vector<BYTE> plain;
if (ContextObject->tls->Decrypt((const BYTE*)ContextObject->szBuffer, dwTrans, plain) < 0) {
ContextObject->IoRefCount.fetch_sub(1);
RemoveStaleContext(ContextObject);
return -2;
}
if (plain.empty()) {
PostRecv(ContextObject); // 本轮无完整明文(TLS 记录分片/握手告警),继续收
return TRUE;
}
if (FALSE == ParseReceivedData(ContextObject, plain.data(), (DWORD)plain.size(), m_NotifyProc, ctx, z)) {
ContextObject->IoRefCount.fetch_sub(1);
RemoveStaleContext(ContextObject);
return -2;
}
PostRecv(ContextObject);
return TRUE;
}
// TLS_Handshaking:握手线程独占 socket,正常不应出现 IORead;若出现属异常,fail-closed。
if (st == TLS_Handshaking) {
Mprintf("[TLS] unexpected IORead during handshake for %s (closing)\n",
ContextObject->GetPeerName().c_str());
ContextObject->IoRefCount.fetch_sub(1);
RemoveStaleContext(ContextObject);
return -2;
}
// TLS_Plain:明文直通(原路径)。
if (FALSE == ParseReceivedData(ContextObject, (PBYTE)ContextObject->szBuffer, dwTrans, m_NotifyProc, ctx, z)) {
ContextObject->IoRefCount.fetch_sub(1);
RemoveStaleContext(ContextObject);
return -2;
}
PostRecv(ContextObject);
return TRUE;
}
@@ -967,16 +1133,50 @@ BOOL WriteContextData(CONTEXT_OBJECT* ContextObject, PBYTE szBuffer, size_t ulOr
BOOL IOCPServer::OnClientPreSending(CONTEXT_OBJECT* ContextObject, PBYTE szBuffer, size_t ulOriginalLength)
{
// TLS 下 OutCompressedBuffer 可能还残留上一轮尚未发完的密文(IOCP 线程异步 WSASend 中),
// 新明文由 WriteContextData 追加在尾部。若把整个缓冲区重新加密,会把旧密文二次加密,
// 客户端解密后得到的是「旧 TLS 记录」→ Unknown header 17 03 03。故只加密本次新追加的尾部。
bool tlsReady = (ContextObject->tlsState.load(std::memory_order_acquire) == TLS_Ready);
ULONG oldLen = tlsReady ? ContextObject->OutCompressedBuffer.GetBufferLength() : 0;
if (WriteContextData(ContextObject, szBuffer, ulOriginalLength, ContextObject->Zcctx)) {
// 单向 TLS:只加密新追加的明文尾部,产出密文后与前缀旧密文拼接,使缓冲区始终保持
// 一串合法 TLS 记录;之后 IOWrite / OnClientPostSending 完全不变(见设计文档 §5.5)。
if (tlsReady) {
ULONG totalLen = ContextObject->OutCompressedBuffer.GetBufferLength();
ULONG plainLen = totalLen - oldLen;
if (plainLen > 0) {
std::vector<BYTE> wire;
if (ContextObject->tls->Encrypt((const BYTE*)ContextObject->OutCompressedBuffer.GetBuffer(oldLen),
(int)plainLen, wire) != (int)plainLen) {
Mprintf("[TLS] Encrypt failed for %s (fail-closed)\n",
ContextObject->GetPeerName().c_str());
return FALSE; // 加密失败:丢弃本次发送
}
// 截断尾部明文,再追加密文。用 TruncateBuffer 而非 ClearBuffer:ClearBuffer 会
// DeAllocateBuffer(可能 VirtualFree 正在被异步 WSASend 引用的底层内存)。
ContextObject->OutCompressedBuffer.TruncateBuffer(oldLen);
ContextObject->OutCompressedBuffer.WriteBuffer(wire.data(), (DWORD)wire.size());
}
}
// 仅当没有发送正在进行时才投递 IOWrite 唤醒发送。若已有 WSASend 在途,其完成回调
// (OnClientPostSending)会看到排空后仍残留的新数据并继续发送,无需再投递;否则会对
// 同一缓冲并发多个 WSASend,读到同一段可变内存,产出重复/错位密文(客户端 DecryptMessage
// 失败,表现为 recv -1、GetLastError=6001)。
if (!ContextObject->bSendPending) {
ContextObject->bSendPending = true;
OVERLAPPEDPLUS* OverlappedPlus = new OVERLAPPEDPLUS(IOWrite);
BOOL bOk = PostQueuedCompletionStatus(m_hCompletionPort, 0, (ULONG_PTR)ContextObject, &OverlappedPlus->m_ol);
if ( (!bOk && GetLastError() != ERROR_IO_PENDING) ) { //如果投递失败
int a = GetLastError();
Mprintf("!!! OnClientPreSending 投递消息失败\n");
ContextObject->bSendPending = false;
RemoveStaleContext(ContextObject);
SAFE_DELETE(OverlappedPlus);
return FALSE;
}
}
return TRUE;
}
return FALSE;
@@ -990,6 +1190,7 @@ BOOL IOCPServer::OnClientPostSending(CONTEXT_OBJECT* ContextObject,ULONG ulCompl
ContextObject->OutCompressedBuffer.RemoveCompletedBuffer(ulCompletedLength); //将完成的数据从数据结构中去除
if (ContextObject->OutCompressedBuffer.GetBufferLength() == 0) {
ContextObject->bSendPending = false; // 缓冲排空,允许下一次 OnClientPreSending 投递
ContextObject->OutCompressedBuffer.ClearBuffer();
return true; //走到这里说明我们的数据真正完全发送
} else {
@@ -1001,6 +1202,7 @@ BOOL IOCPServer::OnClientPostSending(CONTEXT_OBJECT* ContextObject,ULONG ulCompl
NULL, ulFlags,&OverlappedPlus->m_ol, NULL);
if ( iOk == SOCKET_ERROR && WSAGetLastError() != WSA_IO_PENDING ) {
// 先减少引用计数,再调用 RemoveStaleContext (#215)
ContextObject->bSendPending = false;
ContextObject->IoRefCount.fetch_sub(1);
if (RemoveStaleContext(ContextObject))
Mprintf("!!! OnClientPostSending 投递消息失败: %d\n", WSAGetLastError());
+5 -1
View File
@@ -91,6 +91,7 @@ private:
static DWORD WINAPI ListenThreadProc(LPVOID lParam);
static DWORD WINAPI WorkThreadProc(LPVOID lParam);
static DWORD WINAPI RttPollThreadProc(LPVOID lParam);
static DWORD WINAPI TlsHandshakeThreadProc(LPVOID lParam);
BOOL InitializeIOCP(VOID);
VOID OnAccept();
@@ -101,6 +102,7 @@ private:
BOOL HandleIO(IOType PacketFlags, PCONTEXT_OBJECT ContextObject, DWORD dwTrans, ZSTD_DCtx* ctx, z_stream *z);
BOOL OnClientInitializing(PCONTEXT_OBJECT ContextObject, DWORD dwTrans);
BOOL OnClientReceiving(PCONTEXT_OBJECT ContextObject, DWORD dwTrans, ZSTD_DCtx* ctx, z_stream* z);
BOOL StartTlsHandshake(CONTEXT_OBJECT* ContextObject);
BOOL OnClientPreSending(CONTEXT_OBJECT* ContextObject, PBYTE szBuffer, size_t ulOriginalLength);
BOOL OnClientPostSending(CONTEXT_OBJECT* ContextObject, ULONG ulCompressedLength);
int AddWorkThread(int n)
@@ -257,6 +259,8 @@ public:
typedef CDialogBase DialogBase;
BOOL ParseReceivedData(CONTEXT_OBJECT* ContextObject, DWORD dwTrans, pfnNotifyProc m_NotifyProc, ZSTD_DCtx *ctx=NULL, z_stream* z=NULL);
// 解析一段已解密/直通的接收字节。data/dataLen 为数据源(TLS 连接传解密后的明文,
// 明文连接传 szBuffer 原始字节);dataLen==0 视为对端关闭。
BOOL ParseReceivedData(CONTEXT_OBJECT* ContextObject, PBYTE data, DWORD dataLen, pfnNotifyProc m_NotifyProc, ZSTD_DCtx *ctx=NULL, z_stream* z=NULL);
BOOL WriteContextData(CONTEXT_OBJECT* ContextObject, PBYTE szBuffer, size_t ulOriginalLength, ZSTD_CCtx *ctx=NULL, z_stream* z = NULL);
+1 -1
View File
@@ -107,7 +107,7 @@ void IOCPUDPServer::WorkerThread()
IO_CONTEXT* ioCtx = CONTAINING_RECORD(pOverlapped, IO_CONTEXT, ol);
CONTEXT_UDP* ctx = ioCtx->pContext;
BOOL ret = ParseReceivedData(ctx, bytes, m_notify);
BOOL ret = ParseReceivedData(ctx, (PBYTE)ctx->szBuffer, bytes, m_notify);
if (999 != ret)
ctx->Destroy();
+15 -2
View File
@@ -123,6 +123,9 @@ BOOL CKeyBoardDlg::OnInitDialog()
// 后,控件内部以 Unicode 存储,W 版消息直通,不再走 CP_ACP。
// -----------------------------------------------------------------
RebuildEdit(m_edit);
// 剪切板显示框同样重建为 Unicode 控件,ShowClipboardText 全程走 W 版消息,
// 否则 MBCS 下 SetWindowTextA 会在 A<->W 边界按 CP_ACP 转码,中文丢失。
RebuildEdit(m_EditClipText);
m_edit.SetLimitText(MAXDWORD); // 设置最大长度
auto rule = utf8_to_ansi((char*)m_TextRule.param);
@@ -171,9 +174,19 @@ void CKeyBoardDlg::OnReceiveComplete()
LRESULT CKeyBoardDlg::ShowClipboardText(WPARAM wParam, LPARAM lParam)
{
char* buf = (char*)wParam;
std::string text = utf8_to_ansi(buf);
// 剪切板文本是 UTF-8(client/my_clip.h 的 clip::get_text 从 CF_UNICODETEXT 转出)。
// 直接转宽字符串,用 W 版写入重建后的 Unicode 控件,绕开 utf8_to_ansi 的 CP_ACP
// 回转——非中文系统(德语/英语)上中文也能正常显示(与 m_edit 同法)。
std::wstring w;
if (buf && buf[0]) {
int wlen = MultiByteToWideChar(CP_UTF8, 0, buf, -1, NULL, 0);
if (wlen > 1) {
w.resize(wlen - 1);
MultiByteToWideChar(CP_UTF8, 0, buf, -1, &w[0], wlen);
}
}
SAFE_DELETE_ARRAY(buf);
m_EditClipText.SetWindowTextA(text.c_str());
::SetWindowTextW(m_EditClipText.GetSafeHwnd(), w.c_str());
return S_OK;
}
+5 -1
View File
@@ -1126,6 +1126,10 @@ VOID CScreenSpyDlg::OnReceiveComplete()
case TOKEN_CLIPBOARD_TEXT: {
Buffer str = m_ContextObject->InDeCompressedBuffer.GetMyBuffer(1);
UpdateServerClipboard(str.c_str(), str.length());
// 反向回传:远程剪贴板文本(UTF-8)→ Web 客户端
if (m_bIsWebSession && WebService().IsRunning()) {
WebService().NotifyClipboardText(m_ClientID, std::string(str.c_str(), str.length()));
}
break;
}
case TOKEN_BITMAPINFO: {
@@ -3643,7 +3647,7 @@ void CScreenSpyDlg::OnDropFiles(HDROP hDropInfo)
memcpy((char*)szBuffer + 1 + masterId.length(), hmac.c_str(), hmac.length());
memcpy(szBuffer + 1 + 80, str.data(), str.size());
auto md5 = CalcMD5FromBytes((BYTE*)str.data(), str.size());
m_pParent->m_CmdList.PutCmd(md5);
m_pParent->m_CmdList.PutCmd(md5, m_ContextObject->GetPeerName());
m_ContextObject->Send2Client(szBuffer, 81 + str.size());
Mprintf("【拖拽】 [本地 -> 远程] V1 传输: %s\n", md5.c_str());
SAFE_DELETE_ARRAY(szBuffer);
+31
View File
@@ -329,6 +329,17 @@ public:
#define PREALLOC_DECOMPRESS_SIZE (4 * 1024)
#include "context.h"
// 每连接传输层状态(单向 TLS,见 docs/TransportTLS_MFCServer_Design.md §5.2)。
// 明文与 TLS 客户端在同一端口共存:首包按 ClientHello 头嗅探分派。
enum ServerTlsState {
TLS_Unclassified = 0, // 尚未收到足够字节判定(缓冲 tlsPreClassify)
TLS_Plain = 1, // 明文客户端,直通现有解析路径
TLS_Handshaking = 2, // TLS 客户端,握手线程阻塞 AcceptSecurityContext 中
TLS_Ready = 3, // TLS 握手完成,收包解密 / 发包加密
};
class ServerTlsSession; // 定义见 ServerTlsSession.h(避免把 Schannel 头拖进本文件)
typedef class CONTEXT_OBJECT : public context
{
public:
@@ -341,6 +352,7 @@ public:
FreeDecompressBuffer();
FreeCompressBuffer();
FreeSendCompressBuffer();
FreeTls();
if (Zcctx) {
ZSTD_freeCCtx(Zcctx);
Zcctx = nullptr;
@@ -377,6 +389,11 @@ public:
ikcpcb* kcp = nullptr; // 新增,指向KCP会话
std::string GroupName; // 分组名称
CLock SendLock; // fix #214
// 发送是否处于「进行中」状态。OnClientPreSending 每次 Send2Client 都会 Post 一个
// IOWrite,若不加节制,OnClientPostSending(0) 会对同一 OutCompressedBuffer 并发发起
// 多个 WSASend,读到同一段可变内存(背靠背小包最易触发:resize→COMMAND_NEXT→resize)。
// 置位后抑制多余的 IOWrite,待缓冲排空(最后一个 WSASend 完成)再清零。仅受 SendLock 保护。
bool bSendPending = false;
time_t OnlineTime = 0; // 上线时间
time_t LastHeartbeatTime = 0; // 最后心跳时间
@@ -392,6 +409,11 @@ public:
// 试用版反代理逐连接检测器。仅由 IOCPServer 的 RTT 轮询线程访问,免锁。
TcpRttBreachDetector m_RttDetector;
// ---- 单向 TLS 状态(跨线程:握手线程写入 TLS_Ready,工作线程读取;用 atomic 保序)----
std::atomic<int> tlsState{ TLS_Unclassified }; // ServerTlsState
ServerTlsSession* tls = nullptr; // 每连接 TLS 会话(握手线程独占→就绪后共用)
std::vector<BYTE> tlsPreClassify; // 分类前缓冲的首批字节(嗅探 ClientHello 头)
// 子连接身份校验:客户端发 TOKEN_CONN_AUTH 通过验证后置位。
// 主连接(走 TOKEN_LOGIN 流程)不参与此机制。当前阶段宽容(未通过也接受),
// 仅作为标记供后续命令处理 / 未来收紧策略使用。
@@ -495,6 +517,9 @@ public:
}
}
// 释放每连接 TLS 会话。定义在 ServerTlsSession.cpp(需 ServerTlsSession 完整类型才能 delete)。
void FreeTls();
std::string GetProtocol() const override
{
return Parser.m_Masker && Parser.m_Masker->GetMaskType() == MaskTypeNone ? "TCP" : "HTTP";
@@ -539,6 +564,12 @@ public:
m_RttUs.store(0, std::memory_order_release);
m_LastRttSampleMs.store(0, std::memory_order_release);
m_RttDetector.Reset();
// 复用对象池时重置 TLS 状态:释放旧会话、回到未分类、清空嗅探缓冲。
FreeTls();
tlsState.store(TLS_Unclassified, std::memory_order_release);
tlsPreClassify.clear();
// 复用对象池时清空发送进行中标志,避免上次连接残留的 bSendPending 抑制新连接的发送。
bSendPending = false;
}
void SetAuthenticated(bool v) { m_bAuthenticated.store(v, std::memory_order_release); }
bool IsAuthenticated() const { return m_bAuthenticated.load(std::memory_order_acquire); }
+604
View File
@@ -0,0 +1,604 @@
// ServerTlsSession.cpp — 服务端单向 TLS(Schannel 入站)实现
//
// 与客户端 TlsChannel.cpp 对称,但走 AcceptSecurityContext 入站握手、DecryptMessage/
// EncryptMessage 流式加解密。证书/私钥加载为 PEM + CNG(NCryptImportKey PKCS#8),
// 以便与 Go 服务端(server/go 的 crypto/tls,YAMA_TLS_CERT/KEY)共用同一对密钥。
#include "StdAfx.h"
#include "ServerTlsSession.h"
#include "Server.h" // CONTEXT_OBJECT::FreeTls()
#include "common/iniFile.h" // config(GetInt/GetStr)
#include <ncrypt.h>
#include <bcrypt.h>
#include <fstream>
#include <iterator>
#include <sstream>
#pragma comment(lib, "secur32.lib")
#pragma comment(lib, "crypt32.lib")
#pragma comment(lib, "ncrypt.lib")
namespace
{
// 进程级身份(进程启动加载一次;未来 UI/指令热切换时改为引用计数 + 惰性释放)。
std::mutex g_identityMtx;
bool g_enabled = false;
bool g_ready = false;
bool g_loadError = false;
CredHandle g_cred = {};
bool g_credValid = false;
PCCERT_CONTEXT g_cert = NULL; // 与入站凭据同生命周期,FreeIdentity 时释放
NCRYPT_KEY_HANDLE g_persistedKey = 0; // 已持久化到 CNG KSP 的私钥句柄,FreeIdentity 时删除
// 持久化私钥在 CNG KSP 中的名字。用固定名 + NCRYPT_OVERWRITE_KEY_FLAG,重启时覆盖旧键,
// 避免残留;同名键的密钥材料与 server.key 相同,覆盖无副作用。
const wchar_t* g_keyName = L"YamaServerTlsKey";
// 一次性读文件为 std::string(二进制安全)。
bool ReadFileText(const std::string& path, std::string& out)
{
std::ifstream f(path, std::ios::binary);
if (!f.is_open())
return false;
std::ostringstream ss;
ss << f.rdbuf();
if (f.bad())
return false;
out = ss.str();
return true;
}
// PEM(-----BEGIN ...----- 包裹的 base64)解码为 DER。失败返回 false。
// CRYPT_STRING_ANY 会自动识别带头的 base64(PEM),对证书与 PKCS#8 私钥均适用。
bool PemToDer(const std::string& pem, std::vector<BYTE>& der)
{
if (pem.empty())
return false;
DWORD need = 0;
if (!CryptStringToBinaryA(pem.data(), (DWORD)pem.size(), CRYPT_STRING_ANY,
NULL, &need, NULL, NULL))
return false;
der.resize(need);
return CryptStringToBinaryA(pem.data(), (DWORD)pem.size(), CRYPT_STRING_ANY,
der.data(), &need, NULL, NULL) == TRUE;
}
// 从 PEM 文本里定位私钥块的类型,用于给出可操作的错误提示。
enum KeyFmt { KF_PKCS8, KF_PKCS1, KF_UNKNOWN };
KeyFmt DetectKeyFormat(const std::string& keyPem)
{
if (keyPem.find("-----BEGIN PRIVATE KEY-----") != std::string::npos)
return KF_PKCS8;
if (keyPem.find("-----BEGIN RSA PRIVATE KEY-----") != std::string::npos ||
keyPem.find("-----BEGIN EC PRIVATE KEY-----") != std::string::npos)
return KF_PKCS1;
return KF_UNKNOWN;
}
}
// =====================================================================================
// ServerTlsIdentity —— 进程级服务端身份(可重入加载,禁止 call_once,供未来热切换)
// =====================================================================================
void ServerTlsIdentity::FreeIdentity()
{
// 先释放凭据,再释放证书上下文,最后删除持久化私钥(凭据/证书可能仍按名引用该键)。
if (g_credValid)
{
FreeCredentialsHandle(&g_cred);
g_credValid = false;
}
g_cred = {};
if (g_cert)
{
CertFreeCertificateContext(g_cert);
g_cert = NULL;
}
if (g_persistedKey)
{
NCryptDeleteKey(g_persistedKey, 0); // 删除并释放句柄
g_persistedKey = 0;
}
}
bool ServerTlsIdentity::Load(config& cfg)
{
std::lock_guard<std::mutex> lk(g_identityMtx);
bool enabled = cfg.GetInt("tls", "Enabled", 0) != 0;
std::string certPath = cfg.GetStr("tls", "CertPath", "");
std::string keyPath = cfg.GetStr("tls", "KeyPath", "");
g_enabled = enabled;
// 先释放旧身份(本轮只在启动时调用一次,不存在「旧身份仍被并发握手使用」的问题;
// 未来热切换需先引用计数、再惰性释放旧凭据)。
FreeIdentity();
g_ready = false;
g_loadError = false;
if (!enabled)
{
Mprintf("[TLS] server TLS disabled ([tls] Enabled=0) — plaintext only\n");
return true;
}
if (certPath.empty() || keyPath.empty())
{
g_loadError = true;
Mprintf("[TLS] FATAL: [tls] Enabled=1 but CertPath/KeyPath empty; "
"refusing TLS ClientHello (fail-closed)\n");
return false;
}
if (!LoadIdentity(certPath, keyPath))
{
g_loadError = true;
return false;
}
g_ready = true;
Mprintf("[TLS] server TLS enabled: cert=%s key=%s\n", certPath.c_str(), keyPath.c_str());
return true;
}
bool ServerTlsIdentity::LoadIdentity(const std::string& certPath, const std::string& keyPath)
{
std::string certPem, keyPem;
if (!ReadFileText(certPath, certPem))
{
Mprintf("[TLS] FATAL: cannot read cert file %s\n", certPath.c_str());
return false;
}
if (!ReadFileText(keyPath, keyPem))
{
Mprintf("[TLS] FATAL: cannot read key file %s\n", keyPath.c_str());
return false;
}
// 1) 证书:PEM → DER → PCCERT_CONTEXT。
std::vector<BYTE> certDer;
if (!PemToDer(certPem, certDer))
{
Mprintf("[TLS] FATAL: cert file %s is not a valid PEM\n", certPath.c_str());
return false;
}
PCCERT_CONTEXT cert = CertCreateCertificateContext(X509_ASN_ENCODING,
certDer.data(), (DWORD)certDer.size());
if (cert == NULL)
{
Mprintf("[TLS] FATAL: CertCreateCertificateContext failed (%u)\n", GetLastError());
return false;
}
// 2) 私钥:PEM → DER → 持久化到 CNG KSP(仅支持 PKCS#8;PKCS#1 给出可操作的转换提示)。
//
// 关键:Schannel 入站凭据(AcquireCredentialsHandle)需要「能按名重开」的持久化私钥。
// NCryptImportKey 产出的是匿名临时键,绑定 CERT_NCRYPT_KEY_HANDLE_PROP_ID 后 Schannel 仍
// 拿不到(AcquireCredentialsHandle 返回 SEC_E_NO_CREDENTIALS 0x8009030e)。因此先把
// PKCS#8 导入为临时键、导出为裸 RSA 私钥 BLOB(NCryptSetProperty 只认裸 BLOB,不认
// PKCS#8),再写入持久化键并 finalize,最后用 CERT_KEY_PROV_INFO_PROP_ID 按名引用。
std::vector<BYTE> keyDer;
NCRYPT_KEY_HANDLE persistedKey = 0;
if (DetectKeyFormat(keyPem) == KF_PKCS8)
{
if (!PemToDer(keyPem, keyDer))
{
Mprintf("[TLS] FATAL: key file %s is not a valid PEM\n", keyPath.c_str());
CertFreeCertificateContext(cert);
return false;
}
// 显式打开 Microsoft Software Key Storage Provider。hProvider 传 NULL 会让
// NCryptImportKey 走「默认提供者」解析,实测返回 NTE_INVALID_HANDLE(0x80090026),
// 故必须显式取得 KSP 句柄(临时键独立于 hProv,导入后即可释放)。
NCRYPT_PROV_HANDLE hProv = 0;
SECURITY_STATUS st = NCryptOpenStorageProvider(&hProv, MS_KEY_STORAGE_PROVIDER, 0);
if (st != ERROR_SUCCESS)
{
Mprintf("[TLS] FATAL: NCryptOpenStorageProvider failed 0x%08x\n", (unsigned)st);
CertFreeCertificateContext(cert);
return false;
}
// 2a) PKCS#8 → 临时键。
NCRYPT_KEY_HANDLE tmp = 0;
st = NCryptImportKey(hProv, NULL, NCRYPT_PKCS8_PRIVATE_KEY_BLOB, NULL,
&tmp, keyDer.data(), (DWORD)keyDer.size(), 0);
if (st == ERROR_SUCCESS)
{
// 2b) 允许明文导出,导出为裸 RSA 私钥 BLOB。
DWORD policy = NCRYPT_ALLOW_PLAINTEXT_EXPORT_FLAG | NCRYPT_ALLOW_EXPORT_FLAG;
NCryptSetProperty(tmp, NCRYPT_EXPORT_POLICY_PROPERTY, (PBYTE)&policy, sizeof(policy), 0);
std::vector<BYTE> raw;
DWORD rawLen = 0;
st = NCryptExportKey(tmp, NULL, BCRYPT_RSAFULLPRIVATE_BLOB, NULL, NULL, 0, &rawLen, 0);
if (st == ERROR_SUCCESS)
{
raw.resize(rawLen);
st = NCryptExportKey(tmp, NULL, BCRYPT_RSAFULLPRIVATE_BLOB, NULL,
raw.data(), rawLen, &rawLen, 0);
}
// 2c) 写入持久化键并 finalize(落到 KSP,可被 Schannel 按名重开)。
if (st == ERROR_SUCCESS)
st = NCryptCreatePersistedKey(hProv, &persistedKey, BCRYPT_RSA_ALGORITHM,
g_keyName, AT_KEYEXCHANGE, NCRYPT_OVERWRITE_KEY_FLAG);
if (st == ERROR_SUCCESS)
st = NCryptSetProperty(persistedKey, BCRYPT_RSAFULLPRIVATE_BLOB,
raw.data(), (DWORD)raw.size(), 0);
if (st == ERROR_SUCCESS)
st = NCryptFinalizeKey(persistedKey, 0);
NCryptFreeObject(tmp);
}
NCryptFreeObject(hProv);
if (st != ERROR_SUCCESS)
{
Mprintf("[TLS] FATAL: failed to persist PKCS#8 key for %s (0x%08x)\n",
keyPath.c_str(), (unsigned)st);
if (persistedKey)
NCryptFreeObject(persistedKey);
CertFreeCertificateContext(cert);
return false;
}
}
else
{
// PKCS#1(RSA/EC PRIVATE KEY)或未知格式:本次不支持,给转换命令。
Mprintf("[TLS] FATAL: key file %s must be PKCS#8 (-----BEGIN PRIVATE KEY-----). "
"Convert with: openssl pkcs8 -topk8 -nocrypt -in %s -out %s\n",
keyPath.c_str(), keyPath.c_str(), keyPath.c_str());
CertFreeCertificateContext(cert);
return false;
}
// 3) 绑定私钥到证书上下文:按名引用持久化键。Schannel 经 CryptAcquireCertificatePrivateKey
// 在 MS_KEY_STORAGE_PROVIDER 里用 pwszContainerName 重开该键。
CRYPT_KEY_PROV_INFO provInfo = {};
provInfo.pwszContainerName = (LPWSTR)g_keyName;
provInfo.pwszProvName = (LPWSTR)MS_KEY_STORAGE_PROVIDER;
provInfo.dwProvType = 0; // 0 = CNG(无 CAPI provider type)
provInfo.dwKeySpec = AT_KEYEXCHANGE;
if (!CertSetCertificateContextProperty(cert, CERT_KEY_PROV_INFO_PROP_ID, 0, &provInfo))
{
Mprintf("[TLS] FATAL: CertSetCertificateContextProperty(CERT_KEY_PROV_INFO_PROP_ID) "
"failed (%u)\n", GetLastError());
NCryptDeleteKey(persistedKey, 0); // 删除并释放句柄
CertFreeCertificateContext(cert);
return false;
}
// 绑定后持久化键句柄交进程级 g_persistedKey,随 FreeIdentity 删除(覆盖旧键,避免残留)。
g_persistedKey = persistedKey;
// 4) 入站凭据:显式指定服务端证书,不请求客户端证书(单向 TLS)。
SCHANNEL_CRED sc = {};
sc.dwVersion = SCHANNEL_CRED_VERSION;
sc.cCreds = 1;
sc.paCred = &cert;
sc.grbitEnabledProtocols = SP_PROT_TLS1_2_SERVER;
// NO_DEFAULT_CREDS 避免回退到系统默认凭据,确保只用我们提供的证书/私钥。
//(单向 TLS 不请求客户端证书,故无需 SCH_CRED_MANUAL_CRED_VALIDATION。)
sc.dwFlags = SCH_CRED_NO_DEFAULT_CREDS;
TimeStamp tsExpiry = {};
SECURITY_STATUS st = AcquireCredentialsHandleA(
NULL, (SEC_CHAR*)"Microsoft Unified Security Protocol Provider", SECPKG_CRED_INBOUND,
NULL, &sc, NULL, NULL, &g_cred, &tsExpiry);
if (st != SEC_E_OK)
{
Mprintf("[TLS] FATAL: AcquireCredentialsHandle(SECPKG_CRED_INBOUND) failed 0x%08x\n",
(unsigned)st);
CertFreeCertificateContext(cert); // 无凭据引用,安全释放
g_cred = {};
if (g_persistedKey) // 回滚:删除已持久化的私钥,避免残留
{
NCryptDeleteKey(g_persistedKey, 0); // 删除并释放句柄
g_persistedKey = 0;
}
return false;
}
// 成功:把证书上下文生命周期交给进程级 g_cert,随 FreeIdentity 释放,
// 保证在 FreeCredentialsHandle 之前私钥一直可用。
g_cert = cert;
g_credValid = true;
return true;
}
bool ServerTlsIdentity::IsEnabled()
{
std::lock_guard<std::mutex> lk(g_identityMtx);
return g_enabled;
}
bool ServerTlsIdentity::IsReady()
{
std::lock_guard<std::mutex> lk(g_identityMtx);
return g_ready && g_credValid;
}
bool ServerTlsIdentity::IsLoadError()
{
std::lock_guard<std::mutex> lk(g_identityMtx);
return g_loadError;
}
CredHandle ServerTlsIdentity::Cred()
{
std::lock_guard<std::mutex> lk(g_identityMtx);
return g_cred;
}
// =====================================================================================
// ServerTlsSession —— 单连接会话(握手 + 加解密)
// =====================================================================================
void ServerTlsSession::CloseHandles()
{
if (m_ctxValid)
{
DeleteSecurityContext(&m_ctx);
m_ctxValid = false;
}
m_ctx = {};
m_ready = false;
m_raw.clear();
}
// 握手线程在 IOCP 的子套接字上做同步 I/O。该套接字可能是非阻塞的(监听套接字经
// WSAEventSelect 置为非阻塞后,accept 出的子套接字继承非阻塞态;线程里 FIONBIO=0 也
// 不一定即时生效)。故 recv/send 遇 WSAEWOULDBLOCK 时用 select 等可读/可写再重试,
// 而非当致命错误,避免「客户端回包还没到就误判握手失败」。
static bool WaitSocketReady(SOCKET sock, bool forRead, int timeoutMs)
{
fd_set fds;
FD_ZERO(&fds);
FD_SET(sock, &fds);
timeval tv;
tv.tv_sec = timeoutMs / 1000;
tv.tv_usec = (timeoutMs % 1000) * 1000;
return select(0, forRead ? &fds : NULL, forRead ? NULL : &fds, NULL, &tv) > 0;
}
bool ServerTlsSession::SendAll(SOCKET sock, const void* data, int len)
{
const BYTE* p = (const BYTE*)data;
int sent = 0;
while (sent < len)
{
int n = send(sock, (const char*)p + sent, len - sent, 0);
if (n == SOCKET_ERROR && WSAGetLastError() == WSAEWOULDBLOCK)
{
if (!WaitSocketReady(sock, false, 15000))
return false;
continue; // 非阻塞套接字发送缓冲满:等可写后重试
}
if (n <= 0)
return false;
sent += n;
}
return true;
}
bool ServerTlsSession::Handshake(SOCKET sock, const BYTE* initial, int initialLen)
{
std::lock_guard<std::mutex> lk(m_mtx);
if (m_ctxValid)
CloseHandles(); // 防御:会话应只握手一次
CredHandle cred = ServerTlsIdentity::Cred();
// 单向 TLS:服务端不请求客户端证书,故无需 ASC_REQ_MANUAL_CRED_VALIDATION
//(该标志只在 mTLS 校验客户端证书时有意义;SDK 的 ASC 命名空间里亦无此常量)。
DWORD req = ASC_REQ_SEQUENCE_DETECT | ASC_REQ_REPLAY_DETECT | ASC_REQ_CONFIDENTIALITY |
ASC_REQ_ALLOCATE_MEMORY | ASC_REQ_STREAM;
std::vector<BYTE> hs(initial, initial + initialLen);
CtxtHandle ctx = {};
bool ctxValid = false;
TimeStamp tsExpiry = {};
ULONG attrs = 0;
SECURITY_STATUS sc = SEC_I_CONTINUE_NEEDED;
for (;;)
{
SecBuffer inBufs[2] = {
{ (ULONG)hs.size(), SECBUFFER_TOKEN, hs.empty() ? NULL : hs.data() },
{ 0, SECBUFFER_EMPTY, NULL },
};
SecBufferDesc inDesc = { SECBUFFER_VERSION, 2, inBufs };
SecBuffer outBuf = { 0, SECBUFFER_TOKEN, NULL };
SecBufferDesc outDesc = { SECBUFFER_VERSION, 1, &outBuf };
sc = AcceptSecurityContext(&cred, ctxValid ? &ctx : NULL, &inDesc, req,
SECURITY_NATIVE_DREP, ctxValid ? NULL : &ctx,
&outDesc, &attrs, &tsExpiry);
ctxValid = true;
// 发出服务端握手记录(ServerHello/Certificate/... 或 Finished)。
if (outBuf.cbBuffer > 0 && outBuf.pvBuffer)
{
bool ok = SendAll(sock, outBuf.pvBuffer, (int)outBuf.cbBuffer);
FreeContextBuffer(outBuf.pvBuffer);
if (!ok)
{
sc = SEC_E_INTERNAL_ERROR;
break;
}
}
// 计算已消费字节:SECBUFFER_EXTRA 里是「未消费的尾部」(下一条握手消息 / 首条应用数据)。
// 必须在 SEC_E_OK / 失败判定之前处理:否则握手完成时 hs 里仍混着已消费的握手记录,
// 紧跟 Finished 的首条应用数据(客户端登录包)会随 hs 一起被丢弃,导致随机丢包。
if (inBufs[1].BufferType == SECBUFFER_EXTRA)
{
int extra = (int)inBufs[1].cbBuffer;
hs.erase(hs.begin(), hs.begin() + (hs.size() - extra));
}
else if (sc != SEC_E_INCOMPLETE_MESSAGE && sc != SEC_I_INCOMPLETE_CREDENTIALS)
{
hs.clear(); // 已全部消费,继续等下一轮握手消息
}
// 否则:SEC_E_INCOMPLETE_MESSAGE —— 保留 hs(不完整消息),续收补齐。
if (sc == SEC_E_OK)
break;
if (FAILED(sc) && sc != SEC_E_INCOMPLETE_MESSAGE && sc != SEC_I_INCOMPLETE_CREDENTIALS)
{
Mprintf("[TLS] AcceptSecurityContext failed 0x%08x (in=%u consumed? extra=%u)\n",
(unsigned)sc, inBufs[0].cbBuffer, inBufs[1].BufferType == SECBUFFER_EXTRA ? inBufs[1].cbBuffer : 0);
break; // 真正的失败
}
char raw[8192];
int r;
for (;;)
{
r = recv(sock, raw, sizeof(raw), 0);
if (r == SOCKET_ERROR && WSAGetLastError() == WSAEWOULDBLOCK)
{
if (!WaitSocketReady(sock, true, 15000))
{
r = SOCKET_ERROR;
break; // 等可读超时
}
continue; // 非阻塞套接字暂无数据:等可读后重试
}
break;
}
if (r <= 0)
{
Mprintf("[TLS] handshake recv failed/EOF (r=%d err=%d)\n", r, WSAGetLastError());
sc = SEC_E_INTERNAL_ERROR;
break;
}
hs.insert(hs.end(), (BYTE*)raw, (BYTE*)raw + r);
}
if (sc != SEC_E_OK)
{
Mprintf("[TLS] handshake failed: final status 0x%08x (fail-closed)\n", (unsigned)sc);
if (ctxValid)
DeleteSecurityContext(&ctx);
return false;
}
// 握手成功后 hs 里残留的「未消费尾部」是紧跟 Finished 的首条应用数据(TLS 记录)。
// 交给 Decrypt 处理,否则首包会丢(表现为随机登录超时/重连/协议错位)。
if (!hs.empty())
m_raw.insert(m_raw.end(), hs.begin(), hs.end());
// 查询流式记录尺寸(加密封装用)。
SecPkgContext_StreamSizes sizes = {};
if (QueryContextAttributes(&ctx, SECPKG_ATTR_STREAM_SIZES, &sizes) != SEC_E_OK)
{
DeleteSecurityContext(&ctx);
return false;
}
m_ctx = ctx;
m_ctxValid = true;
m_sizes = sizes;
m_ready = true;
return true;
}
int ServerTlsSession::Decrypt(const BYTE* cipher, int len, std::vector<BYTE>& plain)
{
std::lock_guard<std::mutex> lk(m_mtx);
if (!m_ready)
return -1;
if (len <= 0)
return 0;
m_raw.insert(m_raw.end(), cipher, cipher + len);
for (;;)
{
SecBuffer bufs[4] = {
{ (ULONG)m_raw.size(), SECBUFFER_DATA, m_raw.empty() ? NULL : m_raw.data() },
{ 0, SECBUFFER_EMPTY, NULL },
{ 0, SECBUFFER_EMPTY, NULL },
{ 0, SECBUFFER_EMPTY, NULL },
};
SecBufferDesc desc = { SECBUFFER_VERSION, 4, bufs };
SECURITY_STATUS sc = DecryptMessage(&m_ctx, &desc, 0, NULL);
if (sc == SEC_E_INCOMPLETE_MESSAGE)
break; // 记录不完整,等下一轮 WSARecv
if (sc == SEC_I_CONTEXT_EXPIRED) {
Mprintf("[TLS] DecryptMessage: peer close_notify (SEC_I_CONTEXT_EXPIRED)\n");
return -1; // 对端 close_notify / 会话结束
}
if (sc == SEC_I_RENEGOTIATE || FAILED(sc)) {
Mprintf("[TLS] DecryptMessage failed 0x%08x (raw=%u)\n", (unsigned)sc, (unsigned)m_raw.size());
return -1;
}
int decLen = (int)bufs[1].cbBuffer;
const BYTE* p = (const BYTE*)bufs[1].pvBuffer;
int extra = (bufs[3].BufferType == SECBUFFER_EXTRA) ? (int)bufs[3].cbBuffer : 0;
int consumed = (int)m_raw.size() - extra;
if (decLen > 0 && p)
plain.insert(plain.end(), p, p + decLen);
m_raw.erase(m_raw.begin(), m_raw.begin() + consumed);
if (m_raw.empty())
break;
}
return 0;
}
int ServerTlsSession::Encrypt(const BYTE* plain, int len, std::vector<BYTE>& wire)
{
std::lock_guard<std::mutex> lk(m_mtx);
if (!m_ready)
return -1;
if (len <= 0)
return 0;
ULONG maxMsg = m_sizes.cbMaximumMessage > 0 ? m_sizes.cbMaximumMessage : 16384;
int total = 0;
while (total < len)
{
int chunk = (len - total > (int)maxMsg) ? (int)maxMsg : (len - total);
std::vector<BYTE> rec(m_sizes.cbHeader + chunk + m_sizes.cbTrailer);
BYTE* hdr = rec.data();
BYTE* body = hdr + m_sizes.cbHeader;
BYTE* trailer = body + chunk;
memcpy(body, plain + total, chunk);
SecBuffer bufs[4] = {
{ m_sizes.cbHeader, SECBUFFER_STREAM_HEADER, hdr },
{ (ULONG)chunk, SECBUFFER_DATA, body },
{ m_sizes.cbTrailer, SECBUFFER_STREAM_TRAILER, trailer },
{ 0, SECBUFFER_EMPTY, NULL },
};
SecBufferDesc desc = { SECBUFFER_VERSION, 4, bufs };
SECURITY_STATUS sc = EncryptMessage(&m_ctx, 0, &desc, 0);
if (FAILED(sc))
return -1; // 握手已建立,此处失败视为致命(调用方关闭连接)
int wireLen = (int)(bufs[0].cbBuffer + bufs[1].cbBuffer + bufs[2].cbBuffer);
wire.insert(wire.end(), rec.data(), rec.data() + wireLen);
total += chunk;
}
return total;
}
// =====================================================================================
// CONTEXT_OBJECT::FreeTls —— 释放每连接 TLS 会话(定义在此以便 delete 完整类型)
// =====================================================================================
void CONTEXT_OBJECT::FreeTls()
{
delete tls;
tls = NULL;
}
+92
View File
@@ -0,0 +1,92 @@
// ServerTlsSession.h — MFC 服务端单向 TLS(Schannel 入站)会话封装
//
// 与客户端 TlsChannel 对称:客户端走 InitializeSecurityContext(出站),本类走
// AcceptSecurityContext(入站)。单向 TLS:不请求/校验客户端证书,客户端身份仍由
// 隧道内登录(V1 PwdHmac / V2 ECDSA)承担。
//
// 分层:
// ServerTlsIdentity —— 进程级「服务端身份」(证书 + 私钥 → 入站凭据)。可重入加载
// (Load 禁止用 call_once,供未来 UI/指令热切换),本轮启动时加载一次。
// ServerTlsSession —— 每条连接一个,持有握手/加解密所需的 Schannel 上下文与缓冲,
// 随 CONTEXT_OBJECT 池化复用而重建。
//
// 线程模型:Handshake 在专用握手线程上阻塞执行(方案 A);握手完成后,Decrypt 由
// IOCP 工作线程调用(每连接同一时刻只有一个未决 WSARecv,天然串行)、Encrypt 在
// SendLock 内调用。Schannel 上下文非线程安全,故 Encrypt/Decrypt 内部以 m_mtx 互斥。
//
// 选型:Schannel(Windows 原生 SSPI),secur32.lib / crypt32.lib / ncrypt.lib 系统库。
// 证书/私钥为 PEM(与 Go 服务端 YAMA_TLS_CERT/KEY 同构,可共用同一对密钥);私钥
// 须为 PKCS#8(-----BEGIN PRIVATE KEY-----),见 ServerTlsSession.cpp 的加载实现。
#pragma once
#include <WinSock2.h>
#ifndef SECURITY_WIN32
#define SECURITY_WIN32 1
#endif
#include <security.h>
#include <schannel.h>
#include <mutex>
#include <string>
#include <vector>
class config; // common/iniFile.h(仅需引用,避免把 ini 头拖进 Server.h 的 include 图)
// ---- 进程级服务端身份(证书 + 私钥),可重入加载(禁止 call_once,供未来热切换)----
class ServerTlsIdentity
{
public:
// 从 cfg(THIS_CFG)读 [tls] 节:Enabled / CertPath / KeyPath,并加载身份。
// 返回 true 表示「配置已生效」(开关关、或开关开且身份加载成功);开关开但加载
// 失败时返回 false 且 IsLoadError()==true,服务端须对 TLS ClientHello fail-closed。
static bool Load(config& cfg);
static bool IsEnabled(); // [tls] Enabled != 0
static bool IsReady(); // 开关开 && 身份加载成功(有可用入站凭据)
static bool IsLoadError(); // 开关开 && 身份加载失败(fail-closed)
// 入站凭据(AcquireCredentialsHandle 产出)。返回拷贝;句柄值本身可安全拷贝。
static CredHandle Cred();
private:
static void FreeIdentity();
static bool LoadIdentity(const std::string& certPath, const std::string& keyPath);
};
// ---- 单连接服务端 TLS 会话(握手 + 加解密)----
class ServerTlsSession
{
public:
ServerTlsSession() {}
~ServerTlsSession() { CloseHandles(); }
ServerTlsSession(const ServerTlsSession&) = delete;
ServerTlsSession& operator=(const ServerTlsSession&) = delete;
// 阻塞式服务端握手(方案 A:专用握手线程上调用)。initial/initialLen 是嗅探阶段已
// 收到的 ClientHello 字节(可能只是分片;内部按 SEC_E_INCOMPLETE_MESSAGE 续收补齐)。
// 成功返回 true,失败 false(调用方关闭连接,fail-closed)。
bool Handshake(SOCKET sock, const BYTE* initial, int initialLen);
// 解密:把本次 WSARecv 收到的密文 cipher[0..len] 喂入,把能解出的明文追加到 plain。
// 返回 0 = 成功(可能无明文);-1 = 错误 / 对端 close_notify(调用方关闭连接)。
int Decrypt(const BYTE* cipher, int len, std::vector<BYTE>& plain);
// 加密:把明文 plain[0..len] 加密成完整 TLS 记录,追加到 wire。
// 返回加密的明文字节数(== len);-1 = 错误。
int Encrypt(const BYTE* plain, int len, std::vector<BYTE>& wire);
bool Ready() const { return m_ready; }
private:
void CloseHandles();
bool SendAll(SOCKET sock, const void* data, int len);
std::mutex m_mtx; // 序列化对 m_ctx 的 Encrypt/Decrypt(Schannel 非线程安全)
CtxtHandle m_ctx = {};
bool m_ctxValid = false;
bool m_ready = false;
SecPkgContext_StreamSizes m_sizes = {};
std::vector<BYTE> m_raw; // 尚未解密 / 未消费完的 TLS 记录原始字节
};
+76
View File
@@ -349,6 +349,75 @@ public:
void onHttp(HttpHandler handler) { m_httpHandler = handler; }
private:
// ---- Real client IP behind a reverse proxy ----
// A same-host nginx/Caddy connects from 127.0.0.1 and reports the original
// peer in X-Real-IP / X-Forwarded-For. Only trust those headers when the
// TCP peer is loopback: otherwise any internet client could forge its
// address in the audit log and rotate it to evade login throttling.
static std::string trimHttpValue(const std::string& s) {
size_t b = s.find_first_not_of(" \t");
if (b == std::string::npos) return "";
size_t e = s.find_last_not_of(" \t\r\n");
return s.substr(b, e - b + 1);
}
static std::string asciiLower(const std::string& s) {
std::string out = s;
for (char& c : out) {
if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a');
}
return out;
}
// Case-insensitive lookup of one header in the raw request. The request
// line is first, so matching "\r\n<name>:" cannot hit the request target.
static std::string httpHeader(const std::string& request, const char* name) {
const std::string needle = "\r\n" + asciiLower(name) + ":";
const std::string hay = asciiLower(request);
size_t pos = hay.find(needle);
if (pos == std::string::npos) return "";
size_t vstart = pos + needle.size();
size_t vend = hay.find("\r\n", vstart);
return trimHttpValue(request.substr(vstart,
vend == std::string::npos ? std::string::npos : vend - vstart));
}
// Accept only a bare IPv4/IPv6 literal; forwarded headers are attacker
// controlled even when the proxy is trusted, so no hostnames/ports.
static std::string normalizeIPLiteral(const std::string& value) {
std::string s = trimHttpValue(value);
if (s.empty() || s.size() > 63) return "";
struct in_addr v4;
if (inet_pton(AF_INET, s.c_str(), &v4) == 1) return s;
struct in6_addr v6;
if (inet_pton(AF_INET6, s.c_str(), &v6) == 1) return s;
return "";
}
static std::string forwardedClientIP(const std::string& request) {
// X-Forwarded-For is "client, proxy1, ..." with the oldest hop first.
// Our own proxy appends the peer it saw, so the last valid entry is
// the one it actually observed: a client-supplied list cannot forge
// it. Prefer this over X-Real-IP, which is only trustworthy when the
// proxy explicitly overwrites it (some configs pass it through).
std::string xff = httpHeader(request, "x-forwarded-for");
size_t end = xff.size();
while (end > 0) {
size_t comma = xff.rfind(',', end - 1);
size_t start = comma == std::string::npos ? 0 : comma + 1;
std::string ip = normalizeIPLiteral(xff.substr(start, end - start));
if (!ip.empty()) return ip;
if (comma == std::string::npos) break;
end = comma;
}
return normalizeIPLiteral(httpHeader(request, "x-real-ip"));
}
static bool isLoopbackPeer(const std::string& ip) {
return ip.rfind("127.", 0) == 0 || ip == "::1" ||
ip == "::ffff:127.0.0.1";
}
// Parse Proxy Protocol v2 header and extract real client IP
bool parseProxyProtocolV2(SOCKET sock, std::string& clientIP) {
// PP2 signature (12 bytes)
@@ -448,6 +517,13 @@ private:
if (request.find("\r\n\r\n") != std::string::npos) break;
}
// Behind a same-host reverse proxy the TCP peer is 127.0.0.1; recover
// the real client address the proxy recorded in the handshake.
if (isLoopbackPeer(clientIP)) {
std::string forwarded = forwardedClientIP(request);
if (!forwarded.empty()) clientIP = forwarded;
}
// Check if WebSocket upgrade
if (request.find("Upgrade: websocket") == std::string::npos) {
// Regular HTTP request - extract path
+234
View File
@@ -0,0 +1,234 @@
#include "stdafx.h"
#include "TlsCertificateDlg.h"
#include "LangManager.h" // _TR / MessageBoxL
#include "2015Remote.h" // THIS_CFG (GetThisCfg)
#include "ServerTlsSession.h" // ServerTlsIdentity::Load / IsLoadError
#include "common/iniFile.h" // config::GetInt/GetStr/SetInt/SetStr
#include <atlconv.h>
#include <afxdlgs.h>
#ifdef _DEBUG
#define new DEBUG_NEW
#endif
namespace {
// 构造一个无控件的 DLGTEMPLATE:cdit=0,控件由 OnInitDialog 动态创建。
// (与 McpSettingsDlg.cpp / ZstaPickerDlg.cpp 内同名函数一致)
void BuildDialogTemplate(std::vector<BYTE>& out, LPCWSTR caption, short cx, short cy)
{
out.clear();
auto append = [&](const void* p, size_t n) {
const BYTE* b = (const BYTE*)p;
out.insert(out.end(), b, b + n);
};
auto appendW = [&](WORD v) {
out.push_back((BYTE)(v & 0xFF));
out.push_back((BYTE)((v >> 8) & 0xFF));
};
auto appendWStr = [&](LPCWSTR s) {
size_t n = wcslen(s);
append(s, (n + 1) * sizeof(WCHAR));
};
DLGTEMPLATE dt = { 0 };
dt.style = DS_SETFONT | DS_MODALFRAME | DS_FIXEDSYS | DS_CENTER |
WS_POPUP | WS_CAPTION | WS_SYSMENU;
dt.dwExtendedStyle = 0;
dt.cdit = 0;
dt.x = 0; dt.y = 0;
dt.cx = cx; dt.cy = cy;
append(&dt, sizeof(dt));
appendW(0); // no menu
appendW(0); // default dialog class
appendWStr(caption); // caption
appendW(8); // font point size
appendWStr(L"MS Shell Dlg"); // typeface
while (out.size() % 4) out.push_back(0); // DWORD align
}
} // namespace
BEGIN_MESSAGE_MAP(CTlsCertificateDlg, CDialog)
ON_BN_CLICKED(IDC_TLS_CERT_BROWSE, &CTlsCertificateDlg::OnBrowseCert)
ON_BN_CLICKED(IDC_TLS_KEY_BROWSE, &CTlsCertificateDlg::OnBrowseKey)
END_MESSAGE_MAP()
CTlsCertificateDlg::CTlsCertificateDlg(CWnd* parent)
: CDialog((LPCTSTR)NULL, parent)
{
}
INT_PTR CTlsCertificateDlg::DoModal()
{
USES_CONVERSION;
CString title = _TR("TLS证书");
BuildDialogTemplate(m_Template, T2CW(title), 340, 180);
InitModalIndirect((LPCDLGTEMPLATE)m_Template.data());
return CDialog::DoModal();
}
BOOL CTlsCertificateDlg::OnInitDialog()
{
CDialog::OnInitDialog();
CRect cli;
GetClientRect(&cli);
// 占位 rect,真正布局在 LayoutControls 里
CRect r0(0, 0, 10, 10);
m_btnEnable.Create(_TR("启用加密"),
WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_AUTOCHECKBOX,
r0, this, IDC_TLS_ENABLE);
m_lblCert.Create(_TR("证书路径"), WS_CHILD | WS_VISIBLE, r0, this, (UINT)-1);
m_editCert.Create(WS_CHILD | WS_VISIBLE | WS_BORDER | WS_TABSTOP | ES_AUTOHSCROLL,
r0, this, IDC_TLS_CERT);
m_btnCertBrowse.Create(_TR("浏览"), WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_PUSHBUTTON,
r0, this, IDC_TLS_CERT_BROWSE);
m_lblKey.Create(_TR("私钥路径"), WS_CHILD | WS_VISIBLE, r0, this, (UINT)-1);
m_editKey.Create(WS_CHILD | WS_VISIBLE | WS_BORDER | WS_TABSTOP | ES_AUTOHSCROLL,
r0, this, IDC_TLS_KEY);
m_btnKeyBrowse.Create(_TR("浏览"), WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_PUSHBUTTON,
r0, this, IDC_TLS_KEY_BROWSE);
m_btnOK.Create(_TR("确定"), WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_DEFPUSHBUTTON,
r0, this, IDOK);
m_btnCancel.Create(_TR("取消"), WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_PUSHBUTTON,
r0, this, IDCANCEL);
// 子控件继承对话框字体 (DS_SETFONT)
HFONT hFont = (HFONT)::SendMessage(GetSafeHwnd(), WM_GETFONT, 0, 0);
if (hFont) {
m_btnEnable.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_lblCert.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_editCert.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_btnCertBrowse.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_lblKey.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_editKey.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_btnKeyBrowse.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_btnOK.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_btnCancel.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
}
// 回填配置
int enabled = THIS_CFG.GetInt("tls", "Enabled", 0);
std::string cert = THIS_CFG.GetStr("tls", "CertPath", "");
std::string key = THIS_CFG.GetStr("tls", "KeyPath", "");
m_btnEnable.SetCheck(enabled ? BST_CHECKED : BST_UNCHECKED);
m_editCert.SetWindowText(CString(cert.c_str()));
m_editKey.SetWindowText(CString(key.c_str()));
LayoutControls(cli.Width(), cli.Height());
return TRUE;
}
void CTlsCertificateDlg::OnOK()
{
CString sCert, sKey;
m_editCert.GetWindowText(sCert);
m_editKey.GetWindowText(sKey);
bool enabled = (m_btnEnable.GetCheck() == BST_CHECKED);
std::string cert = CT2A(sCert);
std::string key = CT2A(sKey);
// 校验:启用加密时必须填写证书/私钥路径
if (enabled && cert.empty()) {
MessageBoxL("请填写证书路径", "提示", MB_ICONWARNING);
return;
}
if (enabled && key.empty()) {
MessageBoxL("请填写私钥路径", "提示", MB_ICONWARNING);
return;
}
// 落盘 [tls] 三键
THIS_CFG.SetInt("tls", "Enabled", enabled ? 1 : 0);
THIS_CFG.SetStr("tls", "CertPath", cert);
THIS_CFG.SetStr("tls", "KeyPath", key);
// 热重载身份(设计文档 §7.1):开关与证书/私钥路径对新建连接即时生效。
// 已知窄竞态:Load 内部 FreeIdentity 与「已取 Cred 副本、尚在握手」的连接存在竞态,
// 未来热切换需引用计数 + 惰性释放(见 ServerTlsSession.cpp FreeIdentity 注释),本次不做。
ServerTlsIdentity::Load(THIS_CFG);
if (ServerTlsIdentity::IsLoadError()) {
MessageBoxL("证书/私钥加载失败,加密功能未生效。请检查文件路径与格式(证书 PEM、私钥 PKCS#8)。",
"提示", MB_ICONWARNING);
} else {
MessageBox(_TR("TLS 配置已保存。") + _T("\r\n") +
_TR("改动对新建连接即时生效。"),
_TR("提示"), MB_ICONINFORMATION);
}
CDialog::OnOK();
}
void CTlsCertificateDlg::OnBrowseCert()
{
CFileDialog dlg(TRUE, _T("crt"), nullptr,
OFN_FILEMUSTEXIST | OFN_HIDEREADONLY,
_T("Certificate Files (*.crt;*.pem;*.cer;*.cert)|*.crt;*.pem;*.cer;*.cert|All Files (*.*)|*.*||"),
this);
dlg.m_ofn.lpstrTitle = _T("Select Certificate");
if (dlg.DoModal() == IDOK) {
m_editCert.SetWindowText(dlg.GetPathName());
}
}
void CTlsCertificateDlg::OnBrowseKey()
{
CFileDialog dlg(TRUE, _T("key"), nullptr,
OFN_FILEMUSTEXIST | OFN_HIDEREADONLY,
_T("Key Files (*.key;*.pem)|*.key;*.pem|All Files (*.*)|*.*||"),
this);
dlg.m_ofn.lpstrTitle = _T("Select Private Key");
if (dlg.DoModal() == IDOK) {
m_editKey.SetWindowText(dlg.GetPathName());
}
}
void CTlsCertificateDlg::LayoutControls(int cx, int cy)
{
const int margin = 14;
const int labelW = 72;
const int rowH = 24;
const int gap = 10;
const int browseW = 64;
const int btnW = 88;
const int btnH = 26;
int y = margin;
m_btnEnable.MoveWindow(margin, y, cx - margin * 2, 22);
y += 30;
int editW = cx - margin * 2 - labelW - browseW - gap;
m_lblCert.MoveWindow(margin, y, labelW, rowH);
m_editCert.MoveWindow(margin + labelW, y - 2, editW, rowH);
m_btnCertBrowse.MoveWindow(cx - margin - browseW, y - 2, browseW, rowH);
y += rowH + gap;
m_lblKey.MoveWindow(margin, y, labelW, rowH);
m_editKey.MoveWindow(margin + labelW, y - 2, editW, rowH);
m_btnKeyBrowse.MoveWindow(cx - margin - browseW, y - 2, browseW, rowH);
y += rowH + gap;
// 确定 / 取消按钮紧随私钥路径下方
m_btnCancel.MoveWindow(cx - margin - btnW, y, btnW, btnH);
m_btnOK.MoveWindow(cx - margin - btnW * 2 - gap, y, btnW, btnH);
y += btnH + margin;
// 收缩对话框到内容实际高度并重新居中,消除底部空白。
if (y < cy) {
CRect wnd;
GetWindowRect(&wnd);
SetWindowPos(NULL, 0, 0, wnd.Width(), y + (wnd.Height() - cy),
SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE);
CenterWindow();
}
}
+43
View File
@@ -0,0 +1,43 @@
#pragma once
#include <afxwin.h>
#include <afxcmn.h>
#include <vector>
// 「TLS证书」配置对话框:启用服务端单向 TLS 加密 + 指定证书/私钥路径。
// 因 2015Remote.rc 为 UTF-16 编码、不宜手工编辑,参照 CMcpSettingsDlg 用
// InitModalIndirect + 内存 DLGTEMPLATE 程序化创建(见 docs/TransportTLS_MFCServer_Design.md §7.1)。
class CTlsCertificateDlg : public CDialog
{
public:
explicit CTlsCertificateDlg(CWnd* parent = nullptr);
virtual INT_PTR DoModal();
protected:
virtual BOOL OnInitDialog();
virtual void OnOK();
DECLARE_MESSAGE_MAP()
private:
enum CtrlId {
IDC_TLS_ENABLE = 2001, // 「启用加密」复选框
IDC_TLS_CERT = 2002, // 证书路径编辑框
IDC_TLS_CERT_BROWSE = 2003, // 证书路径「浏览...」按钮
IDC_TLS_KEY = 2004, // 私钥路径编辑框
IDC_TLS_KEY_BROWSE = 2005, // 私钥路径「浏览...」按钮
};
CButton m_btnEnable;
CStatic m_lblCert, m_lblKey;
CEdit m_editCert, m_editKey;
CButton m_btnCertBrowse, m_btnKeyBrowse;
CButton m_btnOK, m_btnCancel;
std::vector<BYTE> m_Template; // 内存 DLGTEMPLATE 字节
void LayoutControls(int cx, int cy);
void OnBrowseCert();
void OnBrowseKey();
};
+1
View File
@@ -172,6 +172,7 @@
#define HIDE_MENU_PLUGIN_REQUEST 0 // 插件请求
#define HIDE_MENU_FRPS_FOR_SUB 0 // 下级FRP
#define HIDE_MENU_MCP_SETTINGS 0 // MCP设置
#define HIDE_MENU_TLS_CERTIFICATE 0 // TLS证书
// --- 扩展菜单 - 语言子菜单 ---
#define HIDE_MENU_CHANGE_LANG 0 // 更改语言
File diff suppressed because it is too large. Load diff
+49 -8
View File
@@ -5,6 +5,8 @@
#include <vector>
#include <map>
#include <set>
#include <initializer_list>
#include <utility>
#include <atomic>
#include <chrono>
#include <mutex>
@@ -68,12 +70,20 @@ struct WebClient {
std::string token;
std::string username;
std::string role; // "admin" | "viewer"
uint64_t watch_device_id; // 0 = not watching any device
// 当前这个 ws 订阅的设备集合。屏幕墙允许多路复用;旧前端仍按单设备语义使用
// (connect 替换整个集合)。
std::set<uint64_t> watch_device_ids;
// 每台设备的 keyframe 请求节流时间戳(秒),防止前端重试打爆服务端/客户端。
std::map<uint64_t, uint64_t> keyframe_req_at;
uint64_t connected_at;
uint64_t last_activity; // Last message/pong received (for heartbeat timeout)
std::string client_ip;
// Per-connection audit de-duplication: probes an attacker can repeat in a
// tight loop are recorded once per connection instead of flooding the ring.
bool salt_probe_logged = false;
bool login_throttle_logged = false;
WebClient() : watch_device_id(0), connected_at(0), last_activity(0) {}
WebClient() : connected_at(0), last_activity(0) {}
};
// Login attempt tracking for rate limiting
@@ -160,6 +170,9 @@ public:
// Cursor change notification (called from ScreenSpyDlg)
void BroadcastCursor(uint64_t device_id, uint8_t cursor_index);
// 远程剪贴板文本回传通知(called from ScreenSpyDlg,payload 为 UTF-8)
void NotifyClipboardText(uint64_t device_id, const std::string& text);
// Get count of web clients watching a device
int GetWebClientCount(uint64_t device_id);
@@ -182,13 +195,24 @@ private:
void HandleLogin(void* ws_ptr, const std::string& msg, const std::string& client_ip);
void HandleGetSalt(void* ws_ptr, const std::string& msg);
void HandleGetDevices(void* ws_ptr, const std::string& token);
void HandleConnect(void* ws_ptr, const std::string& token, uint64_t device_id);
void HandleDisconnect(void* ws_ptr, const std::string& token, uint64_t requested_device_id = 0);
void HandleConnect(void* ws_ptr, const std::string& token, uint64_t device_id,
const std::string& req = "", bool multi_watch = false);
void HandleDisconnect(void* ws_ptr, const std::string& token, uint64_t requested_device_id = 0,
const std::string& req = "", bool multi_watch = false);
void HandleKeyframe(void* ws_ptr, const std::string& token, uint64_t device_id,
const std::string& req);
void HandlePing(void* ws_ptr, const std::string& token);
void HandleMouse(void* ws_ptr, const std::string& msg);
void HandleKey(void* ws_ptr, const std::string& msg);
void HandleRdpReset(void* ws_ptr, const std::string& token);
void HandleAudioToggle(void* ws_ptr, const std::string& token);
void HandleClipboardSet(void* ws_ptr, const std::string& msg);
void HandleClipboardGet(void* ws_ptr, const std::string& token, uint64_t device_id = 0);
void HandleRdpReset(void* ws_ptr, const std::string& token, uint64_t device_id = 0);
void HandleAudioToggle(void* ws_ptr, const std::string& token, uint64_t device_id = 0);
// 输入类命令的设备解析:优先用显式 id(必须在 watch 集合内);否则当集合恰好
// 只有一台设备时回退到它(兼容不带 id 的旧前端)。多设备且无 id 时返回 0(丢弃),
// 绝不能回退到任意集合成员,否则会变成跨设备输入注入。
uint64_t ResolveWatchDevice(void* ws_ptr, uint64_t requested_device_id);
// Token management
std::string GenerateToken(const std::string& username, const std::string& role);
@@ -221,6 +245,15 @@ private:
void HandleListUsers(void* ws_ptr, const std::string& token);
void HandleGetGroups(void* ws_ptr, const std::string& token);
// Security audit trail: in-memory ring of the last 1000 events since
// startup (see WebService.cpp). query_audit is admin-only.
void HandleQueryAudit(void* ws_ptr, const std::string& msg);
std::string ClientIP(void* ws_ptr);
void AuditWeb(const std::string& event, bool warn,
const std::string& actor, const std::string& role,
const std::string& ip, void* ws_ptr, uint64_t device_id,
std::initializer_list<std::pair<const char*, std::string>> extra = {});
// Send to WebSocket
void SendText(void* ws_ptr, const std::string& text);
void SendBinary(void* ws_ptr, const uint8_t* data, size_t len);
@@ -241,6 +274,10 @@ private:
static const int HEARTBEAT_INTERVAL_SEC = 30; // Send ping every 30 seconds
static const int HEARTBEAT_TIMEOUT_SEC = 90; // Disconnect if no activity for 90 seconds
// 每个 ws 最多订阅的设备数(屏幕墙最多 25 个 tile,留余量)。防止异常客户端
// 订阅数百台设备拖慢每一帧的广播循环。
static constexpr size_t kMaxWatchPerClient = 32;
// Heartbeat thread function
void HeartbeatThread();
@@ -366,8 +403,12 @@ private:
std::set<uint64_t> m_TermPending; // 已发 COMMAND_SHELL 待响应
std::mutex m_TermMutex;
// 内部清理(已持锁版本)
void CloseTermSessionLocked(uint64_t device_id);
// 内部清理(已持锁版本)。reason/actor/role/ip 仅用于审计日志;actor 为空时
// 尝试从 m_Clients 反查(ws 已注销时需由调用方传入)。
void CloseTermSessionLocked(uint64_t device_id, const char* reason = "ws_close",
const std::string& actor = std::string(),
const std::string& role = std::string(),
const std::string& ip = std::string());
};
// Global accessor
+1
View File
@@ -22,6 +22,7 @@ enum {
ONLINELIST_STARTTIME,
ONLINELIST_CAPABILITIES, // 客户端能力位
ONLINELIST_PRIVILEGE, // 权限级别 (SYSTEM/Admin/User)
ONLINELIST_TLS, // TLS 传输启用标志 (0/1)
ONLINELIST_MAX,
};
+28
View File
@@ -2069,3 +2069,31 @@ JPEG
%s · 约 %.2f MB/s=%s · ca. %.2f MB/s
%s · 不受限速(受网络带宽约束)=%s · Unbegrenzt (durch Netzwerkbandbreite begrenzt)
文件传输速度已保存。上传速度重启主控端后生效;下载速度已推送到在线客户端,新连接的主机也会自动获取。=Uebertragungsgeschwindigkeit gespeichert. Upload-Geschwindigkeit wird nach Neustart des Masters wirksam; Download-Geschwindigkeit wurde an Online-Clients gesendet, neu verbundene Hosts erhalten sie automatisch.
; ===== TLS =====
TLS证书(&T)...=TLS-Zertifikat(&T)...
TLS证书=TLS-Zertifikat
启用加密=Verschluesselung aktivieren
证书路径=Zertifikatspfad
私钥路径=Pfad zum privaten Schluessel
请填写证书路径=Bitte Zertifikatspfad angeben
请填写私钥路径=Bitte Pfad zum privaten Schluessel angeben
TLS 配置已保存。=TLS-Einstellungen gespeichert.
改动对新建连接即时生效。=Aenderungen gelten sofort fuer neue Verbindungen.
证书/私钥加载失败,加密功能未生效。请检查文件路径与格式(证书 PEM、私钥 PKCS#8)。=Zertifikat/privater Schluessel konnte nicht geladen werden; Verschluesselung ist inaktiv. Bitte Pfad und Format pruefen (PEM-Zertifikat, PKCS#8-Schluessel).
TLS传输=TLS-Transport
取消TLS=TLS deaktivieren
确定对选中主机启用 TLS 传输吗?(客户端下次启动生效)=TLS-Transport fuer die ausgewaehlten Hosts aktivieren? (wirkt nach Client-Neustart)
确定取消选中主机的 TLS 传输吗?(客户端下次启动生效)=TLS-Transport fuer die ausgewaehlten Hosts deaktivieren? (wirkt nach Client-Neustart)
服务端未启用 TLS 证书,无法切换客户端 TLS 传输=Server-TLS-Zertifikat ist nicht aktiviert; TLS-Transport der Clients kann nicht umgeschaltet werden
读取服务端 TLS 证书失败,无法下发=Server-TLS-Zertifikat konnte nicht gelesen werden; Verteilung nicht moeglich
白名单=Whitelist
确定将选中日志中的 IP 加入白名单吗?=IP aus den ausgewaehlten Log-Eintraegen zur Whitelist hinzufuegen?
已将 %d 个 IP 加入白名单=%d IP(s) zur Whitelist hinzugefuegt
%d 个 IP 已在白名单中=%d IP(s) bereits in der Whitelist
%d 个 IP 已封禁,无法加入白名单=%d IP(s) sind gesperrt und koennen nicht zur Whitelist hinzugefuegt werden
%s 文件句柄无效=%s ungueltiges Datei-Handle
没有选中任何文件=Keine Dateien ausgewaehlt
V2传输已启动,共 %d 个文件 -> %s=V2-Uebertragung gestartet, %d Datei(en) -> %s
V2下载请求已发送,共 %d 个项目=V2-Download-Anfrage gesendet, %d Element(e)
找不到主连接: clientID %llu=Hauptverbindung nicht gefunden: clientID=%llu
+28
View File
@@ -2084,3 +2084,31 @@ JPEG
连接数:%d=Connections: %d
强制断开=Force Disconnect
远程交谈=Talk
; ===== TLS =====
TLS证书(&T)...=TLS Certificate(&T)...
TLS证书=TLS Certificate
启用加密=Enable Encryption
证书路径=Certificate Path
私钥路径=Private Key Path
请填写证书路径=Please specify the certificate path
请填写私钥路径=Please specify the private key path
TLS 配置已保存。=TLS settings saved.
改动对新建连接即时生效。=Changes take effect immediately for new connections.
证书/私钥加载失败,加密功能未生效。请检查文件路径与格式(证书 PEM、私钥 PKCS#8)。=Failed to load certificate/private key; encryption is not active. Check the file path and format (PEM certificate, PKCS#8 private key).
TLS传输=TLS Transport
取消TLS=Disable TLS
确定对选中主机启用 TLS 传输吗?(客户端下次启动生效)=Enable TLS transport for the selected host? (takes effect after the client restarts)
确定取消选中主机的 TLS 传输吗?(客户端下次启动生效)=Disable TLS transport for the selected host? (takes effect after the client restarts)
服务端未启用 TLS 证书,无法切换客户端 TLS 传输=Server TLS certificate is not enabled; cannot toggle client TLS transport
读取服务端 TLS 证书失败,无法下发=Failed to read the server TLS certificate; cannot distribute
白名单=Whitelist
确定将选中日志中的 IP 加入白名单吗?=Add the IP from the selected log entries to the whitelist?
已将 %d 个 IP 加入白名单=Added %d IP(s) to the whitelist
%d 个 IP 已在白名单中=%d IP(s) already in the whitelist
%d 个 IP 已封禁,无法加入白名单=%d IP(s) are banned and cannot be added to the whitelist
%s 文件句柄无效=%s invalid file handle
没有选中任何文件=No files selected
V2传输已启动,共 %d 个文件 -> %s=V2 transfer started, %d file(s) -> %s
V2下载请求已发送,共 %d 个项目=V2 download request sent, %d item(s)
找不到主连接: clientID %llu=Main connection not found: clientID=%llu
+28
View File
@@ -2075,3 +2075,31 @@ JPEG
连接数:%d=連線數:%d
强制断开=強制中斷
远程交谈=遠程交談
; ===== TLS =====
TLS证书(&T)...=TLS 憑證(&T)...
TLS证书=TLS 憑證
启用加密=啟用加密
证书路径=憑證路徑
私钥路径=私鑰路徑
请填写证书路径=請填寫憑證路徑
请填写私钥路径=請填寫私鑰路徑
TLS 配置已保存。=TLS 設定已儲存。
改动对新建连接即时生效。=變更對新連線即時生效。
证书/私钥加载失败,加密功能未生效。请检查文件路径与格式(证书 PEM、私钥 PKCS#8)。=憑證/私鑰載入失敗,加密功能未生效。請檢查檔案路徑與格式(憑證 PEM、私鑰 PKCS#8)。
TLS传输=TLS 傳輸
取消TLS=停用 TLS
确定对选中主机启用 TLS 传输吗?(客户端下次启动生效)=確定對選取主機啟用 TLS 傳輸嗎?(用戶端下次啟動生效)
确定取消选中主机的 TLS 传输吗?(客户端下次启动生效)=確定取消選取主機的 TLS 傳輸嗎?(用戶端下次啟動生效)
服务端未启用 TLS 证书,无法切换客户端 TLS 传输=伺服器未啟用 TLS 憑證,無法切換用戶端 TLS 傳輸
读取服务端 TLS 证书失败,无法下发=讀取伺服器 TLS 憑證失敗,無法下發
白名单=白名單
确定将选中日志中的 IP 加入白名单吗?=確定將選取日誌中的 IP 加入白名單嗎?
已将 %d 个 IP 加入白名单=已將 %d 個 IP 加入白名單
%d 个 IP 已在白名单中=%d 個 IP 已在白名單中
%d 个 IP 已封禁,无法加入白名单=%d 個 IP 已封禁,無法加入白名單
%s 文件句柄无效=%s 檔案控制代碼無效
没有选中任何文件=沒有選取任何檔案
V2传输已启动,共 %d 个文件 -> %s=V2 傳輸已啟動,共 %d 個檔案 -> %s
V2下载请求已发送,共 %d 个项目=V2 下載請求已傳送,共 %d 個項目
找不到主连接: clientID %llu=找不到主連線: clientID=%llu
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+8 -2
View File
@@ -287,6 +287,8 @@
#define IDB_BITMAP_FILE_MANAGER 407
#define IDB_BITMAP_PRIVACY_SHIELD 408
#define IDB_BITMAP_DRAWING_BOARD 409
#define IDB_BITMAP_TLS_ENABLE 410
#define IDB_BITMAP_TLS_DISABLE 411
#define IDC_MESSAGE 1000
#define IDC_ONLINE 1001
#define IDC_STATIC_TIPS 1002
@@ -1008,9 +1010,11 @@
#define ID_MSGLOG_LOGIN_NOTIFY 33079
#define ID_MSGLOG_BAN_IP 33080
#define ID_MSGLOG_UNBAN_IP 33081
#define ID_MSGLOG_WHITELIST_IP 33096
#define ID_WEB_REMOTE_CONTROL 33044
#define ID_TOOL_PLUGIN_SETTINGS 33045
#define ID_MCP_SETTINGS 33077
#define ID_TLS_CERTIFICATE 33093
#define ID_CONNECTION_MANAGE 33086
#define ID_CONNECTION_KILL 33087
#define ID_CONNECTION_REFRESH 33088
@@ -1018,6 +1022,8 @@
#define ID_EXPERIMENT_FILE_MANAGER 33090
#define ID_EXPERIMENT_PRIVATE_SCREEN 33091
#define ID_EXPERIMENT_DRAWING_BOARD 33092
#define ID_EXPERIMENT_TLS_ENABLE 33094
#define ID_EXPERIMENT_TLS_DISABLE 33095
#define ID_33046 33046
#define ID_PROXY_PORT_AUTORUN 33047
#define ID_TRIGGER_SETTINGS 33048
@@ -1114,8 +1120,8 @@
//
#ifdef APSTUDIO_INVOKED
#ifndef APSTUDIO_READONLY_SYMBOLS
#define _APS_NEXT_RESOURCE_VALUE 410
#define _APS_NEXT_COMMAND_VALUE 33093
#define _APS_NEXT_RESOURCE_VALUE 412
#define _APS_NEXT_COMMAND_VALUE 33097
#define _APS_NEXT_CONTROL_VALUE 2620
#define _APS_NEXT_SYMED_VALUE 105
#endif
+73 -30
View File
@@ -386,6 +386,27 @@ void CMachineDlg::DeleteList()
PostMessage(WM_SHOW_MSG, (WPARAM)new CString(_TR("请等待数据返回...")), 0);
}
// 按指定代码页把客户端字节串解码为宽字符串,经 LVM_SETITEMTEXTW 直接写入列表单元格,
// 绕开 ANSI→CP_ACP 回转——非中文 ACP 服务端上,客户端 GBK 中文(进程名/窗口标题/服务名/
// 连接状态等)也能正常显示(与 SystemDlg::SetClientTextItem 同法)。
static void SetMachineItemText(CListCtrl& list, int iItem, int iSubItem, const char* sz, UINT cp)
{
std::wstring w;
if (sz && sz[0]) {
int wlen = MultiByteToWideChar(cp, 0, sz, -1, NULL, 0);
if (wlen > 1) {
w.resize(wlen - 1);
MultiByteToWideChar(cp, 0, sz, -1, &w[0], wlen);
}
}
LVITEMW lv = {};
lv.mask = LVIF_TEXT;
lv.iItem = iItem;
lv.iSubItem = iSubItem;
lv.pszText = w.empty() ? const_cast<LPWSTR>(L"") : &w[0];
::SendMessageW(list.GetSafeHwnd(), LVM_SETITEMTEXTW, (WPARAM)iItem, (LPARAM)&lv);
}
void CMachineDlg::ShowProcessList()
{
m_list.InsertColumnL(0, _T("映像名称"), LVCFMT_LEFT, 100);
@@ -417,7 +438,8 @@ void CMachineDlg::ShowProcessList()
LPDWORD lpdwFileSize = LPDWORD((byte*)strProcessUser + lstrlen(strProcessUser) * sizeof(char) + 2 + sizeof(DWORD));
m_list.InsertItem(i, strExeFile, 0);
m_list.InsertItem(i, "", 0);
SetMachineItemText(m_list, i, 0, strExeFile, 936);
str.FormatL(_T("%5u"), *lpPID);
m_list.SetItemText(i, 1, str);
@@ -427,7 +449,7 @@ void CMachineDlg::ShowProcessList()
str.FormatL(_T("%5u"), *lpdwThreads);
m_list.SetItemText(i, 3, str);
m_list.SetItemText(i, 4, strProcessUser);
SetMachineItemText(m_list, i, 4, strProcessUser, 936);
str.FormatL(_T("%5u K"), *lpdwWorkingSetSize);
m_list.SetItemText(i, 5, str);
@@ -435,9 +457,9 @@ void CMachineDlg::ShowProcessList()
str.FormatL(_T("%5u KB"), *lpdwFileSize);
m_list.SetItemText(i, 6, str);
m_list.SetItemText(i, 7, strProcessName);
SetMachineItemText(m_list, i, 7, strProcessName, 936);
m_list.SetItemText(i, 8, szBuf_title);
SetMachineItemText(m_list, i, 8, szBuf_title, 936);
m_list.SetItemText(i, 9, (*is64) ? _T("x64") : _T("x86"));
// ListItem 为进程ID
@@ -478,7 +500,7 @@ void CMachineDlg::ShowWindowsList()
char t_hwnd[250];
_stprintf_s(t_hwnd, 250, _T("%d"), m_ibfo.m_hwnd);
m_list.SetItemText(i, 1, t_hwnd);
m_list.SetItemText(i, 2, m_ibfo.strTitle);
SetMachineItemText(m_list, i, 2, m_ibfo.strTitle, 936);
m_list.SetItemText(i, 3, m_ibfo.canlook ? _L(_T("显示")) : _L(_T("隐藏")));
str.FormatL(_T("%d*%d"), m_ibfo.w, m_ibfo.h);
m_list.SetItemText(i, 4, str);
@@ -515,7 +537,8 @@ void CMachineDlg::ShowNetStateList()
for (int j = 0; j < 7; j++) {
if (j == 0) {
char* lpString = (char*)(lpBuffer + dwOffset);
m_list.InsertItem(i, lpString, 0);
m_list.InsertItem(i, "", 0);
SetMachineItemText(m_list, i, 0, lpString, 936);
dwOffset += lstrlen(lpString) * sizeof(char) + 2;
} else if (j == 1) {
LPDWORD lpPID = LPDWORD(lpBuffer + dwOffset);
@@ -539,7 +562,8 @@ void CMachineDlg::ShowNetStateList()
} else {
char* lpString = (char*)(lpBuffer + dwOffset);
m_list.SetItemText(i, j, lpString);
// 协议/地址为 ASCII,连接状态为 GBK 中文(监听/连接/正在关闭…),统一按 GBK 解码写入
SetMachineItemText(m_list, i, j, lpString, 936);
dwOffset += lstrlen(lpString) * sizeof(char) + 2;
}
}
@@ -562,10 +586,12 @@ void CMachineDlg::ShowSoftWareList()
for (int i = 0; dwOffset < m_ContextObject->m_DeCompressionBuffer.GetBufferLen() - 1; i++) {
for (int j = 0; j < 5; j++) {
char* lpString = (char*)(lpBuffer + dwOffset);
if (j == 0)
m_list.InsertItem(i, lpString, 0);
else
m_list.SetItemText(i, j, lpString);
if (j == 0) {
m_list.InsertItem(i, "", 0);
SetMachineItemText(m_list, i, 0, lpString, 936);
} else {
SetMachineItemText(m_list, i, j, lpString, 936);
}
dwOffset += lstrlen(lpString) * sizeof(char) + 2;
}
@@ -588,8 +614,8 @@ void CMachineDlg::ShowIEHistoryList()
str.FormatL(_T("%d"), i);
m_list.InsertItem(i, str, 0);
m_list.SetItemText(i, 1, p_Browsinghistory->strTime);
m_list.SetItemText(i, 2, p_Browsinghistory->strTitle);
m_list.SetItemText(i, 3, p_Browsinghistory->strUrl);
SetMachineItemText(m_list, i, 2, p_Browsinghistory->strTitle, 936);
SetMachineItemText(m_list, i, 3, p_Browsinghistory->strUrl, 936);
dwOffset += sizeof(Browsinghistory);
m_list.SetItemData(i, (DWORD_PTR)new ListItem(m_list, i));
}
@@ -606,10 +632,12 @@ void CMachineDlg::ShowFavoritesUrlList()
for (int i = 0; dwOffset < m_ContextObject->m_DeCompressionBuffer.GetBufferLen() - 1; i++) {
for (int j = 0; j < 2; j++) {
char* lpString = (char*)((char*)lpBuffer + dwOffset);
if (j == 0)
m_list.InsertItem(i, lpString, 0);
else
m_list.SetItemText(i, j, lpString);
if (j == 0) {
m_list.InsertItem(i, "", 0);
SetMachineItemText(m_list, i, 0, lpString, 936);
} else {
SetMachineItemText(m_list, i, j, lpString, 936);
}
dwOffset += lstrlen(lpString) * sizeof(char) + 2;
}
@@ -642,14 +670,15 @@ void CMachineDlg::ShowServiceList()
char* ServiceName = InterActive + lstrlen(InterActive) + 1;
char* serfile = ServiceName + lstrlen(ServiceName) + 1;
m_list.InsertItem(i, DisplayName, 0);
m_list.SetItemText(i, 1, Describe);
m_list.SetItemText(i, 2, serRunway);
m_list.SetItemText(i, 3, serauto);
m_list.SetItemText(i, 4, Login);
m_list.SetItemText(i, 5, InterActive);
m_list.SetItemText(i, 6, ServiceName);
m_list.SetItemText(i, 7, serfile);
m_list.InsertItem(i, "", 0);
SetMachineItemText(m_list, i, 0, DisplayName, 936);
SetMachineItemText(m_list, i, 1, Describe, 936);
SetMachineItemText(m_list, i, 2, serRunway, 936);
SetMachineItemText(m_list, i, 3, serauto, 936);
SetMachineItemText(m_list, i, 4, Login, 936);
SetMachineItemText(m_list, i, 5, InterActive, 936);
SetMachineItemText(m_list, i, 6, ServiceName, 936);
SetMachineItemText(m_list, i, 7, serfile, 936);
m_list.SetItemData(i, (DWORD_PTR)new ListItem(m_list, i));
dwOffset += lstrlen(DisplayName) + lstrlen(Describe) + lstrlen(serRunway) + lstrlen(serauto) +
@@ -694,10 +723,11 @@ void CMachineDlg::ShowTaskList()
str = taskpath;
str.Replace(taskname, _T(""));
m_list.SetItemText(i, 1, str);
m_list.SetItemText(i, 2, taskname);
m_list.SetItemText(i, 3, exepath);
m_list.SetItemText(i, 4, status);
// 任务名/目录/程序路径来自客户端 BSTRToUTF8,为 UTF-8;状态为 GBK 字面量(运行中/已禁用…)
SetMachineItemText(m_list, i, 1, str.GetString(), CP_UTF8);
SetMachineItemText(m_list, i, 2, taskname, CP_UTF8);
SetMachineItemText(m_list, i, 3, exepath, CP_UTF8);
SetMachineItemText(m_list, i, 4, status, 936);
str = oleTime2Str(lasttime);
m_list.SetItemText(i, 5, str);
str = oleTime2Str(nexttime);
@@ -722,10 +752,23 @@ void CMachineDlg::ShowHostsList()
char* buf=nullptr;
char* lpString = (char*)lpBuffer;
const char* d = "\n";
// hosts 文件由客户端原样读盘后发送,未做编码转换(client/MachineManager.cpp SendHostsList)。
// 记事本默认把 hosts 存成 UTF-8 带 BOM(EF BB BF);若按 GBK 解,首行会显示成 "锘?"。
// 识别 BOM:UTF-8 按 UTF-8 解并跳过 BOM,其余仍按 GBK(无 BOM 的中文/英文 hosts)。
// (UTF-16 hosts 极少见,此处未处理,保持原有行为。)
UINT cp = 936;
int dataLen = (int)m_ContextObject->m_DeCompressionBuffer.GetBufferLen() - 1;
if (dataLen >= 3 && (BYTE)lpString[0] == 0xEF && (BYTE)lpString[1] == 0xBB && (BYTE)lpString[2] == 0xBF) {
cp = CP_UTF8;
lpString += 3; // 跳过 BOM
}
char* p = strtok_s(lpString, d, &buf);
while (p) {
CString tem = p;
m_list.InsertItem(i, tem);
m_list.InsertItem(i, "");
SetMachineItemText(m_list, i, 0, tem.GetString(), cp);
p = strtok_s(NULL, d, &buf);
m_list.SetItemData(i, (DWORD_PTR)new ListItem(m_list, i));
i++;
+6 -2
View File
@@ -13,7 +13,9 @@
"--http-port=8080"
],
"env": {
"YAMA_WEB_ADMIN_PASS": "3.14159"
"YAMA_WEB_ADMIN_PASS": "3.14159",
"YAMA_TLS_CERT": "${workspaceFolder}/certs/server.crt",
"YAMA_TLS_KEY": "${workspaceFolder}/certs/server.key"
},
"console": "integratedTerminal",
"preLaunchTask": "sync-web-assets"
@@ -30,7 +32,9 @@
"--http-port=8080"
],
"env": {
"YAMA_WEB_ADMIN_PASS": "3.14159"
"YAMA_WEB_ADMIN_PASS": "3.14159",
"YAMA_TLS_CERT": "${workspaceFolder}/certs/server.crt",
"YAMA_TLS_KEY": "${workspaceFolder}/certs/server.key"
},
"console": "integratedTerminal",
"buildFlags": "-gcflags='all=-N -l'",
+89 -1
View File
@@ -141,7 +141,7 @@ VSCode F5 调试时由 `sync-web-assets` preLaunchTask 自动同步。
./simpleremoter-server
```
默认监听 TCP 端口 `6543`(被控设备),HTTP 端口 `8080`(浏览器 Web UI)。日志写到 `logs/server.log`。
默认监听 TCP 端口 `6543`(被控设备),HTTP 端口 `8080`(浏览器 Web UI)。日志写到 `logs/server.log`,安全审计记录另写 `logs/audit.log`。
### 命令行参数
@@ -170,6 +170,8 @@ VSCode F5 调试时由 `sync-web-assets` preLaunchTask 自动同步。
| `YAMA_USERS_FILE` | Path to the JSON file that persists non-admin web users (allowed_groups, password hash, salt). Default is `users.json` in the working directory. | `users.json` |
| `YAMA_WEB_ALLOWED_ORIGINS` | Comma-separated WebSocket Origin allowlist for cross-origin upgrades. Empty (default) → only same-origin upgrades are accepted, which is correct when the web UI and `/ws` share a host. Add an entry per trusted PWA / dev origin. | `https://yama.example.com,https://yama-mobile.example.com` |
| `YAMA_WEB_TRUST_PROXY` | Set to `1` only when running behind a reverse proxy you control (caddy / nginx / cloudflare). Switches client-IP extraction to use the last entry of `X-Forwarded-For` instead of `RemoteAddr`, so per-IP login rate limit sees the real client. Direct-exposure deployments MUST leave this unset — otherwise attackers can spoof the header to evade rate limits. | `1` |
| `YAMA_AUDIT_LOG` | 安全审计日志路径(JSON lines)。默认 `logs/audit.log`;设为 `off` / `none` / `0` / `-` 关闭独立文件(审计记录仍会写入 `logs/server.log`)。审计记录带 `audit=true` 和稳定的 `event` 名,便于告警与检索。 | `/var/log/yama/audit.log` |
| `YAMA_SSO_JWT_SECRET` | **[SSO]** Shared HS256 secret for bridging the parent portal's `auth_token` JWT into a YAMA web session via `/sso`. Must equal the upstream portal's signing key (FMS: `SERVER_PRIVATE_KEY`). **Only `admin` portal users are auto-logged-in**; other roles still need YAMA's own web login. Unset → SSO disabled, `/sso` returns 401. | `<shared-secret>` |
```bash
# Linux/macOS
@@ -326,6 +328,46 @@ func main() {
{"level":"debug","module":"Handler","time":"2025-12-19T13:17:47+01:00","message":"Heartbeat from client 1 (DESKTOP-BI6RGEJ)"}
```
### 安全审计日志
Web 端与设备端的认证、授权、远程控制操作会额外写入**独立**的 `logs/audit.log`(可由 `YAMA_AUDIT_LOG` 改路径或关闭),同时镜像到 `logs/server.log`。每行是一个 JSON 对象,固定带 `audit=true` 与 `event`,其余字段按事件携带 `actor` / `role` / `ip` / `peer_ip` / `conn` / `device_id` / `reason` 等。
网页暴露到公网后,优先关注这些事件:
| 事件 | 含义 |
| ---- | ---- |
| `web.ws.connect` / `web.ws.reject` | WebSocket 建立 / 被拒(Origin 不允许、升级失败) |
| `web.login.success` / `web.login.failure` / `web.login.throttled` | 登录成功、失败(含 `reason`、`user_exists`)、触发限流 |
| `web.salt.unknown_user` | `get_salt` 探测不存在的用户名(用户名枚举信号,每连接记一次) |
| `web.auth.invalid_token` / `web.authz.denied` | 无效 token、权限不足(非 admin 调用管理命令) |
| `web.session.resume` | 浏览器用缓存的 token 在重连后恢复会话 |
| `web.ws.close` | 连接关闭;带 `duration_ms`、`commands` 以及 `invalid_tokens` / `login_failures` / `admin_denied` 等可疑计数 |
| `remote.screen.start` / `.stop` / `.denied` | 远程桌面开始 / 结束(`reason`:disconnect、device_offline、ws_close、replaced)/ 被拒 |
| `remote.terminal.start` / `.stop` / `.denied` | 远程终端会话生命周期 |
| `remote.clipboard.get` / `.set` | 剪贴板读取 / 写入(只记字节数,**不记内容**) |
| `remote.rdp_reset` | 远程 RDP 会话归位操作 |
| `admin.user.create` / `admin.user.delete` / `admin.device.delete` | 管理操作(`target` / `device_id` / `mode`) |
| `admin.audit.query` | 管理员在网页查询审计日志(记录筛选条件与命中数) |
| `device.online` / `device.offline` | 受控设备上线 / 下线 |
| `device.auth.success` / `.failure` / `.throttled` / `.heartbeat.success` | 设备 TCP 授权结果(**只记 passcode 长度,不记 passcode**) |
| `device.connauth.rejected` / `device.subconn.foreign_ip` | 子连接签名失败 / 子连接来源 IP 与主连接不一致 |
管理员可以在网页里直接查询:设备页工具栏的 **Audit Log** 按钮(`admin` 角色且服务端在 `caps` 中声明 `auditLog` 时显示)打开审计页面,支持按时间范围、事件(精确值或前缀,如 `web.*`)、actor、IP、device_id、关键字筛选以及“仅警告”、分页。页面读取当前 `logs/audit.log`(超大文件只扫描末尾 32 MB,并会提示;已轮转的历史文件仍用下面的 grep/jq 排查),查询动作本身记为 `admin.audit.query`。
常用排查命令:
```bash
# 最近的登录失败与被拒操作
grep -E '"event":"web\.(login\.failure|login\.throttled|authz\.denied|salt\.unknown_user)"' logs/audit.log | tail -50
# 按来源 IP 聚合失败次数(找爆破源)
grep '"event":"web.login.failure"' logs/audit.log | jq -r '.ip' | sort | uniq -c | sort -rn | head
# 谁在什么时候开了远程桌面
grep '"event":"remote.screen.start"' logs/audit.log | jq -c '{time,actor,role,ip,device_id}'
```
注意:鼠标 / 键盘事件不逐条记录(频率太高,会把审计日志刷爆);审计以会话粒度(`remote.screen.*`、`remote.terminal.*`)体现操作。审计记录中的字符串字段来自客户端(用户名、User-Agent、Origin、设备名等),已做控制字符清理与长度截断,避免伪造日志行。
## 协议格式
数据包格式与 C++ 版本兼容:
@@ -598,6 +640,52 @@ nohup ./server_linux_amd64 -p 6543 --http-port=8080 --no-console &
- 受管设备的 `client.exe` 由 Windows 主控端生成(已绑定服务端地址和公钥),下级运营商把它分发给终端用户。
client 通过 6543 端口连接到本服务端;用户通过 8080 端口登录 Web 管理页面。
### 验证与排障
下级服务是否真的拿到付费授权,两侧各看一行日志即可定位:
| 现象 | 授权中心日志(`logs/server.log`) | 下级启动日志 | 结论 |
| ---- | -------------------------------- | ------------ | ---- |
| 付费生效 | `License Server: signed for sub=<客户名> ... active=N/M ttl=...` | `Signer mode: REMOTE (paid customer, ...)` | ✅ 正常 |
| 匿名试用 | `License Server: signed for sub=trial:<下级出口IP> ...` | `Signer mode: TRIAL (...)` | ⚠️ 下级没读到 `YAMA_LICENSE_TOKEN` |
| token 无效 | nginx access.log 里 `/license/sign` 回 `401` | `Signer mode: REMOTE` 但设备连不上 | ⚠️ token 过期 / 密钥不匹配 / 被篡改 |
**关键区分**:`sub=trial:<IP>` 表示下级请求**根本没带 `Authorization` 头**(token 为空,等于没读到),
而不是 token 错 —— token 错会返回 401。两者根因不同,别误判成「token 配错了」。
#### systemd 改 env 文件后不生效(最常见坑)
`EnvironmentFile=` 只在服务**启动那一刻**读一次,改文件不会热更新,必须重启服务。典型表现就是
改了 `YAMA_LICENSE_TOKEN` 后日志仍显示 `Signer mode: TRIAL`。
```bash
# 1. 确认运行中的进程到底有没有读到该变量(这才是真相)
PID=$(systemctl show <服务名> -p MainPID --value)
tr '\0' '\n' < /proc/$PID/environ | grep -i YAMA_LICENSE_TOKEN || echo "进程里没有该变量"
# 2. 重启后再验一次 + 看启动日志
systemctl daemon-reload && systemctl restart <服务名>
journalctl -u <服务名> --no-pager -n 40 | grep -iE "signer mode"
```
下级日志出现 `Signer mode: REMOTE` 说明 token 已读入;授权中心日志出现 `sub=<客户名>` 即全链路闭环。
### SSO(单点登录,可选)
Web UI 挂在已有域名(如 FMS 网关)的 `/remoter` 子路径下时,可让**已登录门户的管理员免二次登录**直接进远程桌面管理页。设 `YAMA_SSO_JWT_SECRET`(= 上游门户的 HS256 签名密钥,FMS 即 `SERVER_PRIVATE_KEY`)后启用:
```bash
export YAMA_SSO_JWT_SECRET="与上游门户一致的共享密钥"
```
行为:
- `/sso` 校验浏览器携带的 `auth_token` cookie(或 `Authorization: Bearer`),通过后铸一枚 YAMA 自己的会话 token 返回给前端。
- **只有 `admin` 角色的门户用户会被自动登录**;其他角色(viewer / 未知)即使已登录门户,`/sso` 仍返回 401,前端回退到 YAMA 自带的 Web 登录表单,必须再输一遍 YAMA 的登录密码。
- 未配置 `YAMA_SSO_JWT_SECRET` 时 `/sso` 恒 401,行为等同纯本地登录。
注意:SSO 只桥接「识别门户身份」,YAMA 本地登录(账号 + 密码)始终保留作为兜底;自动登录后的角色权限仍由 YAMA 自己的账号体系决定。
## License
MIT License
+4
View File
@@ -0,0 +1,4 @@
# 私钥与中间产物不入库;.crt 为公钥证书,可提交
*.key
*.csr
*.srl
+19
View File
@@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDLDCCAhSgAwIBAgIUPkDGqH/CbV5Qgy3mFPOJ4VwdZq0wDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MTAxNDEzMDMwMVoXDTM2MTAx
MTEzMDMwMVowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEAh+nNFRRn9pK94OfgUv7hqVPI774HyUoMEzgiAx/XB3Mf
UdEkJExvtMnHGT4FSlQ80oi1cIUuh/9svYCyPVwGuY1Bd5Jt8me9pN9CY7kJmFl+
MlBv8mz34e5gNwrybd2orn/2RATmVPCgQ82nXRmY8vFqs6KA/Eqh5Sd8S+b2rUs7
wzlg80lDQtH/7Bf7egsQ+cYS3Xeex6H82Q9AwVpvljjrst7HI+kxKEifHRsE/amW
Bx88evqx8keE0AQ30gyXsbIRgID+LF8jcFf3WzMx+cqYStn9JJG+SlakaIlKw2I6
FCES/UTSYoFsk06EaOy+od2+wo6jpfkJCGdEItlJlwIDAQABo3YwdDAgBgNVHREE
GTAXgglsb2NhbGhvc3SHBH8AAAGHBAAAAAAwDAYDVR0TAQH/BAIwADAOBgNVHQ8B
Af8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwHQYDVR0OBBYEFBoxa/G+knDe
DBs5vl+Nrdxy0uJpMA0GCSqGSIb3DQEBCwUAA4IBAQCBE/Xj13AzPhxZacbdAvpn
xNxfF+3wJs9j0qo8w2LcB+x10pe8/ay2Gs0ThZc63o/LqDxlS6LrRvkjRlypM+Ls
gahajW0hJs+sXfa+EhcJx6c1IR/uolSpRYuel/Ui6Xvhsr11BTpsgrHZdcYlKZqf
75mX0byKRoPVkG/dIVPWpnoFkm8BLJdlZ0OpgGL9iyBVqgH0rw5hejnDZVVH2K4g
0ZeE9QUbNl0oS01bpBJfCJfZT70+38yU61TTegpZEzCTBTMKccNU3hKnX7n9C7ZM
JMb0lEZ/IuCaOxmpz5zmHPa4bibkAPSbpozOKP3S48zQPDEa58CvSUlVIrmo7+d+
-----END CERTIFICATE-----
+337 -25
View File
@@ -2,7 +2,10 @@ package main
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"flag"
"fmt"
"net/http"
@@ -15,6 +18,7 @@ import (
"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"
@@ -31,6 +35,8 @@ type MyHandler struct {
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
@@ -130,17 +136,29 @@ func (h *MyHandler) OnReceive(ctx *connection.Context, data []byte) {
// arrives shortly after via TOKEN_NEXTSCREEN.
case protocol.TokenNextScreen:
h.handleScreenFrame(ctx, data, false)
case protocol.TokenClipboardText:
h.handleClipboardText(ctx, data)
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.
// 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)
@@ -153,12 +171,25 @@ func (h *MyHandler) OnReceive(ctx *connection.Context, data []byte) {
// 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.
// 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
@@ -187,6 +218,66 @@ func (h *MyHandler) handleConnAuth(ctx *connection.Context, data []byte) {
}
}
// 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
@@ -217,10 +308,21 @@ func (h *MyHandler) handleTerminalStart(ctx *connection.Context, isPTY bool) {
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.
// 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
}
@@ -253,9 +355,21 @@ func (h *MyHandler) handleBitmapInfo(ctx *connection.Context, data []byte) {
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.
// 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
}
@@ -336,6 +450,18 @@ func (h *MyHandler) handleScreenFrame(ctx *connection.Context, data []byte, alwa
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)
@@ -497,6 +623,24 @@ func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, client
// 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()
@@ -505,12 +649,23 @@ func (h *MyHandler) handleAuth(ctx *connection.Context, data []byte) {
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.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)
h.log.Audit("device.auth.success", fields)
} 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)
h.log.AuditWarn("device.auth.failure", fields)
}
// Build and send response
@@ -582,15 +737,22 @@ func (h *MyHandler) handleHeartbeat(ctx *connection.Context, data []byte) {
if !ctx.IsAuthorized.Load() {
ctx.IsAuthorized.Store(true)
info := ctx.GetInfo()
version := "V1"
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)
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,
})
}
}
}
@@ -674,6 +836,19 @@ func normalizeBasePath(s string) string {
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.
//
@@ -764,8 +939,30 @@ func main() {
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
@@ -794,6 +991,34 @@ func main() {
// 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)
@@ -875,12 +1100,58 @@ func main() {
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)))
@@ -892,6 +1163,8 @@ func main() {
srv: srv,
hub: deviceHub,
signer: signer,
loginLimit: authLoginLimit,
connAuthKey: connAuthKey,
}
srv.SetHandler(handler)
@@ -935,17 +1208,41 @@ func main() {
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)
@@ -1019,5 +1316,20 @@ func main() {
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()
}
+72
View File
@@ -0,0 +1,72 @@
package main
import (
"testing"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
)
type frameRecorder struct {
frames []recordedFrame
}
type recordedFrame struct {
deviceID string
packet []byte
isKey bool
}
func (r *frameRecorder) OnScreenFrame(deviceID string, packet []byte, isKeyframe bool) {
r.frames = append(r.frames, recordedFrame{deviceID, packet, isKeyframe})
}
func (r *frameRecorder) OnDeviceOnline(hub.DeviceInfo) {}
func (r *frameRecorder) OnDeviceOffline(string) {}
func (r *frameRecorder) OnDeviceUpdate(string, int, string) {}
func (r *frameRecorder) OnResolutionChange(string, int, int) {}
func (r *frameRecorder) OnCursorChange(string, byte) {}
func (r *frameRecorder) OnClipboardText(string, string) {}
func (r *frameRecorder) OnTerminalReady(string, bool) {}
func (r *frameRecorder) OnTerminalData(string, []byte) {}
func (r *frameRecorder) OnTerminalClosed(string, string) {}
// TestOnReceiveRelaysKeyframeToken guards the forced-IDR recovery path: when
// the browser asks for a keyframe, the client replies with TOKEN_KEYFRAME.
// Dropping that token (the old no-op) leaves the browser decoding P-frames
// against a reference chain it never received, i.e. persistent corruption.
func TestOnReceiveRelaysKeyframeToken(t *testing.T) {
h := hub.New()
rec := &frameRecorder{}
h.Subscribe(rec)
h.Register(&hub.Device{ID: "dev", PeerIP: "10.0.0.1"},
&connection.Context{RemoteAddr: "10.0.0.1:1000"})
screenCtx := &connection.Context{RemoteAddr: "10.0.0.1:1001"}
if !h.BindScreenConn("dev", screenCtx) {
t.Fatal("screen bind failed")
}
// [TOKEN_KEYFRAME][algo=H264][cursorPos:8][cursorIdx:1][IDR NAL]
data := []byte{
protocol.TokenKeyframe, 2,
0, 0, 0, 0, 0, 0, 0, 0,
0,
0x00, 0x00, 0x00, 0x01, 0x65, 0x88, 0x84,
}
(&MyHandler{hub: h}).OnReceive(screenCtx, data)
if len(rec.frames) != 1 {
t.Fatalf("frames relayed = %d, want 1 (TOKEN_KEYFRAME must not be dropped)", len(rec.frames))
}
f := rec.frames[0]
if f.deviceID != "dev" {
t.Fatalf("deviceID = %q, want dev", f.deviceID)
}
if !f.isKey || f.packet[4] != 1 {
t.Fatalf("isKeyframe=%v frameType=%d, want true/1", f.isKey, f.packet[4])
}
if want := 9 + len(data) - (1 + protocol.ScreenFrameHeaderLen); len(f.packet) != want {
t.Fatalf("packet len = %d, want %d", len(f.packet), want)
}
}
+103
View File
@@ -0,0 +1,103 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"testing"
"time"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
)
// connAuthTestKey is an arbitrary non-secret key used only to exercise the
// verifyConnAuth code path in tests. The production key is never compiled in:
// it is read from the YAMA_CONN_AUTH_KEY environment variable at startup.
const connAuthTestKey = "test-conn-auth-key"
// signConnAuth reproduces the client's ConnAuth signature: hex(HMAC-SHA256(key,
// clientID||timestamp||nonce)) as 64 lowercase hex chars. Mirrors
// client/IOCPClient.cpp PerformConnAuth + license.cpp signMessage.
func signConnAuth(key string, clientID, ts uint64, nonce [16]byte) string {
mac := hmac.New(sha256.New, []byte(key))
var msg [32]byte
binary.LittleEndian.PutUint64(msg[0:8], clientID)
binary.LittleEndian.PutUint64(msg[8:16], ts)
copy(msg[16:32], nonce[:])
mac.Write(msg[:])
return hex.EncodeToString(mac.Sum(nil))
}
func testNonce() [16]byte {
var n [16]byte
for i := range n {
n[i] = byte(i)
}
return n
}
// TestConnAuthHmacAlgorithmVector pins the signing algorithm and byte layout
// (HMAC-SHA256 over clientID||timestamp||nonce, lowercase hex) against a vector
// computed independently with Python's hmac/hashlib (not Go's crypto). The key
// value is irrelevant here — what matters is that the server reproduces the
// exact signing the client performs. The real key is supplied at runtime via
// YAMA_CONN_AUTH_KEY, never compiled in.
func TestConnAuthHmacAlgorithmVector(t *testing.T) {
const want = "e59bf1259c77e065afa604cb5b1794b0c8c03fbb7e3473f5c3c34c2b94a90406"
if got := signConnAuth(connAuthTestKey, 0x1122334455667788, 1700000000, testNonce()); got != want {
t.Fatalf("HMAC mismatch:\n got %s\n want %s", got, want)
}
}
func TestVerifyConnAuth(t *testing.T) {
h := &MyHandler{connAuthKey: []byte(connAuthTestKey)}
build := func(ts uint64, sig []byte) []byte {
pkt := make([]byte, protocol.ConnAuthPacketSize)
pkt[0] = protocol.TokenConnAuth
binary.LittleEndian.PutUint64(pkt[protocol.ConnAuthOffClientID:], 0x1122334455667788)
binary.LittleEndian.PutUint64(pkt[protocol.ConnAuthOffTimestamp:], ts)
for i := range 16 {
pkt[protocol.ConnAuthOffNonce+i] = byte(i)
}
copy(pkt[protocol.ConnAuthOffSignature:], sig)
return pkt
}
nonce := testNonce()
t.Run("ok", func(t *testing.T) {
ts := uint64(time.Now().Unix())
sig := signConnAuth(connAuthTestKey, 0x1122334455667788, ts, nonce)
if got := h.verifyConnAuth(build(ts, []byte(sig))); got != protocol.ConnAuthStatusOK {
t.Fatalf("expected OK, got status %d", got)
}
})
t.Run("bad_signature", func(t *testing.T) {
ts := uint64(time.Now().Unix())
sig := signConnAuth(connAuthTestKey, 0x1122334455667788, ts, nonce)
pkt := build(ts, []byte(sig))
pkt[protocol.ConnAuthOffSignature] ^= 0xff
if got := h.verifyConnAuth(pkt); got != protocol.ConnAuthStatusBadSignature {
t.Fatalf("expected BadSignature, got %d", got)
}
})
t.Run("clock_skew", func(t *testing.T) {
// Just outside the 300 s tolerance, with a signature valid for that
// timestamp, so the skew check fires before the signature check.
ts := uint64(time.Now().Unix()) - protocol.ConnAuthTimestampToleranceSec - 1
sig := signConnAuth(connAuthTestKey, 0x1122334455667788, ts, nonce)
if got := h.verifyConnAuth(build(ts, []byte(sig))); got != protocol.ConnAuthStatusClockSkew {
t.Fatalf("expected ClockSkew, got %d", got)
}
})
t.Run("bad_size", func(t *testing.T) {
if got := h.verifyConnAuth([]byte{protocol.TokenConnAuth}); got != protocol.ConnAuthStatusBadSize {
t.Fatalf("expected BadSize, got %d", got)
}
})
}
+12
View File
@@ -33,6 +33,15 @@ YAMA_WEB_TRUST_PROXY=1
# WebSocket Origin 白名单(逗号分隔)。空 = 仅允许同源
#YAMA_WEB_ALLOWED_ORIGINS=https://你的域名
# ---------- SSO(单点登录,可选)----------
# 共享的 HS256 签名密钥。设置后启用 SSO:YAMA 校验父域名(如 FMS 网关)下发的
# auth_token JWT,通过后为门户管理员铸一枚 YAMA 会话、免二次登录。
# 取值必须与上游门户的签名密钥一致(FMS 即 SERVER_PRIVATE_KEY)。
# ⚠️ 仅 admin 角色的门户用户会被自动登录;其他角色(viewer / 未知)即使已登录
# 门户,也必须再输一遍 YAMA 自己的 Web 登录密码(/sso 返回 401 → 回退登录页)。
#YAMA_SSO_JWT_SECRET=
# ---------- 授权 / 许可(signer)----------
# 优先级:YAMA_SIGN_PASSWORD > YAMA_LICENSE_DISABLED=1 > YAMA_LICENSE_TOKEN > 匿名试用
@@ -48,6 +57,9 @@ YAMA_WEB_TRUST_PROXY=1
# 客户 JWT(付费客户解锁设备配额)
#YAMA_LICENSE_TOKEN=
# ⚠️ EnvironmentFile 只在服务启动时读一次:改本文件后必须 systemctl restart <服务>,
# 否则改动不生效(现象:启动日志仍是 "Signer mode: TRIAL" 而非 REMOTE)
# 缓存 TTL(小时,RemoteSigner 离线缓存,默认 24)
#YAMA_LICENSE_OFFLINE_HRS=24
+373
View File
@@ -0,0 +1,373 @@
// Package devicehistory persists the set of devices that have ever logged in
// to the Go server, so the web UI can keep listing them after they go offline.
//
// It implements hub.EventHandler and is subscribed to the hub, which means the
// only wiring needed at startup is hub.Subscribe(store). Online events upsert
// the record (refreshing the display fields the client reports at login) and
// offline events merely refresh last_seen.
//
// Storage is a small JSON file, mirroring the wsauth users.json convention:
// atomic temp-file + fsync + rename, loaded once at startup and written back
// on a short debounce so a burst of reconnects doesn't rewrite it per device.
package devicehistory
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"sync"
"time"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
)
const (
// fileVersion lets a future release migrate the on-disk shape. Unknown
// versions are still parsed best-effort; only structurally invalid JSON
// makes Open fail.
fileVersion = 1
// flushDelay coalesces updates. Device logins/disconnects are sparse, but
// a server restart makes every agent reconnect at once; without the
// debounce that would be one file write per device.
flushDelay = 500 * time.Millisecond
)
// Record is one persisted device. The fields mirror hub.DeviceInfo so the
// offline list can be rendered with the same front-end card code; only the
// fields that are stable across sessions are kept (RTT / active window are
// live-only and would be stale noise on an offline card).
type Record struct {
ID string `json:"id"`
Name string `json:"name"`
Group string `json:"group,omitempty"`
Version string `json:"version,omitempty"`
Capability string `json:"capability,omitempty"`
OS string `json:"os,omitempty"`
CPU string `json:"cpu,omitempty"`
FilePath string `json:"file_path,omitempty"`
InstallTime string `json:"install_time,omitempty"`
Location string `json:"location,omitempty"`
PeerIP string `json:"peer_ip,omitempty"`
IP string `json:"ip,omitempty"`
Screen string `json:"screen,omitempty"`
FirstSeen int64 `json:"first_seen"`
LastSeen int64 `json:"last_seen"`
}
type fileShape struct {
Version int `json:"version"`
Devices map[string]*Record `json:"devices"`
}
// Store is the in-memory device history plus its backing file. Safe for
// concurrent use.
type Store struct {
path string
max int // 0 = unlimited
log *logger.Logger
mu sync.Mutex
devices map[string]*Record
// online is derived from hub events and used only by pruning: a device
// that is still connected must keep its record even if its last login is
// older than the 1000th offline device's. Not persisted — after a restart
// every device is offline until it reconnects.
online map[string]bool
dirty bool
timer *time.Timer
}
// Open loads the history at path. A missing file is an empty history, not an
// error. If the file exists but cannot be parsed, the returned store is still
// usable (empty) along with the error, so callers can log and continue — the
// next flush will replace the corrupt file.
func Open(path string, max int, log *logger.Logger) (*Store, error) {
s := newStore(path, max, log)
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return s, nil
}
return s, err
}
var f fileShape
if err := json.Unmarshal(data, &f); err != nil {
return s, fmt.Errorf("parse %s: %w", path, err)
}
for id, r := range f.Devices {
if r == nil {
continue
}
// The map key is authoritative; tolerate records written by a
// future/older shape that omitted or disagreed with the id field.
r.ID = id
s.devices[id] = r
}
s.pruneLocked()
return s, nil
}
// New returns an empty store for path without touching disk. Useful when Open
// failed and the caller still wants live recording to work.
func New(path string, max int, log *logger.Logger) *Store {
return newStore(path, max, log)
}
func newStore(path string, max int, log *logger.Logger) *Store {
return &Store{
path: path,
max: max,
log: log,
devices: make(map[string]*Record),
online: make(map[string]bool),
}
}
// OnDeviceOnline records a device that just logged in. It refreshes every
// display field because the agent reports its current name/group/version at
// each login, and preserves first_seen across reconnects.
func (s *Store) OnDeviceOnline(d hub.DeviceInfo) {
s.upsertAt(d, time.Now())
}
func (s *Store) upsertAt(d hub.DeviceInfo, at time.Time) {
if d.ID == "" {
return
}
now := at.Unix()
s.mu.Lock()
rec, ok := s.devices[d.ID]
if !ok {
rec = &Record{ID: d.ID, FirstSeen: now}
s.devices[d.ID] = rec
}
if rec.FirstSeen == 0 {
rec.FirstSeen = now
}
rec.Name = d.Name
rec.Group = d.Group
rec.Version = d.Version
rec.Capability = d.Capability
rec.OS = d.OS
rec.CPU = d.CPU
rec.FilePath = d.FilePath
rec.InstallTime = d.InstallTime
rec.Location = d.Location
rec.PeerIP = d.PeerIP
rec.IP = d.IP
rec.Screen = d.Screen
rec.LastSeen = now
s.online[d.ID] = true
s.pruneLocked()
s.markDirtyLocked()
s.mu.Unlock()
}
// OnDeviceOffline refreshes last_seen. The record was created when the device
// logged in; an unknown ID is ignored (e.g. a device rejected by signing never
// reaches the hub).
func (s *Store) OnDeviceOffline(id string) {
if id == "" {
return
}
s.offlineAt(id, time.Now())
}
func (s *Store) offlineAt(id string, at time.Time) {
s.mu.Lock()
delete(s.online, id)
if rec, ok := s.devices[id]; ok {
rec.LastSeen = at.Unix()
s.markDirtyLocked()
}
s.mu.Unlock()
}
// The remaining EventHandler methods are not relevant to device history.
func (s *Store) OnDeviceUpdate(string, int, string) {}
func (s *Store) OnScreenFrame(string, []byte, bool) {}
func (s *Store) OnResolutionChange(string, int, int) {}
func (s *Store) OnCursorChange(string, byte) {}
func (s *Store) OnClipboardText(string, string) {}
func (s *Store) OnTerminalReady(string, bool) {}
func (s *Store) OnTerminalData(string, []byte) {}
func (s *Store) OnTerminalClosed(string, string) {}
// Offline returns the records whose IDs are not in online, newest last_seen
// first, converted to the hub's device payload shape with Online=false.
//
// Callers pass the hub's current online snapshot; the store deliberately does
// not know which device is online, so a reconnect that re-registers the same
// ID can never produce a duplicate row.
func (s *Store) Offline(online map[string]struct{}) []hub.DeviceInfo {
s.mu.Lock()
out := make([]hub.DeviceInfo, 0, len(s.devices))
for id, rec := range s.devices {
if _, ok := online[id]; ok {
continue
}
out = append(out, hub.DeviceInfo{
ID: rec.ID,
Name: rec.Name,
Group: rec.Group,
Version: rec.Version,
Capability: rec.Capability,
OS: rec.OS,
CPU: rec.CPU,
FilePath: rec.FilePath,
InstallTime: rec.InstallTime,
Location: rec.Location,
IP: rec.IP,
PeerIP: rec.PeerIP,
Screen: rec.Screen,
LastSeen: rec.LastSeen,
Online: false,
})
}
s.mu.Unlock()
sort.Slice(out, func(i, j int) bool {
if out[i].LastSeen != out[j].LastSeen {
return out[i].LastSeen > out[j].LastSeen
}
return out[i].ID < out[j].ID
})
return out
}
// Remove deletes the persisted record for id and reports whether one existed.
// This is the web UI's explicit "delete" for an offline device: an offline
// record has no live agent to uninstall, so forgetting it is all "delete" can
// mean. The caller decides when removal is appropriate; the store does not
// refuse to drop a record that happens to be marked online.
func (s *Store) Remove(id string) bool {
if id == "" {
return false
}
s.mu.Lock()
_, existed := s.devices[id]
if existed {
delete(s.devices, id)
delete(s.online, id)
s.markDirtyLocked()
}
s.mu.Unlock()
return existed
}
// Flush writes the current state synchronously. Normally the debounced timer
// does this; Flush exists for tests and graceful shutdown.
func (s *Store) Flush() error {
s.mu.Lock()
if s.timer != nil {
s.timer.Stop()
s.timer = nil
}
if !s.dirty {
s.mu.Unlock()
return nil
}
s.dirty = false
data, err := s.marshalLocked()
s.mu.Unlock()
if err != nil {
return err
}
if err := writeAtomic(s.path, data); err != nil {
s.mu.Lock()
s.dirty = true
s.mu.Unlock()
return err
}
return nil
}
// Close stops the debounce timer and flushes pending changes.
func (s *Store) Close() error {
return s.Flush()
}
func (s *Store) markDirtyLocked() {
s.dirty = true
if s.timer == nil {
s.timer = time.AfterFunc(flushDelay, s.flush)
}
}
func (s *Store) flush() {
if err := s.Flush(); err != nil && s.log != nil {
s.log.Error("Failed to persist device history to %s: %v", s.path, err)
}
}
func (s *Store) marshalLocked() ([]byte, error) {
f := fileShape{Version: fileVersion, Devices: s.devices}
return json.MarshalIndent(f, "", " ")
}
// pruneLocked keeps the max most-recently-seen records. It runs after every
// online upsert, which is the only path that can grow the map; the O(n log n)
// sort only happens when the cap is actually exceeded.
func (s *Store) pruneLocked() {
if s.max <= 0 || len(s.devices) <= s.max {
return
}
ids := make([]string, 0, len(s.devices))
for id := range s.devices {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool {
oi, oj := s.online[ids[i]], s.online[ids[j]]
if oi != oj {
return !oi // evict offline records before online ones
}
return s.devices[ids[i]].LastSeen < s.devices[ids[j]].LastSeen
})
for _, id := range ids[:len(ids)-s.max] {
delete(s.devices, id)
}
}
// writeAtomic mirrors wsauth.writeUsersFile: temp file in the same directory,
// fsync, then rename over the destination.
func writeAtomic(path string, data []byte) error {
dir := filepath.Dir(path)
if dir != "" {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
}
tmp, err := os.CreateTemp(dir, "devices-*.json.tmp")
if err != nil {
return err
}
tmpName := tmp.Name()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpName)
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpName)
return err
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpName)
return err
}
if err := os.Rename(tmpName, path); err != nil {
_ = os.Remove(tmpName)
return err
}
return nil
}
@@ -0,0 +1,263 @@
package devicehistory
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
)
func testDevice(id, name string) hub.DeviceInfo {
return hub.DeviceInfo{
ID: id,
Name: name,
Group: "office",
Version: "1.2.3",
OS: "Windows 11",
IP: "203.0.113.7",
PeerIP: "198.51.100.9",
Screen: "1:1920*1080",
Online: true,
}
}
func TestStoreRecordsLifecycleViaHub(t *testing.T) {
path := filepath.Join(t.TempDir(), "devices.json")
s, err := Open(path, 1000, nil)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
h := hub.New()
h.Subscribe(s)
h.Register(&hub.Device{ID: "dev-1", Name: "PC-1", Group: "office"},
&connection.Context{RemoteAddr: "198.51.100.9:5000"})
// The device is online, so it must not appear in the offline list.
if got := s.Offline(map[string]struct{}{"dev-1": {}}); len(got) != 0 {
t.Fatalf("offline while online = %d, want 0", len(got))
}
h.Unregister("dev-1")
off := s.Offline(nil)
if len(off) != 1 {
t.Fatalf("offline entries = %d, want 1", len(off))
}
d := off[0]
if d.ID != "dev-1" || d.Name != "PC-1" || d.Group != "office" {
t.Fatalf("offline device = %+v", d)
}
if d.Online {
t.Fatal("offline entry marked online")
}
if d.LastSeen == 0 {
t.Fatal("last_seen not set")
}
}
func TestStorePersistsAndPreservesFirstSeen(t *testing.T) {
path := filepath.Join(t.TempDir(), "devices.json")
s, err := Open(path, 1000, nil)
if err != nil {
t.Fatalf("Open: %v", err)
}
first := time.Unix(1_700_000_000, 0)
s.upsertAt(testDevice("dev-1", "old-name"), first)
// A later login refreshes display fields and last_seen but keeps first_seen.
s.upsertAt(testDevice("dev-1", "new-name"), first.Add(time.Hour))
if err := s.Flush(); err != nil {
t.Fatalf("Flush: %v", err)
}
_ = s.Close()
reopened, err := Open(path, 1000, nil)
if err != nil {
t.Fatalf("reopen: %v", err)
}
t.Cleanup(func() { _ = reopened.Close() })
reopened.mu.Lock()
rec := reopened.devices["dev-1"]
reopened.mu.Unlock()
if rec == nil {
t.Fatal("record not persisted")
}
if rec.Name != "new-name" {
t.Fatalf("name = %q, want new-name", rec.Name)
}
if rec.FirstSeen != first.Unix() {
t.Fatalf("first_seen = %d, want %d", rec.FirstSeen, first.Unix())
}
if rec.LastSeen != first.Add(time.Hour).Unix() {
t.Fatalf("last_seen = %d, want %d", rec.LastSeen, first.Add(time.Hour).Unix())
}
}
func TestStorePrunesOldest(t *testing.T) {
path := filepath.Join(t.TempDir(), "devices.json")
s, err := Open(path, 2, nil)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
base := time.Unix(1_700_000_000, 0)
s.upsertAt(testDevice("a", "A"), base)
s.upsertAt(testDevice("b", "B"), base.Add(time.Minute))
s.upsertAt(testDevice("c", "C"), base.Add(2*time.Minute))
s.mu.Lock()
_, hasA := s.devices["a"]
_, hasC := s.devices["c"]
n := len(s.devices)
s.mu.Unlock()
if n != 2 || hasA || !hasC {
t.Fatalf("after prune: n=%d hasA=%v hasC=%v, want 2/false/true", n, hasA, hasC)
}
}
func TestPruneEvictsOfflineBeforeOnline(t *testing.T) {
path := filepath.Join(t.TempDir(), "devices.json")
s, err := Open(path, 2, nil)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
base := time.Unix(1_700_000_000, 0)
s.upsertAt(testDevice("online-old", "Old"), base)
s.upsertAt(testDevice("offline-new", "Gone"), base.Add(time.Minute))
s.offlineAt("offline-new", base.Add(2*time.Minute))
// This upsert exceeds the cap; the offline record must be evicted before
// the still-connected device, even though its last_seen is newer.
s.upsertAt(testDevice("online-2", "New"), base.Add(3*time.Minute))
s.mu.Lock()
_, hasOld := s.devices["online-old"]
_, hasGone := s.devices["offline-new"]
_, hasNew := s.devices["online-2"]
n := len(s.devices)
s.mu.Unlock()
if n != 2 || !hasOld || hasGone || !hasNew {
t.Fatalf("after prune: n=%d hasOld=%v hasGone=%v hasNew=%v", n, hasOld, hasGone, hasNew)
}
}
func TestStoreOfflineSortedNewestFirst(t *testing.T) {
path := filepath.Join(t.TempDir(), "devices.json")
s, err := Open(path, 1000, nil)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
base := time.Unix(1_700_000_000, 0)
s.upsertAt(testDevice("old", "Old"), base)
s.upsertAt(testDevice("new", "New"), base.Add(time.Hour))
off := s.Offline(nil)
if len(off) != 2 || off[0].ID != "new" || off[1].ID != "old" {
t.Fatalf("offline order = %+v, want new then old", off)
}
}
func TestOpenCorruptFileStartsEmptyAndRecovers(t *testing.T) {
path := filepath.Join(t.TempDir(), "devices.json")
if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {
t.Fatal(err)
}
s, err := Open(path, 1000, nil)
if err == nil {
t.Fatal("Open corrupt file: want error, got nil")
}
if len(s.devices) != 0 {
t.Fatalf("corrupt load produced %d records, want 0", len(s.devices))
}
t.Cleanup(func() { _ = s.Close() })
s.upsertAt(testDevice("dev-1", "PC-1"), time.Unix(1_700_000_000, 0))
if err := s.Flush(); err != nil {
t.Fatalf("Flush after corrupt load: %v", err)
}
reopened, err := Open(path, 1000, nil)
if err != nil {
t.Fatalf("reopen: %v", err)
}
t.Cleanup(func() { _ = reopened.Close() })
if len(reopened.devices) != 1 {
t.Fatalf("records after recovery = %d, want 1", len(reopened.devices))
}
}
func TestStoreRemovePersistsDeletion(t *testing.T) {
path := filepath.Join(t.TempDir(), "devices.json")
s, err := Open(path, 1000, nil)
if err != nil {
t.Fatalf("Open: %v", err)
}
base := time.Unix(1_700_000_000, 0)
s.upsertAt(testDevice("gone", "Gone"), base)
s.upsertAt(testDevice("kept", "Kept"), base.Add(time.Minute))
if !s.Remove("gone") {
t.Fatal("Remove existing record = false, want true")
}
if s.Remove("gone") {
t.Fatal("Remove missing record = true, want false")
}
if got := s.Offline(nil); len(got) != 1 || got[0].ID != "kept" {
t.Fatalf("offline after remove = %+v, want only kept", got)
}
if err := s.Flush(); err != nil {
t.Fatalf("Flush: %v", err)
}
_ = s.Close()
reopened, err := Open(path, 1000, nil)
if err != nil {
t.Fatalf("reopen: %v", err)
}
t.Cleanup(func() { _ = reopened.Close() })
if _, ok := reopened.devices["gone"]; ok {
t.Fatal("removed record came back after reload")
}
if _, ok := reopened.devices["kept"]; !ok {
t.Fatal("kept record missing after reload")
}
}
func TestFlushLeavesNoTempFiles(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "devices.json")
s, err := Open(path, 1000, nil)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
s.upsertAt(testDevice("dev-1", "PC-1"), time.Unix(1_700_000_000, 0))
if err := s.Flush(); err != nil {
t.Fatalf("Flush: %v", err)
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".tmp") {
t.Fatalf("leftover temp file: %s", e.Name())
}
}
}
+50
View File
@@ -165,6 +165,9 @@ type DeviceInfo struct {
ActiveWindow string `json:"activeWindow,omitempty"`
ConnectedAt int64 `json:"connected_at"`
Online bool `json:"online"`
// LastSeen is unix seconds of the last login/disconnect. Only populated
// for offline entries in device_list; online devices leave it zero.
LastSeen int64 `json:"last_seen,omitempty"`
}
// EventHandler receives notifications about device lifecycle, per-tick live
@@ -187,6 +190,9 @@ type EventHandler interface {
// Duplicates (same index as the previous frame) are filtered out by the
// hub before reaching subscribers.
OnCursorChange(deviceID string, index byte)
// OnClipboardText delivers the device's clipboard text (already UTF-8),
// captured in response to a browser "clipboard_get" request.
OnClipboardText(deviceID string, text string)
// OnTerminalReady fires once the device's shell sub-conn is bound and
// the server has sent COMMAND_NEXT to start its output read loop.
// isPTY=true means PTY mode (Linux/macOS or ConPTY); false means the
@@ -237,6 +243,17 @@ func (h *Hub) SetSender(fn SendFunc) {
h.sender = fn
}
// IsOnline reports whether id is currently registered with a live main
// connection. Callers that need to choose between an online-only command and
// an offline bookkeeping action should check this first; SendToDevice can
// still turn into ErrDeviceOffline if the device drops in between.
func (h *Hub) IsOnline(id string) bool {
h.mu.RLock()
d, ok := h.devices[id]
h.mu.RUnlock()
return ok && d.conn != nil
}
// SendToDevice forwards an already-formed command payload to the device's
// main connection. data should be the raw command bytes (the sender takes
// care of framing / compression at the protocol layer).
@@ -299,6 +316,17 @@ func (h *Hub) BindScreenConn(deviceID string, ctx *connection.Context) bool {
h.mu.Unlock()
return false
}
// Bind only sub-connections that originate from the device's own main
// connection source IP. Sub-conns are always opened device→server from
// the same host as the login conn, so a source-IP mismatch means the
// caller is not that device. This is the trust anchor for screen/terminal
// sub-conns: the ConnAuth signature key lives in a vendored client
// library and cannot be verified here, so without this check any internet
// host could claim an online device's MasterID and hijack its stream.
if d.PeerIP != "" && ctx.GetPeerIP() != d.PeerIP {
h.mu.Unlock()
return false
}
old := d.screenConn
d.screenConn = ctx
// The cached resolution and last IDR belong to the old encoder's
@@ -567,6 +595,13 @@ func (h *Hub) PublishResolution(deviceID string, width, height int) {
if d, ok := h.devices[deviceID]; ok {
d.screenWidth = width
d.screenHeight = height
// The cached IDR belongs to the previous capture setup. The client
// re-sends TOKEN_BITMAPINFO on the same sub-conn after a window
// resize / capture restart, so this is not only the
// new-sub-conn path (BindScreenConn already clears there). Serving
// the old-resolution IDR alongside the new dimensions leaves the
// decoder misconfigured and garbles the picture until the next IDR.
d.lastKeyframe = nil
}
h.mu.Unlock()
for _, s := range h.snapshotSubscribers() {
@@ -595,6 +630,14 @@ func (h *Hub) PublishScreenFrame(deviceID string, packet []byte, isKeyframe bool
}
}
// PublishClipboardText relays a device's clipboard text (UTF-8) to all
// subscribers, e.g. in response to a browser "clipboard_get" request.
func (h *Hub) PublishClipboardText(deviceID string, text string) {
for _, s := range h.snapshotSubscribers() {
s.OnClipboardText(deviceID, text)
}
}
// UpdateLive applies a heartbeat-derived RTT and active-window title to the
// device's live fields, then notifies subscribers. No-op if the device is
// not registered (e.g. heartbeat arriving for a connection that never sent
@@ -693,6 +736,13 @@ func (h *Hub) BindTerminalConn(deviceID string, ctx *connection.Context, isPTY b
h.mu.Unlock()
return false
}
// Same trust anchor as BindScreenConn: a terminal sub-conn must come from
// the device's own main-connection source IP, otherwise any internet host
// could claim the pending session and hijack the shell.
if d.PeerIP != "" && ctx.GetPeerIP() != d.PeerIP {
h.mu.Unlock()
return false
}
d.terminalConn = ctx
d.terminalIsPTY = isPTY
d.terminalPending = false
+56
View File
@@ -84,6 +84,8 @@ func (c *captureHandler) OnResolutionChange(_ string, _, _ int) {}
func (c *captureHandler) OnCursorChange(_ string, _ byte) {}
func (c *captureHandler) OnClipboardText(_ string, _ string) {}
func (c *captureHandler) OnTerminalReady(_ string, _ bool) {}
func (c *captureHandler) OnTerminalData(_ string, _ []byte) {}
@@ -143,6 +145,60 @@ func TestHubRegisterOverwrites(t *testing.T) {
}
}
// ctxFromIP builds a *connection.Context whose source IP resolves to ip.
// BindScreenConn/BindTerminalConn compare this against Device.PeerIP.
func ctxFromIP(ip string) *connection.Context {
return &connection.Context{RemoteAddr: ip + ":54321"}
}
// Sub-conns (screen/terminal) are opened device→server from the same host as
// the login conn. A caller claiming a MasterID from a different source IP is
// not that device and must not be able to bind, replace, or hijack the stream.
func TestHubBindScreenConnIPBinding(t *testing.T) {
h := New()
h.Register(&Device{ID: "dev", PeerIP: "10.0.0.1"}, stubCtx())
// First bind from the device's own IP succeeds.
if !h.BindScreenConn("dev", ctxFromIP("10.0.0.1")) {
t.Fatal("same-IP screen bind should succeed")
}
// A second conn claiming the same MasterID from a foreign IP is refused,
// so it cannot displace the legitimate stream.
if h.BindScreenConn("dev", ctxFromIP("203.0.113.9")) {
t.Fatal("foreign-IP screen bind should be rejected")
}
// A device that has never bound a screen conn is equally protected.
h.Register(&Device{ID: "fresh", PeerIP: "10.0.0.2"}, stubCtx())
if h.BindScreenConn("fresh", ctxFromIP("203.0.113.9")) {
t.Fatal("foreign-IP first screen bind should be rejected")
}
}
func TestHubBindTerminalConnIPBinding(t *testing.T) {
h := New()
h.Register(&Device{ID: "dev", PeerIP: "10.0.0.1"}, stubCtx())
openPending := func(id string) {
h.mu.Lock()
h.devices[id].terminalPending = true
h.mu.Unlock()
}
openPending("dev")
// Same-IP terminal bind succeeds and consumes the pending slot.
if !h.BindTerminalConn("dev", ctxFromIP("10.0.0.1"), true) {
t.Fatal("same-IP terminal bind should succeed")
}
// A second device with a pending session cannot be claimed from elsewhere.
h.Register(&Device{ID: "dev2", PeerIP: "10.0.0.2"}, stubCtx())
openPending("dev2")
if h.BindTerminalConn("dev2", ctxFromIP("203.0.113.9"), true) {
t.Fatal("foreign-IP terminal bind should be rejected")
}
}
// Race detector should not fire under `go test -race ./hub/...`.
func TestHubConcurrent(t *testing.T) {
h := New()
+183 -14
View File
@@ -1,10 +1,12 @@
package logger
import (
"fmt"
"io"
"os"
"path/filepath"
"time"
"strings"
"unicode/utf8"
"github.com/rs/zerolog"
"gopkg.in/natefinch/lumberjack.v2"
@@ -24,6 +26,13 @@ const (
// Logger wraps zerolog.Logger
type Logger struct {
zl zerolog.Logger
// audit is a second, file-only logger used for security audit records.
// It writes JSON lines to AuditFile so audit data is not buried in
// debug/operational output and can be retained independently.
audit zerolog.Logger
auditEnabled bool
auditPath string // "" when the separate audit file is disabled
closers []io.Closer
}
// Config holds logger configuration
@@ -42,6 +51,19 @@ type Config struct {
MaxAge int
// Compress enables gzip compression for rotated files
Compress bool
// AuditFile is the dedicated security-audit log path ("" disables the
// separate audit file; audit records still go to the main log). Audit
// records are JSON lines with audit=true, event=<name>, and contextual
// fields such as actor/role/ip/device_id.
AuditFile string
// AuditMaxSize / AuditMaxBackups / AuditMaxAge tune audit rotation.
// Zero values pick 100 MB / 20 files / 90 days.
AuditMaxSize int
AuditMaxBackups int
AuditMaxAge int
// AuditCompress enables gzip compression for rotated audit files.
AuditCompress bool
}
// DefaultConfig returns default configuration
@@ -60,6 +82,7 @@ func DefaultConfig() Config {
// New creates a new logger with config
func New(cfg Config) *Logger {
var writers []io.Writer
var closers []io.Closer
// Console output with color
if cfg.Console {
@@ -87,6 +110,7 @@ func New(cfg Config) *Logger {
LocalTime: true,
}
writers = append(writers, fileWriter)
closers = append(closers, fileWriter)
}
// Combine writers
@@ -106,14 +130,78 @@ func New(cfg Config) *Logger {
Timestamp().
Logger()
return &Logger{zl: zl}
l := &Logger{zl: zl, closers: closers}
if cfg.AuditFile != "" {
dir := filepath.Dir(cfg.AuditFile)
if dir != "" && dir != "." {
_ = os.MkdirAll(dir, 0755)
}
maxSize := cfg.AuditMaxSize
if maxSize <= 0 {
maxSize = 100
}
maxBackups := cfg.AuditMaxBackups
if maxBackups <= 0 {
maxBackups = 20
}
maxAge := cfg.AuditMaxAge
if maxAge <= 0 {
maxAge = 90
}
auditWriter := &lumberjack.Logger{
Filename: cfg.AuditFile,
MaxSize: maxSize,
MaxBackups: maxBackups,
MaxAge: maxAge,
Compress: cfg.AuditCompress,
LocalTime: true,
}
l.audit = zerolog.New(auditWriter).
Level(zerolog.InfoLevel).
With().
Timestamp().
Logger()
l.auditEnabled = true
l.auditPath = cfg.AuditFile
l.closers = append(l.closers, auditWriter)
}
return l
}
// Close releases any file handles owned by the logger. Safe to call multiple
// times. Loggers derived with WithPrefix share the base logger's files but do
// not own them, so call Close on the logger returned by New.
func (l *Logger) Close() error {
if l == nil {
return nil
}
for _, c := range l.closers {
_ = c.Close()
}
l.closers = nil
return nil
}
// WithPrefix returns a new logger with a prefix field
func (l *Logger) WithPrefix(prefix string) *Logger {
return &Logger{
zl: l.zl.With().Str("module", prefix).Logger(),
n := &Logger{zl: l.zl.With().Str("module", prefix).Logger(), auditPath: l.auditPath}
if l.auditEnabled {
n.audit = l.audit.With().Str("module", prefix).Logger()
n.auditEnabled = true
}
return n
}
// AuditPath returns the path of the dedicated audit log file, or "" when the
// separate audit file is disabled (audit records then only reach the main log).
// Derived loggers (WithPrefix) report the same path.
func (l *Logger) AuditPath() string {
if l == nil {
return ""
}
return l.auditPath
}
// Debug logs a debug message
@@ -151,21 +239,102 @@ func (l *Logger) GetLevel() Level {
return l.zl.GetLevel()
}
// ClientEvent logs client online/offline events
// ClientEvent logs client online/offline events as audit records.
func (l *Logger) ClientEvent(event string, clientID uint64, ip string, extra ...string) {
e := l.zl.Info().
Str("event", event).
Uint64("client_id", clientID).
Str("ip", ip).
Time("time", time.Now())
if len(extra) >= 2 {
// clientID is the TCP connection id; the device's stable YAMA id arrives
// as the "clientID" extra and is normalized to device_id so every audit
// record uses the same field names (conn / device_id).
fields := map[string]any{
"conn": clientID,
"ip": ip,
}
for i := 0; i+1 < len(extra); i += 2 {
e = e.Str(extra[i], extra[i+1])
key := extra[i]
if key == "clientID" {
key = "device_id"
}
fields[key] = extra[i+1]
}
l.Audit("device."+event, fields)
}
e.Msg("")
// Audit writes one security-audit record at info level.
func (l *Logger) Audit(event string, fields map[string]any) {
l.AuditAt(LevelInfo, event, fields)
}
// AuditWarn writes one security-audit record at warn level. Use it for
// rejected / failed security-relevant operations.
func (l *Logger) AuditWarn(event string, fields map[string]any) {
l.AuditAt(LevelWarn, event, fields)
}
// AuditAt writes a security-audit record to the dedicated audit file (when
// configured) and mirrors it to the main logger so operators tailing
// server.log still see security events. event is a stable dotted name such
// as "web.login.failure"; fields carry actor/role/ip/device_id/reason.
//
// Untrusted string values (usernames, User-Agent, Origin, device strings)
// are sanitized: control characters are replaced so they cannot forge log
// lines, and long values are truncated.
func (l *Logger) AuditAt(level Level, event string, fields map[string]any) {
if l == nil {
return
}
clean := make(map[string]any, len(fields)+2)
for k, v := range fields {
clean[k] = sanitizeAuditValue(v)
}
clean["audit"] = true
clean["event"] = event
if l.auditEnabled {
l.audit.WithLevel(level).Fields(clean).Msg("audit")
}
l.zl.WithLevel(level).Fields(clean).Msg("audit")
}
// maxAuditValueLen bounds untrusted log values (runes). A 1 MB WebSocket
// frame could otherwise be reflected verbatim into every audit line.
const maxAuditValueLen = 512
// maxAuditValueBytes is the pre-sanitization byte cap. It keeps a single
// hostile value from allocating an unbounded rune slice before truncation.
const maxAuditValueBytes = 4096
func sanitizeAuditValue(v any) any {
switch x := v.(type) {
case string:
return sanitizeAuditString(x)
case []byte:
return sanitizeAuditString(string(x))
case error:
return sanitizeAuditString(x.Error())
case fmt.Stringer:
return sanitizeAuditString(x.String())
default:
// Numeric/bool values are code-controlled and log-safe.
return v
}
}
func sanitizeAuditString(s string) string {
if len(s) > maxAuditValueBytes {
s = s[:maxAuditValueBytes]
}
s = strings.Map(func(r rune) rune {
// C0 controls, DEL, and Unicode line/paragraph separators can be
// used to forge or visually split log lines in console output.
if r < 0x20 || r == 0x7f || r == 0x2028 || r == 0x2029 {
return ' '
}
return r
}, s)
if utf8.RuneCountInString(s) > maxAuditValueLen {
r := []rune(s)
s = string(r[:maxAuditValueLen])
}
return s
}
// default global logger
+156
View File
@@ -0,0 +1,156 @@
package logger
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// readAuditLines parses logs/audit.log-style JSON lines. Callers assert on
// individual records rather than raw text so field ordering is irrelevant.
func readAuditLines(t *testing.T, path string) []map[string]any {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatalf("open audit log: %v", err)
}
defer f.Close()
var out []map[string]any
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
var m map[string]any
if err := json.Unmarshal([]byte(line), &m); err != nil {
t.Fatalf("audit line is not JSON: %q: %v", line, err)
}
out = append(out, m)
}
if err := sc.Err(); err != nil {
t.Fatalf("scan audit log: %v", err)
}
return out
}
func TestAuditWritesDedicatedFileAndMirrorsMainLog(t *testing.T) {
dir := t.TempDir()
auditPath := filepath.Join(dir, "audit.log")
mainPath := filepath.Join(dir, "server.log")
l := New(Config{
Level: LevelInfo,
Console: false,
File: mainPath,
AuditFile: auditPath,
})
t.Cleanup(func() { _ = l.Close() })
l.Audit("web.login.success", map[string]any{
"actor": "admin",
"role": "admin",
"ip": "203.0.113.9",
})
lines := readAuditLines(t, auditPath)
if len(lines) != 1 {
t.Fatalf("audit lines = %d, want 1", len(lines))
}
rec := lines[0]
if rec["audit"] != true {
t.Errorf("audit = %v, want true", rec["audit"])
}
if rec["event"] != "web.login.success" {
t.Errorf("event = %v", rec["event"])
}
if rec["actor"] != "admin" || rec["role"] != "admin" || rec["ip"] != "203.0.113.9" {
t.Errorf("fields = %+v", rec)
}
if _, ok := rec["time"]; !ok {
t.Error("record has no timestamp")
}
// The same record must reach server.log so operators tailing one file
// still see security events.
mainLines := readAuditLines(t, mainPath)
found := false
for _, m := range mainLines {
if m["event"] == "web.login.success" && m["audit"] == true {
found = true
}
}
if !found {
t.Errorf("audit record not mirrored to main log: %+v", mainLines)
}
}
func TestAuditSanitizesUntrustedValues(t *testing.T) {
dir := t.TempDir()
auditPath := filepath.Join(dir, "audit.log")
l := New(Config{Console: false, File: filepath.Join(dir, "server.log"), AuditFile: auditPath})
t.Cleanup(func() { _ = l.Close() })
l.Audit("web.login.failure", map[string]any{
// Newlines must not be able to forge extra log lines.
"username": "evil\n{\"audit\":true,\"event\":\"web.login.success\"}",
"ua": strings.Repeat("A", maxAuditValueLen+50),
})
lines := readAuditLines(t, auditPath)
if len(lines) != 1 {
t.Fatalf("audit lines = %d, want 1 (newline forged a record?)", len(lines))
}
username, _ := lines[0]["username"].(string)
if strings.ContainsAny(username, "\n\r") {
t.Errorf("username still contains a newline: %q", username)
}
if !strings.Contains(username, `{"audit":true`) {
t.Errorf("username content lost: %q", username)
}
ua, _ := lines[0]["ua"].(string)
if got := len([]rune(ua)); got != maxAuditValueLen {
t.Errorf("ua rune length = %d, want %d", got, maxAuditValueLen)
}
}
func TestAuditDisabledFileStillLogsToMain(t *testing.T) {
dir := t.TempDir()
mainPath := filepath.Join(dir, "server.log")
l := New(Config{Console: false, File: mainPath}) // no AuditFile
t.Cleanup(func() { _ = l.Close() })
l.AuditWarn("web.authz.denied", map[string]any{"role": "viewer"})
lines := readAuditLines(t, mainPath)
if len(lines) != 1 || lines[0]["event"] != "web.authz.denied" {
t.Fatalf("main log lines = %+v", lines)
}
if _, err := os.Stat(filepath.Join(dir, "audit.log")); !os.IsNotExist(err) {
t.Errorf("audit.log unexpectedly created: %v", err)
}
}
func TestClientEventEmitsDeviceAudit(t *testing.T) {
dir := t.TempDir()
auditPath := filepath.Join(dir, "audit.log")
l := New(Config{Console: false, File: filepath.Join(dir, "server.log"), AuditFile: auditPath})
t.Cleanup(func() { _ = l.Close() })
l.ClientEvent("online", 42, "198.51.100.7", "clientID", "abc", "computer", "PC-01")
lines := readAuditLines(t, auditPath)
if len(lines) != 1 {
t.Fatalf("audit lines = %d, want 1", len(lines))
}
if lines[0]["event"] != "device.online" {
t.Errorf("event = %v, want device.online", lines[0]["event"])
}
if lines[0]["device_id"] != "abc" {
t.Errorf("device_id = %v", lines[0]["device_id"])
}
}
+22 -1
View File
@@ -106,10 +106,14 @@ const (
CommandActived byte = 0 // COMMAND_ACTIVED
CommandScreenSpy byte = 16 // COMMAND_SCREEN_SPY - start screen capture
CommandScreenControl byte = 20 // COMMAND_SCREEN_CONTROL - mouse/keyboard input (MSG64 batches)
CommandScreenGetClipboard byte = 24 // COMMAND_SCREEN_GET_CLIPBOARD - request remote clipboard (single byte)
CommandScreenSetClipboard byte = 25 // COMMAND_SCREEN_SET_CLIPBOARD - set remote clipboard [25][GBK text]
CommandNext byte = 30 // COMMAND_NEXT - "control-side dialog is open, you may stream"
CommandShell byte = 40 // COMMAND_SHELL - ask device to open a shell sub-connection
CommandUninstall byte = 63 // TOKEN_UNINSTALL - uninstall the host agent (single byte)
CommandTerminalRsize byte = 81 // CMD_TERMINAL_RESIZE - [cmd:1][cols:2 LE][rows:2 LE]
CmdRestoreConsole byte = 82 // CMD_RESTORE_CONSOLE - RDP session "归位": switch back to the console session and restart capture
CommandScreenKeyframeReq byte = 169 // COMMAND_SCREEN_KEYFRAME_REQ - force the client to emit an IDR immediately
CommandBye byte = 204 // COMMAND_BYE - disconnect
CommandHeartbeat byte = 216 // CMD_HEARTBEAT_ACK
@@ -120,12 +124,14 @@ const (
TokenBitmapInfo byte = 115 // TOKEN_BITMAPINFO - screen sub-connection header
TokenFirstScreen byte = 116 // TOKEN_FIRSTSCREEN - raw BGRA baseline frame (NOT H264)
TokenNextScreen byte = 117 // TOKEN_NEXTSCREEN - non-keyframe H264 (P-frame)
TokenClipboardText byte = 118 // TOKEN_CLIPBOARD_TEXT - remote clipboard text [118][UTF-8][NUL]
TokenShellStart byte = 128 // TOKEN_SHELL_START - legacy cmd-pipe shell sub-conn open
TokenKeyframe byte = 134 // TOKEN_KEYFRAME - H264 IDR (sent on GOP boundary)
TokenTerminalStart byte = 232 // TOKEN_TERMINAL_START - modern PTY shell sub-conn open
TokenTerminalClose byte = 233 // TOKEN_TERMINAL_CLOSE - shell exited / close ack
TokenConnAuth byte = 246 // TOKEN_CONN_AUTH - sub-connection identity handshake
CmdCursorImage byte = 93 // CMD_CURSOR_IMAGE - custom cursor bitmap (Phase 5+ feature)
CmdSockDllLoader byte = 210 // SOCKET_DLLLOADER - shellcode/memory-DLL loader requests the full DLL payload
)
// Sub-connection authentication (matches common/commands.h ConnAuth* structs).
@@ -140,11 +146,26 @@ const (
ConnAuthOffClientID = 1 // uint64 LE — pin to the sub-conn so later
// // 1-byte tokens (TOKEN_TERMINAL_START etc.) can
// // resolve the parent device.
ConnAuthOffTimestamp = 9 // uint64 LE - Unix seconds, anti-replay window
ConnAuthOffNonce = 17 // [16]byte - part of the signed message
ConnAuthOffSignature = 33 // [64]byte - hex of HMAC-SHA256(key, clientID||timestamp||nonce)
ConnAuthSignatureSize = 64
// ConnAuthAck field offsets within the outbound 256-byte buffer.
ConnAuthAckOffStatus = 1 // uint8
ConnAuthAckOffServerTime = 2 // uint64 LE
// Status codes.
// ConnAuthTimestampToleranceSec bounds |server - client| clock skew in
// seconds (matches CONN_AUTH_TIMESTAMP_TOLERANCE_SEC in common/commands.h).
// A timestamp outside this window is treated as replayed or forged.
ConnAuthTimestampToleranceSec = 300
// Status codes (match common/commands.h::ConnAuthStatus). The client treats
// any non-zero status as failure and drops the sub-connection.
ConnAuthStatusOK byte = 0
ConnAuthStatusBadSize byte = 1 // packet shorter than the signature field
ConnAuthStatusClockSkew byte = 2 // timestamp outside tolerance
ConnAuthStatusBadSignature byte = 3 // HMAC mismatch
ConnAuthStatusInternalError byte = 4
)
// CMD_MASTERSETTING is the server's reply to a fresh client login. The
+195 -1
View File
@@ -2,6 +2,7 @@ package server
import (
"context"
"crypto/tls"
"io"
"net"
"sync"
@@ -22,6 +23,31 @@ type Config struct {
KeepAliveTime time.Duration
ReadTimeout time.Duration
WriteTimeout time.Duration
// MaxBufferedBytes is a hard cap on unparsed inbound bytes buffered per
// connection. The protocol's own length field is validated to 50 MiB, but
// a peer that sends bytes that never assemble into a complete packet (e.g.
// an HTTP-mask envelope without its terminating CRLF CRLF, which Parse()
// waits on indefinitely) could otherwise grow the buffer without bound.
// Default leaves slack for the largest legitimate packet plus its mask
// envelope and a read chunk.
MaxBufferedBytes int
// Per-IP connection limits. The TCP listener (port 6543) is exposed
// directly to the public internet, so a single host must not be able to
// exhaust the global connection budget or flood new-connection attempts.
// Defaults are generous enough for a NAT office with dozens of devices
// sharing one source IP; raise them if you run a larger NAT deployment.
PerIPMaxConnections int // max concurrent connections per source IP (0 = unlimited)
PerIPConnRateLimit int // max new connections per PerIPConnRateWindow per IP (0 = unlimited)
PerIPConnRateWindow time.Duration // window for PerIPConnRateLimit
// TLSCertFile / TLSKeyFile enable optional one-way TLS on the device port.
// When both are set the server loads the cert and auto-detects TLS clients
// by their ClientHello; plaintext clients keep working unchanged. Both empty
// (the default) disables TLS and behaves exactly as before.
TLSCertFile string // server certificate (PEM)
TLSKeyFile string // server private key (PEM)
}
// DefaultConfig returns default configuration
@@ -34,6 +60,10 @@ func DefaultConfig() Config {
KeepAliveTime: time.Minute * 5,
ReadTimeout: time.Minute * 2,
WriteTimeout: time.Second * 30,
MaxBufferedBytes: 50*1024*1024 + 64*1024,
PerIPMaxConnections: 256,
PerIPConnRateLimit: 120,
PerIPConnRateWindow: time.Minute,
}
}
@@ -51,6 +81,11 @@ type Server struct {
manager *connection.Manager
handler Handler
parser *protocol.Parser
ipLimit *ipLimiter
// tlsCfg is non-nil when one-way TLS is enabled (cert + key configured).
// wrapTLS consults it to sniff and wrap incoming connections.
tlsCfg *tls.Config
running atomic.Bool
wg sync.WaitGroup
@@ -69,6 +104,7 @@ func New(config Config) *Server {
config: config,
manager: connection.NewManager(config.MaxConnections),
parser: protocol.NewParser(),
ipLimit: newIPLimiter(config.PerIPMaxConnections, config.PerIPConnRateLimit, config.PerIPConnRateWindow),
ctx: ctx,
cancel: cancel,
logger: logger.New(logger.DefaultConfig()).WithPrefix("Server"),
@@ -77,6 +113,110 @@ func New(config Config) *Server {
return s
}
// ipLimiter caps concurrent connections and the rate of new connections per
// source IP. Both limits are per-host, not global, so a single attacker (or a
// single misbehaving host) cannot starve legitimate devices of the global
// connection budget.
type ipLimiter struct {
mu sync.Mutex
maxConcurrent int
window time.Duration
maxPerWindow int
concurrent map[string]int
recent map[string][]time.Time
}
func newIPLimiter(maxConcurrent, maxPerWindow int, window time.Duration) *ipLimiter {
return &ipLimiter{
maxConcurrent: maxConcurrent,
maxPerWindow: maxPerWindow,
window: window,
concurrent: make(map[string]int),
recent: make(map[string][]time.Time),
}
}
// allow records a new connection from ip and reports whether it is permitted.
// It enforces both a concurrent cap and a rolling accept-rate cap. An empty ip
// (which should not occur for a real TCP peer) is not throttled.
func (l *ipLimiter) allow(ip string) bool {
if ip == "" {
return true
}
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
if l.maxConcurrent > 0 && l.concurrent[ip] >= l.maxConcurrent {
return false
}
if l.maxPerWindow > 0 && l.window > 0 {
cutoff := now.Add(-l.window)
times := l.recent[ip]
keep := times[:0]
for _, t := range times {
if t.After(cutoff) {
keep = append(keep, t)
}
}
if len(keep) >= l.maxPerWindow {
l.recent[ip] = keep
return false
}
l.recent[ip] = append(keep, now)
}
l.concurrent[ip]++
return true
}
// release decrements the concurrent count for ip. Safe to call on an ip that
// was never allowed.
func (l *ipLimiter) release(ip string) {
if ip == "" {
return
}
l.mu.Lock()
if n := l.concurrent[ip]; n > 1 {
l.concurrent[ip] = n - 1
} else {
delete(l.concurrent, ip)
}
l.mu.Unlock()
}
// sweep drops per-IP rate-limit entries whose timestamps have all aged out,
// keeping the map bounded over a long-running process.
func (l *ipLimiter) sweep() {
l.mu.Lock()
defer l.mu.Unlock()
cutoff := time.Now().Add(-l.window)
for ip, times := range l.recent {
keep := times[:0]
for _, t := range times {
if t.After(cutoff) {
keep = append(keep, t)
}
}
if len(keep) == 0 {
delete(l.recent, ip)
} else {
l.recent[ip] = keep
}
}
}
// peerIP returns the host part of a net.Addr's string form, mirroring
// connection.Context.GetPeerIP so accept-time and release-time keys agree.
func peerIP(addr net.Addr) string {
host, _, err := net.SplitHostPort(addr.String())
if err != nil {
return addr.String()
}
return host
}
// SetHandler sets the event handler
func (s *Server) SetHandler(h Handler) {
s.handler = h
@@ -106,6 +246,12 @@ func (s *Server) SetLogger(l *logger.Logger) {
// Start starts the server
func (s *Server) Start() error {
// Load optional one-way TLS cert up front so a bad path/key fails startup
// loudly rather than silently serving plaintext.
if err := s.loadTLSConfig(); err != nil {
return err
}
addr := net.JoinHostPort("0.0.0.0", itoa(s.config.Port))
listener, err := net.Listen("tcp", addr)
if err != nil {
@@ -120,9 +266,32 @@ func (s *Server) Start() error {
s.wg.Add(1)
go s.acceptLoop()
// Periodically prune aged-out per-IP rate-limit entries so the map stays
// bounded over a long-running process. Stopped by s.ctx.Done() in Stop().
s.wg.Add(1)
go s.ipSweepLoop()
return nil
}
func (s *Server) ipSweepLoop() {
defer s.wg.Done()
interval := s.config.PerIPConnRateWindow
if interval <= 0 {
interval = time.Minute
}
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-s.ctx.Done():
return
case <-t.C:
s.ipLimit.sweep()
}
}
}
func itoa(i int) string {
if i == 0 {
return "0"
@@ -194,16 +363,32 @@ func (s *Server) acceptLoop() {
continue
}
// Per-IP connection cap + accept rate. Rejecting here (before any
// buffer allocation or goroutine) is cheap, and a rejected device
// simply retries on its reconnect loop.
if !s.ipLimit.allow(peerIP(conn.RemoteAddr())) {
s.logger.Warn("Per-IP connection limit reached, rejecting %s", conn.RemoteAddr())
_ = conn.Close()
continue
}
// Handle each connection in its own goroutine
go s.handleConnection(conn)
}
}
// handleConnection handles a single connection
func (s *Server) handleConnection(conn net.Conn) {
func (s *Server) handleConnection(rawConn net.Conn) {
// Sniff and (when TLS is enabled) wrap TLS peers; plaintext passes through.
conn := s.wrapTLS(rawConn)
// Create context
ctx := connection.NewContext(conn, nil)
// Always release the per-IP slot on every exit path, including the
// manager.Add failure below (a rare accept/add race).
defer s.ipLimit.release(ctx.GetPeerIP())
// Add to manager
if err := s.manager.Add(ctx); err != nil {
s.logger.Warn("Failed to add connection: %v", err)
@@ -245,6 +430,15 @@ func (s *Server) handleConnection(conn net.Conn) {
// Write to input buffer
_, _ = ctx.InBuffer.Write(buf[:n])
// Hard cap on unparsed inbound bytes (memory-DoS guard). A peer
// that sends bytes that never assemble into a complete packet can
// otherwise grow this buffer without bound.
if s.config.MaxBufferedBytes > 0 && ctx.InBuffer.Len() > s.config.MaxBufferedBytes {
s.logger.Warn("Inbound buffer exceeded %d bytes from ip=%s conn=%d — closing",
s.config.MaxBufferedBytes, ctx.GetPeerIP(), ctx.ID)
break
}
// Process received data
s.processData(ctx)
}
+132
View File
@@ -0,0 +1,132 @@
package server
import (
"bytes"
"net"
"testing"
"time"
)
func TestIPLimiterConcurrentCap(t *testing.T) {
l := newIPLimiter(2, 0, 0) // 2 concurrent, no rate limit
if !l.allow("10.0.0.1") {
t.Fatal("first connection from an IP should be allowed")
}
if !l.allow("10.0.0.1") {
t.Fatal("second connection from an IP should be allowed")
}
if l.allow("10.0.0.1") {
t.Fatal("third concurrent connection from the same IP should be rejected")
}
// A different IP is unaffected.
if !l.allow("10.0.0.2") {
t.Fatal("a different IP should not share the first IP's cap")
}
// Releasing one slot frees capacity again.
l.release("10.0.0.1")
if !l.allow("10.0.0.1") {
t.Fatal("after release, the IP should have a free slot")
}
}
func TestIPLimiterRateLimit(t *testing.T) {
l := newIPLimiter(0, 3, time.Minute) // no concurrent cap, 3/min per IP
for range 3 {
if !l.allow("10.0.0.1") {
t.Fatal("an attempt within budget should be allowed")
}
}
if l.allow("10.0.0.1") {
t.Fatal("4th connection within the window should be rejected")
}
// A different IP has its own budget.
if !l.allow("10.0.0.2") {
t.Fatal("a different IP should have its own rate budget")
}
}
func TestIPLimiterEmptyIPBypasses(t *testing.T) {
l := newIPLimiter(1, 1, time.Minute)
// An empty peer IP (shouldn't happen for real TCP) must not be throttled,
// so a caller that loses the IP falls back to accepting.
if !l.allow("") {
t.Fatal("empty IP should bypass the limiter")
}
}
func TestIPLimiterSweep(t *testing.T) {
l := newIPLimiter(0, 1, time.Minute)
l.mu.Lock()
l.recent["stale"] = []time.Time{time.Now().Add(-time.Hour)}
l.recent["fresh"] = []time.Time{time.Now()}
l.mu.Unlock()
l.sweep()
l.mu.Lock()
defer l.mu.Unlock()
if _, ok := l.recent["stale"]; ok {
t.Fatal("stale rate-limit entry should be swept")
}
if _, ok := l.recent["fresh"]; !ok {
t.Fatal("fresh rate-limit entry should survive sweep")
}
}
func TestPeerIP(t *testing.T) {
tests := []struct {
addr net.Addr
want string
}{
{&net.TCPAddr{IP: net.ParseIP("1.2.3.4"), Port: 1234}, "1.2.3.4"},
{&net.TCPAddr{IP: net.ParseIP("::1"), Port: 6543}, "::1"},
}
for _, tt := range tests {
if got := peerIP(tt.addr); got != tt.want {
t.Fatalf("peerIP(%v) = %q, want %q", tt.addr, got, tt.want)
}
}
}
// A peer that sends bytes which never assemble into a complete packet (here an
// HTTP-mask envelope with no terminating CRLF CRLF) must be disconnected once
// the per-connection buffer cap is exceeded, rather than allowed to grow it
// without bound.
func TestHandleConnectionBufferCap(t *testing.T) {
cfg := DefaultConfig()
cfg.MaxBufferedBytes = 1024 // small cap for a fast, in-memory test
cfg.ReadTimeout = time.Second
srv := New(cfg)
srv.running.Store(true) // bypass acceptLoop; exercise handleConnection directly
client, serverConn := net.Pipe()
defer client.Close()
done := make(chan struct{})
go func() {
srv.handleConnection(serverConn)
close(done)
}()
// "POST " prefix with no terminator, plus filler to exceed the cap.
payload := append([]byte("POST /x HTTP/1.1\r\n"), bytes.Repeat([]byte{'A'}, 4096)...)
writeErr := make(chan error, 1)
go func() {
_, err := client.Write(payload)
writeErr <- err
}()
select {
case <-done:
// handleConnection returned because the cap was exceeded and it closed
// the connection.
case <-time.After(2 * time.Second):
t.Fatal("connection was not closed after exceeding the buffer cap")
}
}
+113
View File
@@ -0,0 +1,113 @@
package server
import (
"bufio"
"crypto/tls"
"fmt"
"net"
"time"
)
// loadTLSConfig builds the server's one-way TLS configuration from the
// configured cert/key paths, leaving s.tlsCfg nil when TLS is disabled. A cert
// path set without its key (or vice versa) is a misconfiguration: TLS is left
// disabled and a warning is logged rather than guessing. A load failure is
// returned to the caller so startup fails loudly instead of silently serving
// plaintext.
func (s *Server) loadTLSConfig() error {
if s.config.TLSCertFile == "" && s.config.TLSKeyFile == "" {
return nil
}
if s.config.TLSCertFile == "" || s.config.TLSKeyFile == "" {
s.logger.Warn("TLS disabled: both YAMA_TLS_CERT and YAMA_TLS_KEY must be set (cert=%q key=%q)",
s.config.TLSCertFile, s.config.TLSKeyFile)
return nil
}
cert, err := tls.LoadX509KeyPair(s.config.TLSCertFile, s.config.TLSKeyFile)
if err != nil {
return fmt.Errorf("load TLS cert/key: %w", err)
}
s.tlsCfg = &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
// One-way TLS: no ClientAuth / ClientCAs. Client identity is still
// established by the in-tunnel login (V1 PwdHmac / V2 ECDSA).
}
s.logger.Info("TLS enabled (one-way): cert=%s", s.config.TLSCertFile)
return nil
}
// isTLSClientHello reports whether b begins with a TLS handshake record whose
// first message is a ClientHello:
//
// 0x16 — handshake record type
// 0x03 — TLS major version (all TLS 1.x / 1.3 records)
// 0x01 (b[5])— handshake message type ClientHello
//
// The first two bytes alone are insufficient: the C++ client obfuscates its
// "HELL" header (common/header.h GetHead) with a per-connection key, so a
// plaintext client can in principle emit 0x16 0x03 as its first two bytes. For
// that obfuscated header byte 5 is always 0x00, and for the plain "Shine"/
// "POST" variants it is an ASCII byte — never 0x01. Requiring b[5]==0x01 makes
// a false positive effectively impossible.
func isTLSClientHello(b []byte) bool {
return len(b) >= 6 && b[0] == 0x16 && b[1] == 0x03 && b[5] == 0x01
}
// bufferedConn replays bytes already peeked from a net.Conn, so the caller can
// sniff a prefix and then hand the connection (peeked bytes included) to the
// TLS or plaintext read loop. Read delegates to the buffered reader; everything
// else delegates to the embedded net.Conn.
type bufferedConn struct {
net.Conn
r *bufio.Reader
}
func (c *bufferedConn) Read(p []byte) (int, error) {
return c.r.Read(p)
}
// wrapTLS sniffs the first bytes of a freshly accepted connection and, when TLS
// is enabled and the peer is speaking TLS, returns a tls.Conn wrapper. The TLS
// handshake is lazy: it runs on the first Read, which handleConnection already
// bounds with its ReadTimeout. Plaintext peers are returned unchanged (wrapped
// in a bufferedConn so the sniffed prefix is replayed).
func (s *Server) wrapTLS(conn net.Conn) net.Conn {
// NewContext only disables Nagle on a *net.TCPConn; once we wrap the conn
// (bufferedConn / tls.Conn) that assertion no longer fires, so disable it
// here on the raw conn to preserve screen-handshake responsiveness.
if tcp, ok := conn.(*net.TCPConn); ok {
_ = tcp.SetNoDelay(true)
}
if s.tlsCfg == nil {
return conn
}
br := bufio.NewReader(conn)
if s.config.ReadTimeout > 0 {
_ = conn.SetReadDeadline(time.Now().Add(s.config.ReadTimeout))
}
hdr, err := br.Peek(6)
_ = conn.SetReadDeadline(time.Time{})
// Peek failed (timeout / EOF): the peer never delivered enough bytes to
// identify itself. Fall through with the raw conn so the read loop surfaces
// EOF/timeout exactly as it does for the plaintext path today. Any <6
// partial bytes already buffered are discarded, which only affects peers
// that were already broken (real clients send ≥13 bytes immediately).
if err != nil {
s.logger.Warn("wrapTLS peek failed for %s: %v", conn.RemoteAddr(), err)
return conn
}
bc := &bufferedConn{Conn: conn, r: br}
if isTLSClientHello(hdr) {
s.logger.Debug("wrapTLS: TLS peer %s first6=%x", conn.RemoteAddr(), hdr)
return tls.Server(bc, s.tlsCfg)
}
s.logger.Debug("wrapTLS: plaintext peer %s first6=%x", conn.RemoteAddr(), hdr)
return bc
}
+55
View File
@@ -0,0 +1,55 @@
package server
import "testing"
func TestIsTLSClientHello(t *testing.T) {
tests := []struct {
name string
b []byte
want bool
}{
{
name: "TLS 1.2 ClientHello",
b: []byte{0x16, 0x03, 0x03, 0x00, 0x01, 0x01, 0x00},
want: true,
},
{
name: "TLS 1.3 ClientHello (legacy record version)",
b: []byte{0x16, 0x03, 0x01, 0x00, 0x01, 0x01, 0x00},
want: true,
},
{
name: "plaintext Shine flag",
b: []byte{'S', 'h', 'i', 'n', 'e', 0x00, 0x00},
want: false,
},
{
name: "obfuscated HELL header that happens to start 0x16 0x03 but byte5=0x00",
b: []byte{0x16, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00},
want: false,
},
{
name: "HTTP-mask POST envelope",
b: []byte{'P', 'O', 'S', 'T', ' ', '/', 'x'},
want: false,
},
{
name: "0x16 0x03 but handshake type ServerHello (0x02), not ClientHello",
b: []byte{0x16, 0x03, 0x03, 0x00, 0x01, 0x02, 0x00},
want: false,
},
{
name: "too short",
b: []byte{0x16, 0x03, 0x01},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isTLSClientHello(tt.b); got != tt.want {
t.Errorf("isTLSClientHello(% x) = %v, want %v", tt.b, got, tt.want)
}
})
}
}
+171
View File
@@ -0,0 +1,171 @@
package web
import (
"crypto/rand"
"encoding/hex"
"net/http"
"time"
)
// Stable audit event names. Keep these stable: operators build alerts and
// dashboards on them. Every record also carries audit=true.
const (
auditWSConnect = "web.ws.connect"
auditWSReject = "web.ws.reject"
auditWSClose = "web.ws.close"
auditLoginOK = "web.login.success"
auditLoginFail = "web.login.failure"
auditLoginThrot = "web.login.throttled"
auditSession = "web.session.resume"
auditBadToken = "web.auth.invalid_token"
auditDenied = "web.authz.denied"
auditUnknownCmd = "web.command.unknown"
auditSaltProbe = "web.salt.unknown_user"
auditScreenStart = "remote.screen.start"
auditScreenStop = "remote.screen.stop"
auditScreenDenied = "remote.screen.denied"
auditTermStart = "remote.terminal.start"
auditTermStop = "remote.terminal.stop"
auditTermDenied = "remote.terminal.denied"
auditClipSet = "remote.clipboard.set"
auditClipGet = "remote.clipboard.get"
auditRdpReset = "remote.rdp_reset"
auditUserCreate = "admin.user.create"
auditUserDelete = "admin.user.delete"
auditDeviceAdmin = "admin.device.delete"
auditQuery = "admin.audit.query"
)
// audit emits one audit record for a WebSocket client. Identity fields are
// read under h.mu because requireAuth/handleLogin install them there and
// broadcast goroutines may observe the same client concurrently.
//
// Callers MUST NOT hold h.mu when calling this method.
func (h *wsHub) audit(c *wsClient, warn bool, event string, fields map[string]any) {
if fields == nil {
fields = make(map[string]any)
}
if c != nil {
h.mu.RLock()
connID := c.id
addr := c.addr
peer := c.peerAddr
user := c.user
role := c.role
h.mu.RUnlock()
if _, ok := fields["conn"]; !ok && connID != "" {
fields["conn"] = connID
}
if _, ok := fields["ip"]; !ok && addr != "" {
fields["ip"] = addr
}
if _, ok := fields["peer_ip"]; !ok && peer != "" && peer != addr {
fields["peer_ip"] = peer
}
if _, ok := fields["actor"]; !ok && user != "" {
fields["actor"] = user
}
if _, ok := fields["role"]; !ok && role != "" {
fields["role"] = role
}
}
if warn {
h.log.AuditWarn(event, fields)
} else {
h.log.Audit(event, fields)
}
}
// auditClose records the end of a browser WebSocket. Anonymous scanners that
// connect and do nothing produce only the connect event; authenticated
// sessions, failed-login probes, and command traffic always produce a close
// record with counters that help spot brute-force / probing behavior.
func (h *wsHub) auditClose(c *wsClient, screens int) {
suspicious := c.statInvalidToken > 0 ||
c.statLoginFailures > 0 ||
c.statAdminDenied > 0 ||
c.statUnknownSalt > 0 ||
c.statUnknownCmd > 0
if c.user == "" && c.statCommands == 0 && !suspicious {
return
}
fields := map[string]any{
"duration_ms": time.Since(c.connectedAt).Milliseconds(),
"commands": c.statCommands,
}
if screens > 0 {
fields["screens"] = screens
}
if c.statInvalidToken > 0 {
fields["invalid_tokens"] = c.statInvalidToken
}
if c.statLoginFailures > 0 {
fields["login_failures"] = c.statLoginFailures
}
if c.statAdminDenied > 0 {
fields["admin_denied"] = c.statAdminDenied
}
if c.statUnknownSalt > 0 {
fields["unknown_salt"] = c.statUnknownSalt
}
if c.statUnknownCmd > 0 {
fields["unknown_commands"] = c.statUnknownCmd
}
h.audit(c, suspicious, auditWSClose, fields)
}
// requestFields builds the audit context available before a WebSocket client
// exists (upgrade rejected) or for plain HTTP endpoints (SSO).
func requestFields(r *http.Request, trustForwardedFor bool) map[string]any {
fields := make(map[string]any)
if r == nil {
return fields
}
ip := clientIP(r, trustForwardedFor)
if ip != "" {
fields["ip"] = ip
}
if peer := stripPort(r.RemoteAddr); peer != "" && peer != ip {
fields["peer_ip"] = peer
}
if origin := r.Header.Get("Origin"); origin != "" {
fields["origin"] = origin
}
if ua := r.UserAgent(); ua != "" {
fields["ua"] = ua
}
return fields
}
// wsRequestFields returns request metadata worth attaching to login events.
func (c *wsClient) wsRequestFields() map[string]any {
fields := make(map[string]any)
if c.origin != "" {
fields["origin"] = c.origin
}
if c.userAgent != "" {
fields["ua"] = c.userAgent
}
return fields
}
func durationMs(start time.Time) int64 {
if start.IsZero() {
return 0
}
return time.Since(start).Milliseconds()
}
// newConnID returns a short random identifier used to correlate all audit
// records belonging to one browser WebSocket connection.
func newConnID() string {
var b [4]byte
if _, err := rand.Read(b[:]); err != nil {
return "conn"
}
return hex.EncodeToString(b[:])
}
+328
View File
@@ -0,0 +1,328 @@
package web
import (
"bufio"
"encoding/json"
"os"
"strings"
"time"
)
// Audit-log query support for the admin-only "Audit Log" page.
//
// The server reads its own JSON-lines audit file (logs/audit.log) back to the
// browser. Records are already redacted at write time (see logger.AuditAt), so
// returning raw lines is safe as long as we never widen what is written.
//
// Only the CURRENT audit file is searched. Rotated files (audit-*.log[.gz])
// are deliberately out of scope: they need gzip support and a separate index,
// and the UI is meant for recent-window triage ("what happened just now").
const (
auditQueryDefaultLimit = 100
auditQueryMaxLimit = 500
auditQueryMaxOffset = 10000
// auditQueryScanBudget bounds how much of the audit file a single query
// reads. The tail is read when the file is larger, so the newest records
// (what operators actually triage) are always covered and a multi-GB log
// cannot stall the WS goroutine.
auditQueryScanBudget = 32 << 20 // 32 MB
)
// auditQueryRequest is the JSON body of the `query_audit` command.
type auditQueryRequest struct {
From string `json:"from"` // RFC3339 ("2026-10-18T10:00:00Z"), optional
To string `json:"to"` // RFC3339, optional
Event string `json:"event"` // exact ("web.login.failure"), prefix ("web." / "web.*"), or ""
Actor string `json:"actor"` // exact, case-insensitive
IP string `json:"ip"` // exact
DeviceID string `json:"device_id"`
Keyword string `json:"keyword"` // case-insensitive substring of the raw record
WarnOnly bool `json:"warn_only"` // only level=warn records
Limit int `json:"limit"`
Offset int `json:"offset"` // skip the newest N matching records
}
// auditQueryResult is the internal result of scanning the audit file.
type auditQueryResult struct {
Events []map[string]any
Total int // matching records in the scanned window
FileExists bool
FileSize int64
ScannedBytes int64
Truncated bool // the file was larger than auditQueryScanBudget
}
// handleQueryAudit implements the admin-only audit query command. The reply is
// `{cmd:"audit_result", ok, events, total, ...}`; events are newest-first.
func (h *wsHub) handleQueryAudit(c *wsClient, raw []byte) {
const replyCmd = "audit_result"
if !h.requireAdmin(c, raw, replyCmd) {
return
}
var in auditQueryRequest
if err := json.Unmarshal(raw, &in); err != nil {
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": "Invalid request"}))
return
}
in.Limit = clampAuditLimit(in.Limit)
in.Offset = clampAuditOffset(in.Offset)
path := h.log.AuditPath()
if path == "" {
h.audit(c, true, auditQuery, map[string]any{
"ok": false,
"reason": "audit_log_disabled",
})
c.queue(mustJSON(map[string]any{
"cmd": replyCmd, "ok": false,
"msg": "Audit log file is disabled on this server (YAMA_AUDIT_LOG=off)",
}))
return
}
res, err := queryAuditLog(path, in)
if err != nil {
h.audit(c, true, auditQuery, map[string]any{
"ok": false,
"reason": "read_failed",
"error": err.Error(),
})
c.queue(mustJSON(map[string]any{
"cmd": replyCmd, "ok": false,
"msg": "Failed to read audit log: " + err.Error(),
}))
return
}
// Record the query itself: an audit viewer is privileged, and reading the
// trail is itself an auditable action. Filter terms are included so an
// investigator can see what was searched for.
h.audit(c, false, auditQuery, map[string]any{
"ok": true,
"event_filter": in.Event,
"actor_filter": in.Actor,
"ip_filter": in.IP,
"device_filter": in.DeviceID,
"keyword": in.Keyword,
"warn_only": in.WarnOnly,
"matches": res.Total,
"returned": len(res.Events),
"offset": in.Offset,
})
c.queue(mustJSON(map[string]any{
"cmd": replyCmd,
"ok": true,
"events": res.Events,
"total": res.Total,
"offset": in.Offset,
"limit": in.Limit,
"file": path,
"file_exists": res.FileExists,
"file_size": res.FileSize,
"scanned_bytes": res.ScannedBytes,
"truncated": res.Truncated,
}))
}
func clampAuditLimit(n int) int {
if n <= 0 {
return auditQueryDefaultLimit
}
if n > auditQueryMaxLimit {
return auditQueryMaxLimit
}
return n
}
func clampAuditOffset(n int) int {
if n < 0 {
return 0
}
if n > auditQueryMaxOffset {
return auditQueryMaxOffset
}
return n
}
// queryAuditLog scans path for audit records matching q. The returned events
// are the newest limit records after skipping offset, in newest-first order.
func queryAuditLog(path string, q auditQueryRequest) (auditQueryResult, error) {
res := auditQueryResult{Events: []map[string]any{}}
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
// No audit record has been written yet (lumberjack creates the file
// lazily). An empty result is correct, not an error.
return res, nil
}
return res, err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return res, err
}
res.FileExists = true
res.FileSize = info.Size()
// For very large files, scan only the tail; the newest matches live there.
start := int64(0)
if info.Size() > auditQueryScanBudget {
start = info.Size() - auditQueryScanBudget
if _, err := f.Seek(start, 0); err != nil {
return res, err
}
res.Truncated = true
}
from, hasFrom := parseAuditTime(q.From)
to, hasTo := parseAuditTime(q.To)
keep := q.Offset + q.Limit
ring := make([]map[string]any, keep)
filled := 0
sc := bufio.NewScanner(f)
// Sanitized values are capped at 4096 bytes each; a record with many
// fields stays well under 1 MB. Allow headroom rather than aborting the
// whole query on one long line.
sc.Buffer(make([]byte, 64*1024), 4*1024*1024)
first := true
for sc.Scan() {
line := sc.Text()
if start > 0 && first {
// Seeking into the middle of the file lands mid-line. Drop that
// partial record.
first = false
continue
}
first = false
res.ScannedBytes += int64(len(line)) + 1
line = strings.TrimSpace(line)
if line == "" {
continue
}
var rec map[string]any
if err := json.Unmarshal([]byte(line), &rec); err != nil {
continue // tolerate a torn line instead of failing the page
}
if _, ok := rec["event"].(string); !ok {
continue
}
// Only audit records belong on the page. The dedicated file contains
// nothing else, but a hand-edited or mixed file must not surface
// ordinary operational lines here.
if rec["audit"] != true {
continue
}
if !auditRecordMatches(rec, line, q, from, hasFrom, to, hasTo) {
continue
}
// Ring buffer of the last `keep` matches (file order is chronological).
ring[res.Total%keep] = rec
if filled < keep {
filled++
}
res.Total++
}
if err := sc.Err(); err != nil {
return res, err
}
// Newest first, skipping Offset. The match with 0-based index i lives at
// ring[i%keep] as long as i >= Total-filled.
for i := res.Total - 1 - q.Offset; i >= 0 && i >= res.Total-filled; i-- {
res.Events = append(res.Events, ring[i%keep])
if len(res.Events) >= q.Limit {
break
}
}
return res, nil
}
// auditRecordMatches applies the non-time and time filters to one record.
// line is the raw JSON line, used for the keyword substring search.
func auditRecordMatches(rec map[string]any, line string, q auditQueryRequest, from time.Time, hasFrom bool, to time.Time, hasTo bool) bool {
if q.Event != "" && !auditEventFilterMatches(q.Event, auditStringField(rec, "event")) {
return false
}
if q.Actor != "" && !strings.EqualFold(auditStringField(rec, "actor"), q.Actor) {
return false
}
if q.IP != "" && auditStringField(rec, "ip") != q.IP {
return false
}
if q.DeviceID != "" && auditStringField(rec, "device_id") != q.DeviceID {
return false
}
if q.WarnOnly && auditStringField(rec, "level") != "warn" {
return false
}
if q.Keyword != "" && !strings.Contains(strings.ToLower(line), strings.ToLower(q.Keyword)) {
return false
}
if hasFrom || hasTo {
t, ok := parseAuditTime(auditStringField(rec, "time"))
if !ok {
return false // cannot prove it is in range
}
if hasFrom && t.Before(from) {
return false
}
if hasTo && t.After(to) {
return false
}
}
return true
}
// auditEventFilterMatches supports three forms so the UI stays simple:
//
// "web.login.failure" exact
// "web.*" prefix (trailing "*")
// "web." prefix (trailing ".")
func auditEventFilterMatches(filter, event string) bool {
switch {
case strings.HasSuffix(filter, "*"):
return strings.HasPrefix(event, strings.TrimSuffix(filter, "*"))
case strings.HasSuffix(filter, "."):
return strings.HasPrefix(event, filter)
default:
return event == filter
}
}
func auditStringField(rec map[string]any, key string) string {
s, _ := rec[key].(string)
return s
}
// parseAuditTime accepts the formats operators and browsers realistically send.
// Layouts without an explicit zone are parsed as UTC; the frontend always sends
// Date.toISOString(), so the RFC3339 path is what production uses.
func parseAuditTime(s string) (time.Time, bool) {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}, false
}
layouts := []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02T15:04:05",
"2006-01-02T15:04",
"2006-01-02 15:04:05",
"2006-01-02",
}
for _, layout := range layouts {
if t, err := time.Parse(layout, s); err == nil {
return t, true
}
}
return time.Time{}, false
}
+294
View File
@@ -0,0 +1,294 @@
package web
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
)
// newAuditTestHubWithoutAuditFile builds a hub whose logger has no dedicated
// audit file, mirroring a server started with YAMA_AUDIT_LOG=off.
func newAuditTestHubWithoutAuditFile(t *testing.T, dir string) (*wsHub, string) {
t.Helper()
lg := logger.New(logger.Config{
Level: logger.LevelInfo,
Console: false,
File: filepath.Join(dir, "server.log"),
})
t.Cleanup(func() { _ = lg.Close() })
wh := newWSHub(wsauth.New(), hub.New(), nil, lg)
t.Cleanup(wh.stop)
return wh, ""
}
func readQueuedJSON(t *testing.T, c *wsClient) map[string]any {
t.Helper()
select {
case m := <-c.send:
var v map[string]any
if err := json.Unmarshal(m.data, &v); err != nil {
t.Fatalf("queued reply is not JSON: %q: %v", m.data, err)
}
return v
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for a queued reply")
return nil
}
}
// writeAuditFixture writes JSON-lines audit records (oldest first) and returns
// the file path. Times are UTC RFC3339 so the tests are timezone-independent.
func writeAuditFixture(t *testing.T, records []map[string]any) string {
t.Helper()
path := filepath.Join(t.TempDir(), "audit.log")
var b strings.Builder
for _, rec := range records {
line, err := json.Marshal(rec)
if err != nil {
t.Fatalf("marshal fixture: %v", err)
}
b.Write(line)
b.WriteByte('\n')
}
if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
return path
}
func auditFixtureRecords() []map[string]any {
at := func(hhmm string) string {
tm, err := time.Parse("2006-01-02T15:04", "2026-10-18T"+hhmm)
if err != nil {
panic(err)
}
return tm.Format(time.RFC3339)
}
return []map[string]any{
{"level": "info", "time": at("10:00"), "audit": true, "event": "web.login.success", "actor": "admin", "ip": "203.0.113.1"},
{"level": "warn", "time": at("10:01"), "audit": true, "event": "web.login.failure", "actor": "alice", "ip": "203.0.113.2", "reason": "invalid_credentials"},
{"level": "info", "time": at("10:02"), "audit": true, "event": "remote.screen.start", "actor": "admin", "role": "admin", "ip": "203.0.113.1", "device_id": "dev1"},
{"level": "info", "time": at("10:03"), "audit": true, "event": "admin.user.delete", "actor": "admin", "ip": "203.0.113.1", "target": "bob"},
}
}
func runAuditQuery(t *testing.T, path string, q auditQueryRequest) auditQueryResult {
t.Helper()
q.Limit = clampAuditLimit(q.Limit)
q.Offset = clampAuditOffset(q.Offset)
res, err := queryAuditLog(path, q)
if err != nil {
t.Fatalf("queryAuditLog: %v", err)
}
return res
}
func eventNames(res auditQueryResult) []string {
out := make([]string, 0, len(res.Events))
for _, e := range res.Events {
out = append(out, auditStringField(e, "event"))
}
return out
}
func TestAuditQueryNoFilterNewestFirst(t *testing.T) {
path := writeAuditFixture(t, auditFixtureRecords())
res := runAuditQuery(t, path, auditQueryRequest{Limit: 100})
if res.Total != 4 || len(res.Events) != 4 {
t.Fatalf("total=%d events=%d, want 4/4", res.Total, len(res.Events))
}
got := eventNames(res)
want := []string{"admin.user.delete", "remote.screen.start", "web.login.failure", "web.login.success"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("events = %v, want %v", got, want)
}
}
func TestAuditQueryEventFilters(t *testing.T) {
path := writeAuditFixture(t, auditFixtureRecords())
cases := []struct {
filter string
want []string
}{
{"web.", []string{"web.login.failure", "web.login.success"}},
{"web.*", []string{"web.login.failure", "web.login.success"}},
{"web.login.failure", []string{"web.login.failure"}},
{"remote.*", []string{"remote.screen.start"}},
}
for _, tc := range cases {
res := runAuditQuery(t, path, auditQueryRequest{Event: tc.filter, Limit: 100})
got := eventNames(res)
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
t.Errorf("filter %q → %v, want %v", tc.filter, got, tc.want)
}
}
}
func TestAuditQueryFieldFilters(t *testing.T) {
path := writeAuditFixture(t, auditFixtureRecords())
from, _ := time.Parse(time.RFC3339, "2026-10-18T10:01:00Z")
to, _ := time.Parse(time.RFC3339, "2026-10-18T10:02:00Z")
cases := []struct {
name string
q auditQueryRequest
want []string
}{
{"actor case-insensitive", auditQueryRequest{Actor: "ALICE", Limit: 100}, []string{"web.login.failure"}},
{"ip", auditQueryRequest{IP: "203.0.113.2", Limit: 100}, []string{"web.login.failure"}},
{"device", auditQueryRequest{DeviceID: "dev1", Limit: 100}, []string{"remote.screen.start"}},
{"warn only", auditQueryRequest{WarnOnly: true, Limit: 100}, []string{"web.login.failure"}},
{"keyword target", auditQueryRequest{Keyword: "BOB", Limit: 100}, []string{"admin.user.delete"}},
{"time range", auditQueryRequest{
From: from.Format(time.RFC3339), To: to.Format(time.RFC3339), Limit: 100,
}, []string{"remote.screen.start", "web.login.failure"}},
}
for _, tc := range cases {
res := runAuditQuery(t, path, tc.q)
got := eventNames(res)
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
t.Errorf("%s → %v, want %v", tc.name, got, tc.want)
}
}
}
func TestAuditQueryOffsetAndLimit(t *testing.T) {
path := writeAuditFixture(t, auditFixtureRecords())
res := runAuditQuery(t, path, auditQueryRequest{Limit: 2, Offset: 1})
if res.Total != 4 {
t.Fatalf("total = %d, want 4", res.Total)
}
got := eventNames(res)
want := []string{"remote.screen.start", "web.login.failure"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("events = %v, want %v", got, want)
}
// Offsetting past the end yields no events but keeps the total.
res = runAuditQuery(t, path, auditQueryRequest{Limit: 2, Offset: 4})
if res.Total != 4 || len(res.Events) != 0 {
t.Fatalf("offset past end: total=%d events=%v", res.Total, eventNames(res))
}
}
func TestAuditQuerySkipsNonAuditAndTornLines(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.log")
content := `{"event":"device.online","actor":"x"}
not json at all
{"level":"info","time":"2026-10-18T10:00:00Z","audit":true,"event":"web.login.success","actor":"admin"}`
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
res := runAuditQuery(t, path, auditQueryRequest{Limit: 100})
if res.Total != 1 || len(res.Events) != 1 {
t.Fatalf("total=%d events=%v, want the single valid record", res.Total, eventNames(res))
}
}
func TestAuditQueryMissingFile(t *testing.T) {
res := runAuditQuery(t, filepath.Join(t.TempDir(), "does-not-exist.log"), auditQueryRequest{Limit: 10})
if res.FileExists {
t.Error("FileExists = true for a missing file")
}
if res.Total != 0 || len(res.Events) != 0 {
t.Errorf("total=%d events=%v, want empty", res.Total, eventNames(res))
}
if res.Events == nil {
t.Error("Events is nil; JSON would encode null instead of []")
}
}
func TestAuditQueryClampsPaging(t *testing.T) {
if got := clampAuditLimit(0); got != auditQueryDefaultLimit {
t.Errorf("limit(0) = %d, want default %d", got, auditQueryDefaultLimit)
}
if got := clampAuditLimit(-3); got != auditQueryDefaultLimit {
t.Errorf("limit(-3) = %d, want default %d", got, auditQueryDefaultLimit)
}
if got := clampAuditLimit(9999); got != auditQueryMaxLimit {
t.Errorf("limit(9999) = %d, want max %d", got, auditQueryMaxLimit)
}
if got := clampAuditLimit(50); got != 50 {
t.Errorf("limit(50) = %d, want 50", got)
}
if got := clampAuditOffset(-5); got != 0 {
t.Errorf("offset(-5) = %d, want 0", got)
}
if got := clampAuditOffset(1 << 30); got != auditQueryMaxOffset {
t.Errorf("offset(huge) = %d, want max %d", got, auditQueryMaxOffset)
}
}
func TestQueryAuditHandlerAdminOnly(t *testing.T) {
wh, _, _ := auditTestHub(t)
viewer, tok := auditTestClient(wh, "viewer")
raw, _ := json.Marshal(map[string]any{"token": tok})
wh.handleQueryAudit(viewer, raw)
msg := readQueuedJSON(t, viewer)
if msg["cmd"] != "audit_result" || msg["ok"] != false {
t.Fatalf("viewer reply = %+v", msg)
}
if _, ok := msg["events"]; ok {
t.Error("denied viewer got events")
}
}
func TestQueryAuditHandlerReturnsEvents(t *testing.T) {
wh, _, _ := auditTestHub(t)
// Two records written directly through the same logger the hub queries.
wh.log.Audit("web.login.success", map[string]any{"actor": "admin", "ip": "203.0.113.1"})
wh.log.Audit("remote.screen.start", map[string]any{"actor": "admin", "device_id": "dev1"})
admin, tok := auditTestClient(wh, "admin")
raw, _ := json.Marshal(map[string]any{"token": tok, "limit": 10})
wh.handleQueryAudit(admin, raw)
msg := readQueuedJSON(t, admin)
if msg["cmd"] != "audit_result" || msg["ok"] != true {
t.Fatalf("reply = %+v", msg)
}
events, ok := msg["events"].([]any)
if !ok || len(events) < 2 {
t.Fatalf("events = %#v, want at least the two written records", msg["events"])
}
// requireAuth writes a web.session.resume record for this same request, so
// that one legitimately appears newer. Assert the two written records are
// present and still newest-first relative to each other.
var names []string
for _, e := range events {
if m, ok := e.(map[string]any); ok {
names = append(names, auditStringField(m, "event"))
}
}
joined := strings.Join(names, ",")
if !strings.Contains(joined, "remote.screen.start") || !strings.Contains(joined, "web.login.success") {
t.Fatalf("events = %v, want both written records", names)
}
if strings.Index(joined, "remote.screen.start") > strings.Index(joined, "web.login.success") {
t.Fatalf("events = %v, want newest-first order", names)
}
if msg["truncated"] != false || msg["file_exists"] != true {
t.Errorf("meta = %+v", msg)
}
}
func TestQueryAuditHandlerDisabledFile(t *testing.T) {
dir := t.TempDir()
wh, _ := newAuditTestHubWithoutAuditFile(t, dir)
admin, tok := auditTestClient(wh, "admin")
raw, _ := json.Marshal(map[string]any{"token": tok})
wh.handleQueryAudit(admin, raw)
msg := readQueuedJSON(t, admin)
if msg["ok"] != false {
t.Fatalf("reply = %+v, want ok:false when the audit file is disabled", msg)
}
}
+195
View File
@@ -0,0 +1,195 @@
package web
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"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/wsauth"
)
// auditTestHub builds a wsHub whose logger writes both the main log and the
// dedicated audit log to a temp directory.
func auditTestHub(t *testing.T) (wh *wsHub, devices *hub.Hub, auditPath string) {
t.Helper()
dir := t.TempDir()
auditPath = filepath.Join(dir, "audit.log")
lg := logger.New(logger.Config{
Level: logger.LevelInfo,
Console: false,
File: filepath.Join(dir, "server.log"),
AuditFile: auditPath,
})
t.Cleanup(func() { _ = lg.Close() })
devices = hub.New()
wh = newWSHub(wsauth.New(), devices, nil, lg)
t.Cleanup(wh.stop)
return wh, devices, auditPath
}
func auditTestClient(wh *wsHub, role string) (*wsClient, string) {
tok, err := wh.auth.IssueToken("tester", role)
if err != nil {
panic(err)
}
c := &wsClient{
send: make(chan wsMsg, 64),
closed: make(chan struct{}),
id: newConnID(),
addr: "203.0.113.5",
peerAddr: "203.0.113.5",
connectedAt: time.Now(),
watchIDs: make(map[string]struct{}),
watchStartedAt: make(map[string]time.Time),
keyframeAt: make(map[string]int64),
}
wh.register(c)
return c, tok
}
func readAuditEvents(t *testing.T, path string) []map[string]any {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatalf("open audit log: %v", err)
}
defer f.Close()
var out []map[string]any
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
var m map[string]any
if err := json.Unmarshal([]byte(line), &m); err != nil {
t.Fatalf("bad audit line %q: %v", line, err)
}
out = append(out, m)
}
if err := sc.Err(); err != nil {
t.Fatalf("scan audit log: %v", err)
}
return out
}
func findAuditEvent(t *testing.T, path, event string) map[string]any {
t.Helper()
for _, m := range readAuditEvents(t, path) {
if m["event"] == event {
return m
}
}
t.Fatalf("audit event %q not found in %v", event, readAuditEvents(t, path))
return nil
}
func TestLoginFailureIsAudited(t *testing.T) {
wh, _, auditPath := auditTestHub(t)
wh.auth.AddAdminFromPlainPassword("admin", "hunter2")
c, _ := auditTestClient(wh, "viewer")
nonce, err := wsauth.NewNonce()
if err != nil {
t.Fatalf("NewNonce: %v", err)
}
c.nonce = nonce
raw, _ := json.Marshal(map[string]any{
"username": "admin",
"response": "not-the-right-response",
"nonce": nonce,
})
wh.handleLogin(c, raw)
rec := findAuditEvent(t, auditPath, auditLoginFail)
if rec["reason"] != "invalid_credentials" {
t.Errorf("reason = %v", rec["reason"])
}
if rec["username"] != "admin" {
t.Errorf("username = %v", rec["username"])
}
if rec["user_exists"] != true {
t.Errorf("user_exists = %v, want true", rec["user_exists"])
}
if rec["ip"] != "203.0.113.5" {
t.Errorf("ip = %v", rec["ip"])
}
if _, ok := rec["response"]; ok {
t.Error("audit record leaked the login response")
}
}
func TestAdminDenialIsAudited(t *testing.T) {
wh, _, auditPath := auditTestHub(t)
c, tok := auditTestClient(wh, "viewer")
raw, _ := json.Marshal(map[string]any{"token": tok})
if wh.requireAdmin(c, raw, "delete_device_result") {
t.Fatal("viewer passed requireAdmin")
}
rec := findAuditEvent(t, auditPath, auditDenied)
if rec["operation"] != "delete_device" {
t.Errorf("operation = %v", rec["operation"])
}
if rec["role"] != "viewer" || rec["actor"] != "tester" {
t.Errorf("identity fields = %+v", rec)
}
}
func TestScreenStartAndOfflineStopAreAudited(t *testing.T) {
wh, devices, auditPath := auditTestHub(t)
devices.SetSender(func(*connection.Context, []byte) error { return nil })
hctx := &connection.Context{RemoteAddr: "10.0.0.9:1234"}
devices.Register(&hub.Device{ID: "dev1", Name: "Dev"}, hctx)
c, tok := auditTestClient(wh, "admin")
raw, _ := json.Marshal(map[string]any{
"token": tok, "id": "dev1", "multiWatch": true, "req": "1",
})
wh.handleConnect(c, raw)
start := findAuditEvent(t, auditPath, auditScreenStart)
if start["device_id"] != "dev1" || start["mode"] != "started" {
t.Errorf("screen start = %+v", start)
}
wh.OnDeviceOffline("dev1")
stop := findAuditEvent(t, auditPath, auditScreenStop)
if stop["device_id"] != "dev1" || stop["reason"] != "device_offline" {
t.Errorf("screen stop = %+v", stop)
}
if _, ok := stop["duration_ms"]; !ok {
t.Error("screen stop has no duration_ms")
}
}
func TestUnknownCommandRecordedOnce(t *testing.T) {
wh, _, auditPath := auditTestHub(t)
c, _ := auditTestClient(wh, "viewer")
for i := 0; i < 5; i++ {
wh.dispatch(c, "definitely_not_a_command", []byte(`{}`))
}
count := 0
for _, m := range readAuditEvents(t, auditPath) {
if m["event"] == auditUnknownCmd {
count++
}
}
if count != 1 {
t.Fatalf("unknown-command audit records = %d, want 1", count)
}
if c.statUnknownCmd != 5 {
t.Fatalf("statUnknownCmd = %d, want 5", c.statUnknownCmd)
}
}
+131
View File
@@ -0,0 +1,131 @@
package web
import (
"encoding/json"
"path/filepath"
"testing"
"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/logger"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
)
// deleteTestHub builds the minimal wsHub wiring handleDeleteDevice needs.
func deleteTestHub(t *testing.T) (*wsHub, *hub.Hub, *devicehistory.Store) {
t.Helper()
h := hub.New()
hist, err := devicehistory.Open(filepath.Join(t.TempDir(), "devices.json"), 100, nil)
if err != nil {
t.Fatalf("Open history: %v", err)
}
t.Cleanup(func() { _ = hist.Close() })
h.Subscribe(hist)
auth := wsauth.New()
wh := &wsHub{
auth: auth,
devices: h,
history: hist,
log: logger.New(logger.Config{}),
clients: make(map[*wsClient]struct{}),
}
return wh, h, hist
}
func deleteTestClient(wh *wsHub, role string) (*wsClient, string) {
tok, err := wh.auth.IssueToken("tester", role)
if err != nil {
panic(err)
}
c := &wsClient{send: make(chan wsMsg, 8), closed: make(chan struct{})}
wh.register(c)
return c, tok
}
func deleteReply(t *testing.T, c *wsClient) map[string]any {
t.Helper()
for {
select {
case m := <-c.send:
var out map[string]any
if err := json.Unmarshal(m.data, &out); err != nil {
t.Fatalf("decode reply: %v", err)
}
if out["cmd"] == "delete_device_result" {
return out
}
default:
t.Fatal("no delete_device_result reply")
return nil
}
}
}
func TestDeleteDeviceOfflineRemovesHistoryRecord(t *testing.T) {
wh, h, hist := deleteTestHub(t)
h.Register(&hub.Device{ID: "gone", Name: "Gone"},
&connection.Context{RemoteAddr: "10.0.0.1:1000"})
h.Unregister("gone")
c, tok := deleteTestClient(wh, "admin")
raw, _ := json.Marshal(map[string]any{"token": tok, "id": "gone"})
wh.handleDeleteDevice(c, raw)
rep := deleteReply(t, c)
if rep["ok"] != true || rep["mode"] != "removed" {
t.Fatalf("reply = %+v, want ok/removed", rep)
}
if hist.Remove("gone") {
t.Fatal("history record still present after delete")
}
}
func TestDeleteDeviceOnlineSendsUninstall(t *testing.T) {
wh, h, hist := deleteTestHub(t)
var sent []byte
h.SetSender(func(_ *connection.Context, data []byte) error {
sent = append([]byte(nil), data...)
return nil
})
h.Register(&hub.Device{ID: "live", Name: "Live"},
&connection.Context{RemoteAddr: "10.0.0.2:1000"})
c, tok := deleteTestClient(wh, "admin")
raw, _ := json.Marshal(map[string]any{"token": tok, "id": "live"})
wh.handleDeleteDevice(c, raw)
rep := deleteReply(t, c)
if rep["ok"] != true || rep["mode"] != "uninstall" {
t.Fatalf("reply = %+v, want ok/uninstall", rep)
}
if len(sent) != 1 || sent[0] != protocol.CommandUninstall {
t.Fatalf("sent = %v, want single %d byte", sent, protocol.CommandUninstall)
}
// The history record is dropped along with the uninstall, so the device
// does not reappear as a dead offline entry after it disconnects.
if hist.Remove("live") {
t.Fatal("online device's history record survived the uninstall")
}
}
func TestDeleteDeviceRequiresAdmin(t *testing.T) {
wh, h, hist := deleteTestHub(t)
h.Register(&hub.Device{ID: "gone", Name: "Gone"},
&connection.Context{RemoteAddr: "10.0.0.3:1000"})
h.Unregister("gone")
c, tok := deleteTestClient(wh, "viewer")
raw, _ := json.Marshal(map[string]any{"token": tok, "id": "gone"})
wh.handleDeleteDevice(c, raw)
rep := deleteReply(t, c)
if rep["ok"] != false {
t.Fatalf("reply = %+v, want ok:false", rep)
}
if !hist.Remove("gone") {
t.Fatal("viewer delete removed the history record")
}
}
+53
View File
@@ -0,0 +1,53 @@
package web
import (
"path/filepath"
"testing"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/devicehistory"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
)
func TestListDevicesWithOffline(t *testing.T) {
h := hub.New()
hist, err := devicehistory.Open(filepath.Join(t.TempDir(), "devices.json"), 100, nil)
if err != nil {
t.Fatalf("Open history: %v", err)
}
t.Cleanup(func() { _ = hist.Close() })
h.Subscribe(hist)
h.Register(&hub.Device{ID: "online", Name: "Online"},
&connection.Context{RemoteAddr: "10.0.0.1:1000"})
h.Register(&hub.Device{ID: "gone", Name: "Gone"},
&connection.Context{RemoteAddr: "10.0.0.2:1000"})
h.Unregister("gone")
wh := &wsHub{devices: h, history: hist}
got := wh.listDevicesWithOffline()
if len(got) != 2 {
t.Fatalf("devices = %d, want 2 (1 online + 1 offline)", len(got))
}
if got[0].ID != "online" || !got[0].Online {
t.Fatalf("first = %+v, want online device", got[0])
}
if got[1].ID != "gone" || got[1].Online {
t.Fatalf("second = %+v, want offline device", got[1])
}
if got[1].LastSeen == 0 {
t.Fatal("offline device missing last_seen")
}
}
func TestListDevicesWithoutHistoryIsOnlineOnly(t *testing.T) {
h := hub.New()
h.Register(&hub.Device{ID: "online", Name: "Online"},
&connection.Context{RemoteAddr: "10.0.0.1:1000"})
wh := &wsHub{devices: h}
got := wh.listDevicesWithOffline()
if len(got) != 1 || got[0].ID != "online" {
t.Fatalf("devices = %+v, want just the online device", got)
}
}
+161 -10
View File
@@ -6,12 +6,17 @@ import (
"encoding/json"
"errors"
"fmt"
"html"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/devicehistory"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
@@ -26,12 +31,17 @@ type Server struct {
srv *http.Server
hub *hub.Hub
auth *wsauth.Authenticator
history *devicehistory.Store // nil → no offline devices
ws *wsHub
allowedOrigins []string // for WS Origin allowlist; empty = same-origin only
loginIPLimit *wsauth.RateLimiter
loginUserLimit *wsauth.RateLimiter
trustForwardedFor bool // honor X-Forwarded-For (behind trusted proxy only)
basePath string // web UI mount prefix (e.g. "/remoter"); "" = domain root
brandName string // white-label title; "" = "SimpleRemoter" (official branding)
sso SSODetector // nil = SSO disabled
sweepDone chan struct{} // closes to stop the login-limiter sweeper
sweepStopOnce sync.Once
}
// Config tunes the server's exposed-on-public-HTTPS hardening knobs.
@@ -60,6 +70,15 @@ type Config struct {
// https://example.com/remoter). Must be empty or a leading-slash,
// no-trailing-slash path; "" (default) serves from the domain root.
BasePath string
// BrandName is the white-label product title shown in index.html and the
// PWA manifest when BasePath is non-empty (i.e. mounted under another
// domain). Read from the YAMA_WEB_BRAND_NAME env var by cmd. Empty leaves
// the official "SimpleRemoter" branding in place.
BrandName string
// SSO is an optional detector that maps an already-authenticated portal
// session (cookie/header) into a YAMA web session via /sso. nil disables
// SSO and the front-end falls back to the built-in login form.
SSO SSODetector
}
// New creates an HTTP server bound to the given port. port=0 disables the server.
@@ -69,6 +88,14 @@ func New(port int, log *logger.Logger, h *hub.Hub, auth *wsauth.Authenticator) *
return &Server{port: port, log: log, hub: h, auth: auth}
}
// WithDeviceHistory attaches the persistent device-history store. When set,
// device_list also returns devices that have logged in before but are offline
// now. Passing nil keeps the online-only behaviour. Safe to call before Start.
func (s *Server) WithDeviceHistory(h *devicehistory.Store) *Server {
s.history = h
return s
}
// WithConfig applies hardening configuration. Returns the receiver for
// chainable setup. Safe to call before Start; ignored thereafter.
func (s *Server) WithConfig(cfg Config) *Server {
@@ -77,6 +104,8 @@ func (s *Server) WithConfig(cfg Config) *Server {
s.loginUserLimit = cfg.LoginUserLimit
s.trustForwardedFor = cfg.TrustForwardedFor
s.basePath = cfg.BasePath
s.brandName = resolveBrandName(cfg.BrandName, s.basePath)
s.sso = cfg.SSO
return s
}
@@ -88,7 +117,7 @@ func (s *Server) Start() error {
return nil
}
s.ws = newWSHub(s.auth, s.hub, s.log).
s.ws = newWSHub(s.auth, s.hub, s.history, s.log).
withOriginAllowlist(s.allowedOrigins).
withLoginRateLimiters(s.loginIPLimit, s.loginUserLimit).
withTrustForwardedFor(s.trustForwardedFor)
@@ -98,6 +127,7 @@ func (s *Server) Start() error {
mux.HandleFunc("/health", s.handleHealth)
mux.HandleFunc("/manifest.json", s.handleManifest)
mux.HandleFunc("/api/devices", s.requireBearer(s.handleDevices))
mux.HandleFunc("/sso", s.handleSSO)
mux.HandleFunc("/ws", s.ws.serve)
mux.HandleFunc("/static/xterm.js", staticHandler(xtermJS, "application/javascript; charset=utf-8"))
mux.HandleFunc("/static/xterm.css", staticHandler(xtermCSS, "text/css; charset=utf-8"))
@@ -123,11 +153,40 @@ func (s *Server) Start() error {
s.log.Error("HTTP server stopped: %v", err)
}
}()
// Prune aged-out login rate-limit entries so the key maps (especially the
// username-keyed one, which is attacker-controlled) stay bounded.
s.sweepDone = make(chan struct{})
go s.sweepLoginLimiters(s.sweepDone)
return nil
}
// sweepLoginLimiters periodically sweeps both web login rate limiters. Runs
// until `stop` is closed (from Stop).
func (s *Server) sweepLoginLimiters(stop <-chan struct{}) {
t := time.NewTicker(time.Minute)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
if s.loginIPLimit != nil {
s.loginIPLimit.Sweep()
}
if s.loginUserLimit != nil {
s.loginUserLimit.Sweep()
}
}
}
}
// Stop gracefully shuts the server down.
func (s *Server) Stop() {
if s.sweepDone != nil {
s.sweepStopOnce.Do(func() { close(s.sweepDone) })
}
if s.ws != nil {
s.ws.stop()
}
@@ -143,6 +202,10 @@ func (s *Server) Stop() {
// configured base path at serve time. Empty base path → "" → root URLs.
const basePathPlaceholder = "__BASE_PATH__"
// brandPlaceholder is the token in index.html substituted with the product
// title at serve time (official or white-label, depending on BasePath).
const brandPlaceholder = "__BRAND_NAME__"
// mount wraps the root-path mux with the configured base path. When basePath
// is empty the mux is returned unchanged (domain-root serving, 方案A). Otherwise
// the bare prefix redirects to prefix+"/", paths outside the prefix 404, and
@@ -171,6 +234,11 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
return
}
body := bytes.ReplaceAll(IndexHTML, []byte(basePathPlaceholder), []byte(s.basePath))
body = bytes.ReplaceAll(body, []byte(brandPlaceholder), []byte(html.EscapeString(s.displayName())))
if s.isWhiteLabel() {
body = stripBrandBlock(body, "NOTICE")
body = stripBrandBlock(body, "LINKS")
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(body)
@@ -234,15 +302,36 @@ func (s *Server) manifestJSON() []byte {
if s.basePath != "" {
start = s.basePath + "/"
}
return []byte(fmt.Sprintf(`{
"name": "SimpleRemoter",
"short_name": "Remoter",
"start_url": "%s",
"display": "standalone",
"orientation": "any",
"background_color": "#1a1a2e",
"theme_color": "#1a1a2e"
}`, start))
name := s.displayName()
shortName := "Remoter"
if s.isWhiteLabel() {
shortName = name
}
// Marshal via encoding/json so an operator-supplied brand name containing
// quotes or backslashes can't produce invalid JSON.
manifest := struct {
Name string `json:"name"`
ShortName string `json:"short_name"`
StartURL string `json:"start_url"`
Display string `json:"display"`
Orientation string `json:"orientation"`
BackgroundColor string `json:"background_color"`
ThemeColor string `json:"theme_color"`
}{
Name: name,
ShortName: shortName,
StartURL: start,
Display: "standalone",
Orientation: "any",
BackgroundColor: "#1a1a2e",
ThemeColor: "#1a1a2e",
}
b, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
// A struct of plain strings cannot fail to marshal.
return []byte(`{"start_url":"/","display":"standalone"}`)
}
return b
}
func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) {
@@ -251,6 +340,68 @@ func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(s.manifestJSON())
}
// displayName returns the product title shown in the UI: the configured brand
// name when set, otherwise the official "SimpleRemoter".
func (s *Server) displayName() string {
if s.brandName != "" {
return s.brandName
}
return "SimpleRemoter"
}
// isWhiteLabel reports whether the web UI is mounted under another domain's
// subpath (BasePath non-empty). In that case the official SimpleRemoter
// branding (security notice, website links) is stripped and the configured
// brand name is shown instead.
func (s *Server) isWhiteLabel() bool {
return s.basePath != ""
}
// resolveBrandName picks the effective white-label title: the configured value
// when non-empty, else a fallback derived from the base path's last segment
// (e.g. "/remoter" → "Remoter"). Empty base path means the UI is served at the
// domain root, so it returns "" (official "SimpleRemoter") and ignores any
// configured name — white-label branding only applies to subpath deployment.
func resolveBrandName(configured, basePath string) string {
if basePath == "" {
return ""
}
if configured != "" {
return configured
}
seg := strings.Trim(basePath, "/")
if i := strings.LastIndex(seg, "/"); i >= 0 {
seg = seg[i+1:]
}
return capitalizeFirst(seg)
}
// capitalizeFirst upper-cases the first rune, UTF-8 safe (no byte slicing).
func capitalizeFirst(s string) string {
if s == "" {
return s
}
r, size := utf8.DecodeRuneInString(s)
return string(unicode.ToUpper(r)) + s[size:]
}
// stripBrandBlock removes the index.html block delimited by
// <!--BRAND-<name>-START--> … <!--BRAND-<name>-END--> (markers included).
func stripBrandBlock(body []byte, name string) []byte {
start := []byte("<!--BRAND-" + name + "-START-->")
end := []byte("<!--BRAND-" + name + "-END-->")
i := bytes.Index(body, start)
if i < 0 {
return body
}
j := bytes.Index(body[i:], end)
if j < 0 {
return body
}
j += i + len(end)
return append(body[:i], body[j:]...)
}
// staticHandler returns an http.HandlerFunc that serves a fixed byte slice
// with the given content-type. Used for embedded third-party assets (xterm.js etc.)
// that change infrequently — 1-day browser cache is fine.
+84
View File
@@ -0,0 +1,84 @@
package web
import (
"encoding/json"
"net/http"
)
// SSODetector maps an incoming HTTP request from an already-authenticated
// portal session into a YAMA web identity (username + role). Implementations
// are portal-specific: each one knows how to recognize its portal's session
// credential (a cookie, a header, a JWT, ...) and translate the caller to
// YAMA's two roles, "admin" and "viewer".
//
// Detect returns ok=false when the request carries no usable credential, or
// the credential is invalid/expired; the caller then falls back to the
// built-in login form. username is used only for audit/logging. Whether a
// detected identity is actually bridged into a session is handleSSO's policy:
// currently only "admin" is bridged, everyone else falls back to the login form.
type SSODetector interface {
Detect(r *http.Request) (username, role string, ok bool)
}
// handleSSO bridges a detected portal identity into a YAMA session token.
// Only administrators are bridged: a valid portal session for any other role
// (viewer, unknown) still returns 401 and the front-end falls back to the
// built-in login form, so a low-privileged portal account cannot walk straight
// into the remote-desktop console. When no detector is configured, or the
// request is not recognized, it also returns 401.
func (s *Server) handleSSO(w http.ResponseWriter, r *http.Request) {
if s.sso == nil {
s.unauthorized(w)
return
}
username, role, ok := s.sso.Detect(r)
if !ok {
// No usable portal credential: the normal fallback to the login form.
// Not audited — portal users without YAMA access hit this routinely.
s.unauthorized(w)
return
}
// Only the "admin" role may skip the login form via SSO. Every other role
// (viewer, unknown) must authenticate against YAMA's own login, even with
// a valid portal session. A valid portal identity being refused here is
// worth an audit record: it is an authorization decision, not a login-form
// fallback.
if role != "admin" {
fields := requestFields(r, s.trustForwardedFor)
fields["method"] = "sso"
fields["actor"] = username
fields["role"] = role
fields["reason"] = "role_not_allowed"
s.log.AuditWarn(auditLoginFail, fields)
s.unauthorized(w)
return
}
token, err := s.auth.IssueToken(username, role)
if err != nil {
fields := requestFields(r, s.trustForwardedFor)
fields["method"] = "sso"
fields["actor"] = username
fields["role"] = role
fields["reason"] = "token_issue_failed"
fields["error"] = err.Error()
s.log.AuditWarn(auditLoginFail, fields)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"internal error"}`))
return
}
fields := requestFields(r, s.trustForwardedFor)
fields["method"] = "sso"
fields["actor"] = username
fields["role"] = role
s.log.Audit(auditLoginOK, fields)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]string{
"token": token,
"role": role,
"username": username,
})
}
+127
View File
@@ -0,0 +1,127 @@
package web
import (
"errors"
"net/http"
"strings"
"github.com/golang-jwt/jwt/v5"
)
// JWTDetector is an SSODetector for portals that hand the browser a signed
// JWT as an HttpOnly session cookie (or an Authorization: Bearer header). It
// verifies the token with a shared HMAC secret and maps the identity claims
// to a YAMA role. It is portal-agnostic: the secret, cookie name, claim keys
// and the role value that means "admin" are all supplied at construction, so
// the same detector serves any portal using this pattern.
type JWTDetector struct {
secret []byte
cookieName string
usernameClaim string
roleClaim string
adminRole string
}
// JWTDetectorConfig configures a JWTDetector. Secret is the shared HMAC key
// (required). The remaining fields have defaults for a typical portal:
// CookieName "auth_token", UsernameClaim "username", RoleClaim "rolename",
// AdminRole "admin".
type JWTDetectorConfig struct {
Secret string
CookieName string
UsernameClaim string
RoleClaim string
AdminRole string
}
// NewJWTDetector builds a JWTDetector, applying defaults for any unset
// optional field.
func NewJWTDetector(cfg JWTDetectorConfig) *JWTDetector {
if cfg.CookieName == "" {
cfg.CookieName = "auth_token"
}
if cfg.UsernameClaim == "" {
cfg.UsernameClaim = "username"
}
if cfg.RoleClaim == "" {
cfg.RoleClaim = "rolename"
}
if cfg.AdminRole == "" {
cfg.AdminRole = "admin"
}
return &JWTDetector{
secret: []byte(cfg.Secret),
cookieName: cfg.CookieName,
usernameClaim: cfg.UsernameClaim,
roleClaim: cfg.RoleClaim,
adminRole: cfg.AdminRole,
}
}
// Detect reads the JWT from the configured cookie (falling back to a Bearer
// header), verifies its signature and expiry, and maps the identity to a YAMA
// role. Only HMAC algorithms are accepted, rejecting algorithm-confusion
// attempts (alg=none / asymmetric algorithms).
func (d *JWTDetector) Detect(r *http.Request) (username, role string, ok bool) {
tokenString := d.extract(r)
if tokenString == "" {
return "", "", false
}
claims, ok := d.parse(tokenString)
if !ok {
return "", "", false
}
username, _ = claims[d.usernameClaim].(string)
if username == "" {
return "", "", false
}
rolename, _ := claims[d.roleClaim].(string)
if rolename == d.adminRole {
role = "admin"
} else {
role = "viewer"
}
return username, role, true
}
// extract returns the raw JWT from the configured cookie, or from a Bearer
// header as a fallback.
func (d *JWTDetector) extract(r *http.Request) string {
if d.cookieName != "" {
if ck, err := r.Cookie(d.cookieName); err == nil && ck.Value != "" {
return ck.Value
}
}
if hdr := r.Header.Get("Authorization"); strings.HasPrefix(hdr, "Bearer ") {
return strings.TrimSpace(strings.TrimPrefix(hdr, "Bearer "))
}
return ""
}
// parse verifies a JWT against the shared HMAC secret and returns its claims.
func (d *JWTDetector) parse(tokenString string) (jwt.MapClaims, bool) {
if len(d.secret) == 0 {
// Fail closed: an unset shared secret must never accept a token.
// A detector wired up with an empty secret is a misconfiguration, and
// accepting an empty-key-signed token here would be a trivial auth
// bypass. Rejecting everything means SSO silently falls back to login.
return nil, false
}
token, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("invalid signing algorithm")
}
return d.secret, nil
})
if err != nil || !token.Valid {
return nil, false
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, false
}
return claims, true
}
+180
View File
@@ -0,0 +1,180 @@
package web
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
)
const testSSOSecret = "test-shared-secret"
func signTestToken(t *testing.T, secret string, claims jwt.MapClaims) string {
t.Helper()
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
s, err := tok.SignedString([]byte(secret))
if err != nil {
t.Fatalf("sign test token: %v", err)
}
return s
}
// noneToken builds a base64url header/claims JWT with alg "none" and no
// signature, to exercise the algorithm-confusion rejection path.
func noneToken(claims jwt.MapClaims) string {
h, _ := json.Marshal(map[string]string{"alg": "none", "typ": "JWT"})
c, _ := json.Marshal(claims)
enc := base64.RawURLEncoding.EncodeToString
return strings.TrimRight(enc(h), "=") + "." + strings.TrimRight(enc(c), "=") + "."
}
func cookieReq(token string) *http.Request {
r := &http.Request{Header: http.Header{}}
r.AddCookie(&http.Cookie{Name: "auth_token", Value: token})
return r
}
func bearerReq(token string) *http.Request {
r := &http.Request{Header: http.Header{}}
r.Header.Set("Authorization", "Bearer "+token)
return r
}
func TestJWTDetector(t *testing.T) {
d := NewJWTDetector(JWTDetectorConfig{Secret: testSSOSecret})
valid := func(rolename string) jwt.MapClaims {
return jwt.MapClaims{
"username": "alice",
"rolename": rolename,
"exp": time.Now().Add(time.Hour).Unix(),
}
}
tests := []struct {
name string
req *http.Request
wantUser string
wantRole string
wantOK bool
}{
{"admin maps to admin", cookieReq(signTestToken(t, testSSOSecret, valid("admin"))), "alice", "admin", true},
{"viewer maps to viewer", cookieReq(signTestToken(t, testSSOSecret, valid("viewer"))), "alice", "viewer", true},
{"empty rolename maps to viewer", cookieReq(signTestToken(t, testSSOSecret, valid(""))), "alice", "viewer", true},
{"unknown rolename maps to viewer", cookieReq(signTestToken(t, testSSOSecret, valid("operator"))), "alice", "viewer", true},
{"bearer header accepted", bearerReq(signTestToken(t, testSSOSecret, valid("admin"))), "alice", "admin", true},
{"no credential", &http.Request{Header: http.Header{}}, "", "", false},
{"wrong secret rejected", cookieReq(signTestToken(t, "other-secret", valid("admin"))), "", "", false},
{"expired token rejected", cookieReq(signTestToken(t, testSSOSecret, jwt.MapClaims{
"username": "alice", "rolename": "admin", "exp": time.Now().Add(-time.Hour).Unix(),
})), "", "", false},
{"missing username rejected", cookieReq(signTestToken(t, testSSOSecret, jwt.MapClaims{
"rolename": "admin", "exp": time.Now().Add(time.Hour).Unix(),
})), "", "", false},
{"alg none rejected", cookieReq(noneToken(valid("admin"))), "", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
user, role, ok := d.Detect(tt.req)
if ok != tt.wantOK {
t.Fatalf("ok = %v, want %v (user=%q role=%q)", ok, tt.wantOK, user, role)
}
if !tt.wantOK {
return
}
if user != tt.wantUser || role != tt.wantRole {
t.Fatalf("got (%q, %q), want (%q, %q)", user, role, tt.wantUser, tt.wantRole)
}
})
}
}
// A detector with no shared secret must never accept a token, even one signed
// with an empty key — otherwise a misconfigured deployment would silently
// accept forged sessions.
func TestJWTDetectorEmptySecretFailClosed(t *testing.T) {
d := NewJWTDetector(JWTDetectorConfig{})
tok := signTestToken(t, "", jwt.MapClaims{
"username": "alice", "rolename": "admin", "exp": time.Now().Add(time.Hour).Unix(),
})
if _, _, ok := d.Detect(cookieReq(tok)); ok {
t.Fatal("empty-secret detector accepted a token; expected fail-closed")
}
}
// Non-default config keys must be honored, so a different portal can reuse the
// detector with no code changes.
func TestJWTDetectorCustomConfig(t *testing.T) {
d := NewJWTDetector(JWTDetectorConfig{
Secret: testSSOSecret,
CookieName: "session",
UsernameClaim: "sub",
RoleClaim: "role",
AdminRole: "root",
})
tok := signTestToken(t, testSSOSecret, jwt.MapClaims{
"sub": "bob", "role": "root", "exp": time.Now().Add(time.Hour).Unix(),
})
r := &http.Request{Header: http.Header{}}
r.AddCookie(&http.Cookie{Name: "session", Value: tok})
user, role, ok := d.Detect(r)
if !ok || user != "bob" || role != "admin" {
t.Fatalf("got (%q, %q, %v), want (%q, %q, true)", user, role, ok, "bob", "admin")
}
}
// fakeSSODetector lets the /sso handler's role policy be exercised without a
// real portal JWT.
type fakeSSODetector struct {
username, role string
ok bool
}
func (f *fakeSSODetector) Detect(*http.Request) (string, string, bool) {
return f.username, f.role, f.ok
}
// The /sso handler must only bridge administrators. A valid portal session for
// any other role — or no session at all — returns 401 so the front-end falls
// back to the built-in login form.
func TestHandleSSOAdminOnly(t *testing.T) {
newSrv := func(d SSODetector) *Server {
return &Server{
log: logger.New(logger.Config{}),
auth: wsauth.New(),
sso: d,
}
}
tests := []struct {
name string
detector SSODetector
wantStatus int
}{
{"admin is bridged", &fakeSSODetector{"alice", "admin", true}, http.StatusOK},
{"viewer is rejected", &fakeSSODetector{"bob", "viewer", true}, http.StatusUnauthorized},
{"unknown role is rejected", &fakeSSODetector{"carol", "operator", true}, http.StatusUnauthorized},
{"no credential is rejected", &fakeSSODetector{ok: false}, http.StatusUnauthorized},
{"SSO disabled", nil, http.StatusUnauthorized},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newSrv(tt.detector)
rec := httptest.NewRecorder()
s.handleSSO(rec, httptest.NewRequest(http.MethodPost, "/sso", nil))
if rec.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d (body=%s)", rec.Code, tt.wantStatus, rec.Body.String())
}
})
}
}
+96
View File
@@ -0,0 +1,96 @@
package web
import (
"fmt"
"reflect"
"sort"
"testing"
)
func sortedWatchIDs(c *wsClient) []string {
out := make([]string, 0, len(c.watchIDs))
for id := range c.watchIDs {
out = append(out, id)
}
sort.Strings(out)
return out
}
func TestApplyConnectWatchMulti(t *testing.T) {
c := &wsClient{watchIDs: make(map[string]struct{})}
if stopped, tooMany := applyConnectWatch(c, "a", true); len(stopped) != 0 || tooMany {
t.Fatalf("first connect = (%v, %v), want ([], false)", stopped, tooMany)
}
if stopped, tooMany := applyConnectWatch(c, "b", true); len(stopped) != 0 || tooMany {
t.Fatalf("second connect = (%v, %v), want ([], false)", stopped, tooMany)
}
if got, want := sortedWatchIDs(c), []string{"a", "b"}; !reflect.DeepEqual(got, want) {
t.Fatalf("watch set = %v, want %v", got, want)
}
// Repeated connect (focus switch) must be a no-op.
if stopped, tooMany := applyConnectWatch(c, "a", true); len(stopped) != 0 || tooMany {
t.Fatalf("repeat connect = (%v, %v), want ([], false)", stopped, tooMany)
}
if got, want := sortedWatchIDs(c), []string{"a", "b"}; !reflect.DeepEqual(got, want) {
t.Fatalf("watch set after repeat = %v, want %v", got, want)
}
}
func TestApplyConnectWatchLegacyReplaces(t *testing.T) {
c := &wsClient{watchIDs: map[string]struct{}{"a": {}, "b": {}}}
stopped, tooMany := applyConnectWatch(c, "c", false)
if tooMany {
t.Fatal("legacy connect hit the cap, want not")
}
sort.Strings(stopped)
if want := []string{"a", "b"}; !reflect.DeepEqual(stopped, want) {
t.Fatalf("stopped = %v, want %v", stopped, want)
}
if got, want := sortedWatchIDs(c), []string{"c"}; !reflect.DeepEqual(got, want) {
t.Fatalf("watch set = %v, want %v", got, want)
}
}
func TestApplyConnectWatchCap(t *testing.T) {
c := &wsClient{watchIDs: make(map[string]struct{})}
for i := 0; i < maxWatchPerClient; i++ {
applyConnectWatch(c, fmt.Sprintf("d%02d", i), true)
}
if _, tooMany := applyConnectWatch(c, "overflow", true); !tooMany {
t.Fatal("expected the cap to reject a new device")
}
if len(c.watchIDs) != maxWatchPerClient {
t.Fatalf("watch set size = %d, want %d", len(c.watchIDs), maxWatchPerClient)
}
// An already watched device must still be accepted at the cap.
if _, tooMany := applyConnectWatch(c, "d00", true); tooMany {
t.Fatal("repeat connect at the cap was rejected")
}
}
func TestResolveWatchDevice(t *testing.T) {
h := &wsHub{}
c := &wsClient{watchIDs: map[string]struct{}{"a": {}}}
if got := h.resolveWatchDevice(c, []byte(`{"id":"a"}`)); got != "a" {
t.Fatalf("explicit watched id = %q, want a", got)
}
if got := h.resolveWatchDevice(c, []byte(`{"id":"nope"}`)); got != "" {
t.Fatalf("explicit unwatched id = %q, want empty", got)
}
// Old frontend: no id, exactly one watched device.
if got := h.resolveWatchDevice(c, []byte(`{}`)); got != "a" {
t.Fatalf("no id, single device = %q, want a", got)
}
c.watchIDs["b"] = struct{}{}
if got := h.resolveWatchDevice(c, []byte(`{}`)); got != "" {
t.Fatalf("no id, two devices = %q, want empty", got)
}
if got := h.resolveWatchDevice(c, []byte(`{"id":"a"}`)); got != "a" {
t.Fatalf("explicit id, two devices = %q, want a", got)
}
}
+199 -21
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/gorilla/websocket"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/devicehistory"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
@@ -47,13 +48,50 @@ type wsClient struct {
closed chan struct{}
once sync.Once
// Immutable connection metadata (set once in serve).
id string // short random id for audit correlation
addr string // resolved client IP for logs / rate limits
peerAddr string // actual TCP peer (differs from addr behind a proxy)
origin string
userAgent string
connectedAt time.Time
// Mutated under wsHub.mu (or only by the read loop owning this client).
nonce string // outstanding challenge — cleared after a successful login
token string // set once authenticated
role string // mirrors session role after login
addr string // client address for logs
watching string // device ID this browser is currently streaming, "" when on the list
user string // mirrors session username after login/resume
termWatching string // device ID for an open web terminal session, "" otherwise
// watchIDs is the set of devices this browser is streaming (the screen
// wall watches several at once). Empty while on the device list.
// Guarded by wsHub.mu.
watchIDs map[string]struct{}
// watchStartedAt records when each watch began so stop events can carry a
// duration. Guarded by wsHub.mu.
watchStartedAt map[string]time.Time
// keyframeAt throttles keyframe requests to one per device per second.
// Unix seconds, guarded by wsHub.mu.
keyframeAt map[string]int64
// Terminal session bookkeeping. Guarded by wsHub.mu.
termStartedAt time.Time
termCloseReason string
// Audit counters, owned by the read-loop goroutine and read in unregister
// after that goroutine returns.
statCommands int
statInvalidToken int
statLoginFailures int
statAdminDenied int
statUnknownSalt int
statUnknownCmd int
auditInvalidTokenLogged bool
auditUnknownSaltLogged bool
auditUnknownCmdLogged bool
}
// queue writes a JSON text frame onto the send buffer. Drops silently if the
@@ -85,11 +123,30 @@ func (c *wsClient) close() {
})
}
// challengeCaps advertises optional protocol features. It is appended to every
// challenge, including the rotation after a failed login, because the browser
// overwrites its capability state on each challenge. Old frontends ignore it.
const challengeCaps = `,"caps":{"multiWatch":true,"keyframeRequest":true,"offlineDevices":true,"auditLog":true}`
// maxWatchPerClient bounds the number of devices one WebSocket may stream at
// once. The wall tops out at 25 tiles; the slack covers a focus switch racing
// a teardown. This keeps the per-frame broadcast loop cheap for a misbehaving
// or buggy client.
const maxWatchPerClient = 32
// isWatching reports whether this connection subscribed to deviceID.
// Callers must hold wsHub.mu (read or write).
func (c *wsClient) isWatching(deviceID string) bool {
_, ok := c.watchIDs[deviceID]
return ok
}
// ----- ws hub: registry of all connected browsers -------------------------
type wsHub struct {
auth *wsauth.Authenticator
devices *hub.Hub
history *devicehistory.Store // nil → device_list stays online-only
log *logger.Logger
mu sync.RWMutex
@@ -112,10 +169,11 @@ type wsHub struct {
trustForwardedFor bool // honor X-Forwarded-For (only when behind a trusted proxy)
}
func newWSHub(auth *wsauth.Authenticator, devices *hub.Hub, log *logger.Logger) *wsHub {
func newWSHub(auth *wsauth.Authenticator, devices *hub.Hub, history *devicehistory.Store, log *logger.Logger) *wsHub {
h := &wsHub{
auth: auth,
devices: devices,
history: history,
log: log,
clients: make(map[*wsClient]struct{}),
screenCloseTimers: make(map[string]*time.Timer),
@@ -253,12 +311,38 @@ func (h *wsHub) OnDeviceOffline(id string) {
"cmd": "device_offline",
"id": id,
})
h.mu.RLock()
defer h.mu.RUnlock()
// Drop the id from every watch set as well: the device is gone, so this
// connection no longer holds a subscription for it. The browser does the
// same with its local refcount. Without this, a stale entry would keep
// countWatchers non-zero and later block the screen teardown.
type viewer struct {
c *wsClient
start time.Time
}
var stopped []viewer
h.mu.Lock()
for c := range h.clients {
if c.watching == id && c.token != "" {
if !c.isWatching(id) {
continue
}
delete(c.watchIDs, id)
delete(c.keyframeAt, id)
if c.token != "" {
c.queue(msg)
}
if !c.watchStartedAt[id].IsZero() {
stopped = append(stopped, viewer{c, c.watchStartedAt[id]})
}
delete(c.watchStartedAt, id)
}
h.mu.Unlock()
// Audit outside h.mu: h.audit takes h.mu.RLock.
for _, v := range stopped {
h.audit(v.c, false, auditScreenStop, map[string]any{
"device_id": id,
"duration_ms": durationMs(v.start),
"reason": "device_offline",
})
}
}
@@ -269,12 +353,13 @@ func (h *wsHub) OnDeviceOffline(id string) {
func (h *wsHub) OnCursorChange(deviceID string, index byte) {
msg := mustJSON(map[string]any{
"cmd": "cursor",
"id": deviceID,
"index": index,
})
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.clients {
if c.watching == deviceID && c.token != "" {
if c.isWatching(deviceID) && c.token != "" {
c.queue(msg)
}
}
@@ -294,7 +379,24 @@ func (h *wsHub) OnResolutionChange(deviceID string, width, height int) {
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.clients {
if c.watching == deviceID && c.token != "" {
if c.isWatching(deviceID) && c.token != "" {
c.queue(msg)
}
}
}
// OnClipboardText relays a device's clipboard text back to every browser
// watching it (in response to a "clipboard_get" request).
func (h *wsHub) OnClipboardText(deviceID string, text string) {
msg := mustJSON(map[string]any{
"cmd": "clipboard_text",
"id": deviceID,
"text": text,
})
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.clients {
if c.isWatching(deviceID) && c.token != "" {
c.queue(msg)
}
}
@@ -308,7 +410,7 @@ func (h *wsHub) OnScreenFrame(deviceID string, packet []byte, _ bool) {
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.clients {
if c.watching == deviceID && c.token != "" {
if c.isWatching(deviceID) && c.token != "" {
c.queueBinary(packet)
}
}
@@ -359,14 +461,35 @@ func (h *wsHub) OnTerminalClosed(deviceID string, reason string) {
"ok": true,
"reason": reason,
})
type closed struct {
c *wsClient
start time.Time
why string
}
var done []closed
h.mu.Lock()
defer h.mu.Unlock()
for c := range h.clients {
if c.termWatching == deviceID && c.token != "" {
why := c.termCloseReason
if why == "" {
why = reason
}
done = append(done, closed{c, c.termStartedAt, why})
c.termWatching = ""
c.termStartedAt = time.Time{}
c.termCloseReason = ""
c.queue(msg)
}
}
h.mu.Unlock()
// Audit outside h.mu: h.audit takes h.mu.RLock.
for _, d := range done {
h.audit(d.c, false, auditTermStop, map[string]any{
"device_id": deviceID,
"duration_ms": durationMs(d.start),
"reason": d.why,
})
}
}
// OnDeviceUpdate forwards heartbeat-derived liveness data so the device-list
@@ -407,20 +530,53 @@ func (h *wsHub) register(c *wsClient) {
func (h *wsHub) unregister(c *wsClient) {
h.mu.Lock()
delete(h.clients, c)
// Snapshot the watch set and clear it under the same lock. The teardown
// below must run OUTSIDE the lock: countWatchers takes h.mu.RLock and
// deferredCloseScreen takes h.mu.Lock.
watched := make([]string, 0, len(c.watchIDs))
started := make(map[string]time.Time, len(c.watchIDs))
for id := range c.watchIDs {
watched = append(watched, id)
started[id] = c.watchStartedAt[id]
}
c.watchIDs = nil
c.watchStartedAt = nil
c.keyframeAt = nil
termDevice := c.termWatching
termStart := c.termStartedAt
c.termWatching = ""
c.termStartedAt = time.Time{}
c.termCloseReason = ""
h.mu.Unlock()
// If this client was the last viewer of a device, tear down the screen
// session so the device stops encoding. Done OUTSIDE the lock so the
// hub's mutators can take their own locks without risk of recursion.
if c.watching != "" && h.countWatchers(c.watching) == 0 {
h.deferredCloseScreen(c.watching)
// session so the device stops encoding.
for _, id := range watched {
if !started[id].IsZero() {
h.audit(c, false, auditScreenStop, map[string]any{
"device_id": id,
"duration_ms": durationMs(started[id]),
"reason": "ws_close",
})
}
if h.countWatchers(id) == 0 {
h.deferredCloseScreen(id)
}
}
// Terminal sessions are single-viewer by design, so any open session
// belongs to this client. Tear it down so the next viewer doesn't
// hit ErrTerminalBusy from an abandoned session.
if c.termWatching != "" {
h.devices.CloseTerminalSession(c.termWatching)
c.termWatching = ""
// hit ErrTerminalBusy from an abandoned session. The client is already
// out of h.clients, so OnTerminalClosed won't reach it — audit here.
if termDevice != "" {
h.devices.CloseTerminalSession(termDevice)
h.audit(c, false, auditTermStop, map[string]any{
"device_id": termDevice,
"duration_ms": durationMs(termStart),
"reason": "ws_close",
})
}
h.auditClose(c, len(watched))
// Do NOT revoke the token: tokens are session-scoped, not WS-scoped.
// Frontend may close+reopen the WS at any time (visibilitychange handler,
// brief network blip, reload) and must be able to resume with the same
@@ -455,6 +611,16 @@ func (h *wsHub) deferredCloseScreen(deviceID string) {
// ----- HTTP handler -------------------------------------------------------
func (h *wsHub) serve(w http.ResponseWriter, r *http.Request) {
// Reject disallowed origins explicitly so the rejection is auditable.
// Upgrader.CheckOrigin below is kept as defense in depth.
if !h.checkOrigin(r) {
fields := requestFields(r, h.trustForwardedFor)
fields["reason"] = "origin_not_allowed"
h.log.AuditWarn(auditWSReject, fields)
http.Error(w, "origin not allowed", http.StatusForbidden)
return
}
// Build a per-call upgrader so CheckOrigin closes over this hub's
// allowlist instead of a package-level mutable.
upgrader := baseUpgrader
@@ -462,7 +628,10 @@ func (h *wsHub) serve(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
h.log.Error("ws upgrade: %v", err)
fields := requestFields(r, h.trustForwardedFor)
fields["reason"] = "upgrade_failed"
fields["error"] = err.Error()
h.log.AuditWarn(auditWSReject, fields)
return
}
conn.SetReadLimit(wsReadLimit)
@@ -478,16 +647,25 @@ func (h *wsHub) serve(w http.ResponseWriter, r *http.Request) {
conn: conn,
send: make(chan wsMsg, wsSendBuffer),
closed: make(chan struct{}),
nonce: nonce,
id: newConnID(),
addr: clientIP(r, h.trustForwardedFor),
peerAddr: stripPort(r.RemoteAddr),
origin: r.Header.Get("Origin"),
userAgent: r.UserAgent(),
connectedAt: time.Now(),
nonce: nonce,
watchIDs: make(map[string]struct{}),
watchStartedAt: make(map[string]time.Time),
keyframeAt: make(map[string]int64),
}
h.register(client)
h.audit(client, false, auditWSConnect, client.wsRequestFields())
defer h.unregister(client)
go h.writeLoop(client)
// Greet with a challenge nonce so the browser can compute the login response.
client.queue([]byte(`{"cmd":"challenge","nonce":"` + nonce + `"}`))
client.queue([]byte(`{"cmd":"challenge","nonce":"` + nonce + `"` + challengeCaps + `}`))
h.readLoop(client)
}
File diff suppressed because it is too large. Load diff
+16 -5
View File
@@ -258,7 +258,6 @@ func (a *Authenticator) fakeSalt(username string) string {
func (a *Authenticator) VerifyLogin(username, response, nonce string) (token, role string, err error) {
a.mu.RLock()
u, ok := a.users[username]
expire := a.tokenExpire
a.mu.RUnlock()
if !ok {
return "", "", errors.New("invalid credentials")
@@ -268,18 +267,30 @@ func (a *Authenticator) VerifyLogin(username, response, nonce string) (token, ro
return "", "", errors.New("invalid credentials")
}
token, err = randomHex(tokenBytes)
token, err = a.IssueToken(username, u.Role)
if err != nil {
return "", "", err
}
return token, u.Role, nil
}
// IssueToken mints and stores a fresh session token for an identity that has
// already been authenticated out-of-band (SSO), skipping the challenge
// response. username is used only for audit/logging; authorization keys on
// Role. The caller must pass a role already normalized to "admin" or "viewer".
func (a *Authenticator) IssueToken(username, role string) (string, error) {
token, err := randomHex(tokenBytes)
if err != nil {
return "", err
}
a.mu.Lock()
a.tokens[token] = &Session{
Username: username,
Role: u.Role,
ExpiresAt: time.Now().Add(expire),
Role: role,
ExpiresAt: time.Now().Add(a.tokenExpire),
}
a.mu.Unlock()
return token, u.Role, nil
return token, nil
}
// usersFileEntry is the on-disk shape of one record in users.json. Field
+2543 -83
View File
File diff suppressed because it is too large. Load diff