Feat: Sub-license count limit - LicenseLimit field in licenses.ini + context menu

- Add LicenseLimit field to LicenseInfo struct (0 = not set, unlimited)
- Add GetLicenseLimit/SetLicenseLimit: read/write LicenseLimit key in licenses.ini
- Append |lic:N to reserved field in TOKEN_AUTH response only when
  LicenseLimit > 0; absent |lic: means no limit (client defaults to 9999),
  so super admin authenticating to its own server is never falsely terminated
- Add "Sub-license limit" item in CLicenseDlg right-click menu (1-9999,
  empty = clear limit); menu label shows current value in real time
- Limit change takes effect when sub-client re-authenticates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
yuanyuanxiang
2026-06-20 12:30:43 +02:00
parent c1433b4b5d
commit 837d89c8b5
6 changed files with 122 additions and 3 deletions

View File

@@ -5391,15 +5391,20 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
offset += 1; // 空的 frpConfig offset += 1; // 空的 frpConfig
} }
// Reserved 字段:签名 "sig:<base64(64字节ECDSA签名)>" 防假服务器 // Reserved 字段:签名 + 下级数量上限
// 签名消息: "SN|valid(0/1)",客户端可用已知信息重建并验签 // 格式: "sig:<88字符base64>"(无限制)或 "sig:<88字符base64>|lic:<N>"(有限制)
// 签名消息: "SN|valid(0/1)"|lic:N 仅当 LicenseLimit 显式配置(>0时才写入
// 未配置(=0则不写客户端视为 9999不限制。超管自身无需配置永不受限。
if (!m_v2KeyPath.empty() && len > 20) { if (!m_v2KeyPath.empty() && len > 20) {
std::string snForSig(szBuffer + 1, szBuffer + 20); std::string snForSig(szBuffer + 1, szBuffer + 20);
while (!snForSig.empty() && snForSig.back() == '\0') snForSig.pop_back(); while (!snForSig.empty() && snForSig.back() == '\0') snForSig.pop_back();
std::string signMsg = snForSig + "|" + (valid ? "1" : "0"); std::string signMsg = snForSig + "|" + (valid ? "1" : "0");
BYTE sig[V2_SIGNATURE_SIZE]; BYTE sig[V2_SIGNATURE_SIZE];
if (SignMessageV2(m_v2KeyPath.c_str(), (const BYTE*)signMsg.c_str(), (int)signMsg.size(), sig)) { if (SignMessageV2(m_v2KeyPath.c_str(), (const BYTE*)signMsg.c_str(), (int)signMsg.size(), sig)) {
int licLimit = GetLicenseLimit(snForSig);
std::string reservedStr = "sig:" + base64Encode(sig, V2_SIGNATURE_SIZE); std::string reservedStr = "sig:" + base64Encode(sig, V2_SIGNATURE_SIZE);
if (licLimit > 0)
reservedStr += "|lic:" + std::to_string(licLimit);
if (offset + reservedStr.size() + 1 < sizeof(resp)) { if (offset + reservedStr.size() + 1 < sizeof(resp)) {
memcpy(resp + offset, reservedStr.c_str(), reservedStr.size() + 1); memcpy(resp + offset, reservedStr.c_str(), reservedStr.size() + 1);
offset += reservedStr.size() + 1; offset += reservedStr.size() + 1;

View File

@@ -46,6 +46,7 @@ BEGIN_MESSAGE_MAP(CLicenseDlg, CDialogEx)
ON_COMMAND(ID_LICENSE_DELETE, &CLicenseDlg::OnLicenseDelete) ON_COMMAND(ID_LICENSE_DELETE, &CLicenseDlg::OnLicenseDelete)
ON_COMMAND(ID_LICENSE_AUTO_FRP, &CLicenseDlg::OnLicenseAutoFrp) ON_COMMAND(ID_LICENSE_AUTO_FRP, &CLicenseDlg::OnLicenseAutoFrp)
ON_COMMAND(ID_LICENSE_REVOKE_FRP, &CLicenseDlg::OnLicenseRevokeFrp) ON_COMMAND(ID_LICENSE_REVOKE_FRP, &CLicenseDlg::OnLicenseRevokeFrp)
ON_COMMAND(ID_LICENSE_SET_LIMIT, &CLicenseDlg::OnLicenseSetLimit)
END_MESSAGE_MAP() END_MESSAGE_MAP()
// 前向声明实现位于本文件后段Auto FRP 相关工具) // 前向声明实现位于本文件后段Auto FRP 相关工具)
@@ -110,6 +111,9 @@ std::vector<LicenseInfo> GetAllLicenses(int* activeNum)
it = kv.find("PendingQuota"); it = kv.find("PendingQuota");
if (it != kv.end()) info.PendingQuota = atoi(it->second.c_str()); if (it != kv.end()) info.PendingQuota = atoi(it->second.c_str());
it = kv.find("LicenseLimit");
if (it != kv.end()) info.LicenseLimit = atoi(it->second.c_str());
licenses.push_back(info); licenses.push_back(info);
} }
@@ -346,6 +350,15 @@ void CLicenseDlg::OnNMRClickLicenseList(NMHDR* pNMHDR, LRESULT* pResult)
const auto& lic = m_Licenses[nIndex]; const auto& lic = m_Licenses[nIndex];
menu.AppendMenuL(MF_STRING, ID_LICENSE_RENEWAL, _T("预设续期(&N)...")); menu.AppendMenuL(MF_STRING, ID_LICENSE_RENEWAL, _T("预设续期(&N)..."));
menu.AppendMenuL(MF_STRING, ID_LICENSE_EDIT_REMARK, _T("编辑备注(&E)...")); menu.AppendMenuL(MF_STRING, ID_LICENSE_EDIT_REMARK, _T("编辑备注(&E)..."));
{
CString strLimitLabel;
int curLimit = lic.LicenseLimit;
if (curLimit > 0)
strLimitLabel.FormatL("下级数量限制: %d (&L)...", curLimit);
else
strLimitLabel = _TR("下级数量限制 (&L)...");
menu.AppendMenuL(MF_STRING, ID_LICENSE_SET_LIMIT, strLimitLabel);
}
menu.AppendMenuL(MF_STRING, ID_LICENSE_AUTO_FRP, _T("自动FRP(&F)...")); menu.AppendMenuL(MF_STRING, ID_LICENSE_AUTO_FRP, _T("自动FRP(&F)..."));
// 仅当该授权已分配 FRPC 端口时,才显示"撤销FRP" // 仅当该授权已分配 FRPC 端口时,才显示"撤销FRP"
@@ -1254,3 +1267,80 @@ void CLicenseDlg::OnLicenseRevokeFrp()
lic.SerialNumber.c_str(), existingPort); lic.SerialNumber.c_str(), existingPort);
MessageBox(msg, _TR("撤销成功"), MB_OK | MB_ICONINFORMATION); MessageBox(msg, _TR("撤销成功"), MB_OK | MB_ICONINFORMATION);
} }
// 写入下级数量上限到 licenses.inilimit <= 0 表示清除,恢复默认)
bool SetLicenseLimit(const std::string& deviceID, int limit)
{
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
std::string iniPath = GetLicensesPath();
config cfg(iniPath);
if (cfg.GetStr(deviceID, "Passcode", "").empty())
return false;
if (limit > 0)
cfg.SetInt(deviceID, "LicenseLimit", limit);
else
cfg.SetStr(deviceID, "LicenseLimit", ""); // 清除字段,恢复默认 10
return true;
}
// 读取下级数量上限;返回 0 表示未设置(调用方视为默认 10
int GetLicenseLimit(const std::string& deviceID)
{
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
std::string iniPath = GetLicensesPath();
config cfg(iniPath);
return cfg.GetInt(deviceID, "LicenseLimit", 0);
}
void CLicenseDlg::OnLicenseSetLimit()
{
int nItem = m_ListLicense.GetNextItem(-1, LVNI_SELECTED);
if (nItem < 0)
return;
size_t nIndex = (size_t)m_ListLicense.GetItemData(nItem);
if (nIndex >= m_Licenses.size())
return;
auto& lic = m_Licenses[nIndex];
int curLimit = lic.LicenseLimit;
CInputDialog dlg(this);
CString strTitle;
strTitle.FormatL("下级数量限制 (%s)", lic.SerialNumber.c_str());
dlg.Init(strTitle, _L("数量上限(1-9999):"));
if (curLimit > 0)
dlg.m_str = std::to_string(curLimit).c_str();
if (dlg.DoModal() != IDOK)
return;
std::string input(dlg.m_str);
// 去除首尾空格
size_t s = input.find_first_not_of(" \t");
if (s != std::string::npos) input = input.substr(s);
size_t e = input.find_last_not_of(" \t");
if (e != std::string::npos) input = input.substr(0, e + 1);
int newLimit = 0;
if (!input.empty()) {
newLimit = atoi(input.c_str());
if (newLimit < 1 || newLimit > 9999) {
MessageBoxL("请输入 1 到 9999 之间的整数。", "输入无效", MB_ICONWARNING);
return;
}
}
if (!SetLicenseLimit(lic.SerialNumber, newLimit))
return;
lic.LicenseLimit = newLimit;
CString confirm;
if (newLimit > 0)
confirm.FormatL("授权 %s 的下级数量上限已设置为 %d。\n下级重新认证后生效。",
lic.SerialNumber.c_str(), newLimit);
else
confirm.FormatL("授权 %s 的下级数量限制已清除。\n下级重新认证后生效。",
lic.SerialNumber.c_str());
MessageBox(confirm, _TR("设置成功"), MB_OK | MB_ICONINFORMATION);
}

View File

@@ -27,6 +27,7 @@ struct LicenseInfo {
std::string PendingExpireDate; // 预设的新过期日期(如 20270221空表示无预设 std::string PendingExpireDate; // 预设的新过期日期(如 20270221空表示无预设
int PendingHostNum = 0; // 预设的并发连接数 int PendingHostNum = 0; // 预设的并发连接数
int PendingQuota = 0; // 预设的配额数量(支持多机器续期) int PendingQuota = 0; // 预设的配额数量(支持多机器续期)
int LicenseLimit = 0; // 下级数量上限0=未设置下发时默认10
}; };
// 续期信息结构体 // 续期信息结构体
@@ -99,6 +100,7 @@ public:
afx_msg void OnLicenseDelete(); afx_msg void OnLicenseDelete();
afx_msg void OnLicenseAutoFrp(); afx_msg void OnLicenseAutoFrp();
afx_msg void OnLicenseRevokeFrp(); afx_msg void OnLicenseRevokeFrp();
afx_msg void OnLicenseSetLimit();
}; };
// 获取所有授权信息 // 获取所有授权信息
@@ -130,5 +132,10 @@ int GetIPCountFromList(const std::string& ipListStr);
std::string GetFirstIPFromList(const std::string& ipListStr); std::string GetFirstIPFromList(const std::string& ipListStr);
std::string FormatIPDisplay(const std::string& ipListStr); // 格式化显示: "[3] 192.168.1.1, ..." std::string FormatIPDisplay(const std::string& ipListStr); // 格式化显示: "[3] 192.168.1.1, ..."
// 下级数量限制(写 licenses.ini读取见 GetLicenseLimit
bool SetLicenseLimit(const std::string& deviceID, int limit);
// 读取下级数量上限0 = 未设置,调用方应视为 10默认值
int GetLicenseLimit(const std::string& deviceID);
// 检查 IP+机器名 是否在授权数据库中存在 // 检查 IP+机器名 是否在授权数据库中存在
bool FindLicenseByIPAndMachine(const std::string& ip, const std::string& machineName, std::string* outSN = nullptr); bool FindLicenseByIPAndMachine(const std::string& ip, const std::string& machineName, std::string* outSN = nullptr);

View File

@@ -1935,3 +1935,11 @@ FRPC Զ
[安全提示] 请设置Web访问密码!!!=[Security Warning] Please set web password!!! [安全提示] 请设置Web访问密码!!!=[Security Warning] Please set web password!!!
查看=View Window 查看=View Window
该窗口已最小化,请先还原后再查看。=The window is minimized. Please restore it before viewing. 该窗口已最小化,请先还原后再查看。=The window is minimized. Please restore it before viewing.
下级数量限制: %d (&L)...=License Limit: %d (&L)...
下级数量限制 (&L)...=License Limit (&L)...
下级数量限制 (%s)=License Limit (%s)
数量上限(1-9999):=Limit(1-9999):
请输入 1 到 9999 之间的整数。=Please input a integer range 1, 9999.
输入无效=Invalid Input
授权 %s 的下级数量上限已设置为 %d。\n下级重新认证后生效。=Sub-license limit for %s has been set to %d.\nTakes effect when the sub-client re-authenticates.
授权 %s 的下级数量限制已清除。\n下级重新认证后生效。=Sub-license limit for %s has been cleared.\nTakes effect when the sub-client re-authenticates.

View File

@@ -1926,3 +1926,11 @@ FRPC Զ
[安全提示] 请设置Web访问密码!!!=[安全提示] 请设置Web访问密码!!! [安全提示] 请设置Web访问密码!!!=[安全提示] 请设置Web访问密码!!!
查看=查看 查看=查看
该窗口已最小化,请先还原后再查看。=該視窗已最小化,請先還原後再查看。 该窗口已最小化,请先还原后再查看。=該視窗已最小化,請先還原後再查看。
下级数量限制: %d (&L)...=下级数量限制: %d (&L)...
下级数量限制 (&L)...=下级数量限制 (&L)...
下级数量限制 (%s)=下级数量限制 (%s)
数量上限(1-9999):=数量上限(1-9999):
请输入 1 到 9999 之间的整数。=请输入 1 到 9999 之间的整数。
输入无效=输入无效
授权 %s 的下级数量上限已设置为 %d。\n下级重新认证后生效。=授权 %s 的下级数量上限已设置为 %d。\n下级重新认证后生效。
授权 %s 的下级数量限制已清除。\n下级重新认证后生效。=授权 %s 的下级数量限制已清除。\n下级重新认证后生效。

View File

@@ -991,6 +991,7 @@
#define ID_PARAM_THUMBNAIL_PREVIEW 33050 #define ID_PARAM_THUMBNAIL_PREVIEW 33050
#define ID_LICENSE_AUTO_FRP 33051 #define ID_LICENSE_AUTO_FRP 33051
#define ID_LICENSE_REVOKE_FRP 33052 #define ID_LICENSE_REVOKE_FRP 33052
#define ID_LICENSE_SET_LIMIT 33068
#define ID_Menu 33053 #define ID_Menu 33053
#define ID_33054 33054 #define ID_33054 33054
#define ID_MENU_COMPRESS 33055 #define ID_MENU_COMPRESS 33055
@@ -1013,7 +1014,7 @@
#ifdef APSTUDIO_INVOKED #ifdef APSTUDIO_INVOKED
#ifndef APSTUDIO_READONLY_SYMBOLS #ifndef APSTUDIO_READONLY_SYMBOLS
#define _APS_NEXT_RESOURCE_VALUE 391 #define _APS_NEXT_RESOURCE_VALUE 391
#define _APS_NEXT_COMMAND_VALUE 33068 #define _APS_NEXT_COMMAND_VALUE 33069
#define _APS_NEXT_CONTROL_VALUE 2542 #define _APS_NEXT_CONTROL_VALUE 2542
#define _APS_NEXT_SYMED_VALUE 105 #define _APS_NEXT_SYMED_VALUE 105
#endif #endif