7 Commits
Author SHA1 Message Date
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
18 changed files with 1072 additions and 63 deletions

No files matched your search

+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");
+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();
+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;
}
+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++;
+47
View File
@@ -170,6 +170,7 @@ 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_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
@@ -598,6 +599,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
+72 -10
View File
@@ -26,11 +26,12 @@ import (
// MyHandler implements the server.Handler interface
type MyHandler struct {
log *logger.Logger
auth *auth.Authenticator
srv *server.Server
hub *hub.Hub
signer licensing.Signer // CMD_MASTERSETTING signer (local / remote / noop)
log *logger.Logger
auth *auth.Authenticator
srv *server.Server
hub *hub.Hub
signer licensing.Signer // CMD_MASTERSETTING signer (local / remote / noop)
loginLimit *wsauth.RateLimiter // per-IP TOKEN_AUTH throttle
}
// OnConnect is called when a client connects
@@ -497,6 +498,20 @@ 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.Warn("TOKEN_AUTH rate-limited: ip=%s conn=%d", ip, ctx.ID)
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()
@@ -674,6 +689,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.
//
@@ -875,23 +903,43 @@ 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()
}
}()
// 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)
srv := server.New(config)
srv.SetLogger(log.WithPrefix(fmt.Sprintf("Server:%d", port)))
// Create handler for this server
handler := &MyHandler{
log: log.WithPrefix(fmt.Sprintf("Handler:%d", port)),
auth: authenticator,
srv: srv,
hub: deviceHub,
signer: signer,
log: log.WithPrefix(fmt.Sprintf("Handler:%d", port)),
auth: authenticator,
srv: srv,
hub: deviceHub,
signer: signer,
loginLimit: authLoginLimit,
}
srv.SetHandler(handler)
@@ -935,12 +983,26 @@ func main() {
if basePath != "" {
log.Info("Serving web UI under base path %q (subpath deployment)", basePath)
}
// 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,
SSO: ssoDetector,
}
// Start HTTP server for web UI. Hub gives it read-only access to the
+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
+18
View File
@@ -299,6 +299,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
@@ -693,6 +704,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
+54
View File
@@ -143,6 +143,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()
+180 -7
View File
@@ -22,18 +22,40 @@ 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
}
// DefaultConfig returns default configuration
func DefaultConfig() Config {
return Config{
Port: 6543,
MaxConnections: 9999,
ReadBufferSize: 8192,
WriteBufferSize: 8192,
KeepAliveTime: time.Minute * 5,
ReadTimeout: time.Minute * 2,
WriteTimeout: time.Second * 30,
Port: 6543,
MaxConnections: 9999,
ReadBufferSize: 8192,
WriteBufferSize: 8192,
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 +73,7 @@ type Server struct {
manager *connection.Manager
handler Handler
parser *protocol.Parser
ipLimit *ipLimiter
running atomic.Bool
wg sync.WaitGroup
@@ -69,6 +92,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 +101,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
@@ -120,9 +248,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,6 +345,15 @@ 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)
}
@@ -204,6 +364,10 @@ func (s *Server) handleConnection(conn net.Conn) {
// 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 +409,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")
}
}
+39
View File
@@ -10,6 +10,7 @@ import (
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
@@ -32,6 +33,9 @@ type Server struct {
loginUserLimit *wsauth.RateLimiter
trustForwardedFor bool // honor X-Forwarded-For (behind trusted proxy only)
basePath string // web UI mount prefix (e.g. "/remoter"); "" = domain root
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 +64,10 @@ 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
// 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.
@@ -77,6 +85,7 @@ func (s *Server) WithConfig(cfg Config) *Server {
s.loginUserLimit = cfg.LoginUserLimit
s.trustForwardedFor = cfg.TrustForwardedFor
s.basePath = cfg.BasePath
s.sso = cfg.SSO
return s
}
@@ -98,6 +107,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 +133,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()
}
+63
View File
@@ -0,0 +1,63 @@
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 {
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.
if role != "admin" {
s.unauthorized(w)
return
}
token, err := s.auth.IssueToken(username, role)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"internal error"}`))
return
}
s.log.Info("sso: user=%s role=%s", username, role)
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())
}
})
}
}
+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
+33 -6
View File
@@ -1670,6 +1670,26 @@
}
}
// Attempt single sign-on via the portal's session cookie. On success
// the server returns a YAMA session token + role, which we persist
// exactly like a local login; the caller then connects the WS and skips
// the login form. Returns true on success, false to fall back to login.
async function trySSO() {
try {
const resp = await fetch('__BASE_PATH__/sso', { credentials: 'same-origin' });
if (!resp.ok) return false;
const data = await resp.json();
if (!data || !data.token) return false;
token = data.token;
currentUserRole = data.role || 'viewer';
sessionStorage.setItem('token', token);
sessionStorage.setItem('role', currentUserRole);
return true;
} catch (e) {
return false;
}
}
function connectWebSocket() {
if (!isPageVisible) return; // Don't connect in background
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
@@ -4480,14 +4500,21 @@
bindKeyboardBtnEvents(document.getElementById('qc-keyboard'));
bindKeyboardBtnEvents(document.getElementById('btn-keyboard'));
bindKeyboardBtnEvents(document.getElementById('btn-keyboard-bar'));
// Restore token and role from sessionStorage
// Restore token and role from sessionStorage (local-login fallback).
token = sessionStorage.getItem('token');
currentUserRole = sessionStorage.getItem('role') || 'viewer';
// Show Users button for admin only (will be updated after login verification)
if (token && currentUserRole === 'admin') {
document.getElementById('users-btn').classList.add('visible');
}
connectWebSocket();
// Attempt portal SSO first: if the user is already logged into the
// parent domain, the server bridges the portal session cookie into
// a YAMA session token so the login form is skipped. On failure
// (not logged in, or SSO disabled) fall back to the restored
// session or the built-in login form.
trySSO().then(() => {
// Show Users button for admin only (final role known after SSO).
if (token && currentUserRole === 'admin') {
document.getElementById('users-btn').classList.add('visible');
}
connectWebSocket();
});
};
</script>
</body>