diff --git a/PLAN.md b/PLAN.md index 0d0f7f8..81ac924 100644 --- a/PLAN.md +++ b/PLAN.md @@ -89,7 +89,7 @@ are recorded, and no full sovereignty claim is made. See [LICENSE](LICENSE) and admin-ui (Custom Client form) ↓ custom-build request Go API (DeskForge) - ↓ workflow_dispatch + authenticated DFP1 enc_payload (AES-256-CBC + PBKDF2 + HMAC) + ↓ workflow_dispatch + provider-derived public workflow_sha + authenticated DFP1 enc_payload (AES-256-CBC + PBKDF2 + HMAC) GitHub Actions [configured RustDesk fork, owned platform workflow] ↓ L1: config.rs (server + key) ↓ L2: custom_.txt (permanent password, allowCustom patch) @@ -100,9 +100,13 @@ Go API validates/extracts/publishes locally → admin-ui Download ``` **Security:** password never published — `enc_payload`, decrypted inside runner via -GitHub Secret `WORKFLOW_PAYLOAD_KEY`. The runner does not callback to the API; -the API retrieves the artifact through the provider API and publishes it locally, -not to a public release. +GitHub Secret `WORKFLOW_PAYLOAD_KEY`. The provider-derived outer `workflow_sha` is +checked against `github.sha` before secret-bearing jobs; the same authenticated inner +payload field is checked again before exports, checkout, or build use. This is defense +in depth, not an atomic defense against a malicious workflow file; the verified tag +and active no-bypass ruleset remain required controls. The runner does not callback to +the API; the API retrieves the artifact through the provider API and publishes it +locally, not to a public release. ### Workflow approval and schema evidence diff --git a/README.md b/README.md index 52a45f0..575c150 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,12 @@ only; no APK/package/install/runtime evidence exists. The local schema target is `DatabaseVersion 282`; the published DeskForge schema is 272, and SQLite-only checks do not establish cross-database verification. +### GitHub Actions PAT permissions + +See the [fine-grained PAT permission checklist](api/README.md#github-fine-grained-pat-permissions) +for the custom-client workflow, including why Administration, Actions, and +Secrets write access are required. + **Not implemented (vs RustDesk Pro):** 2FA, RBAC, session recording, device policy, remote script, HA, backup/restore. diff --git a/admin-ui/src/api/custom_client.js b/admin-ui/src/api/custom_client.js index 3023336..607904f 100644 --- a/admin-ui/src/api/custom_client.js +++ b/admin-ui/src/api/custom_client.js @@ -12,6 +12,7 @@ export function create (data) { url: '/custom_build/create', method: 'post', data, + useServerErrorMessage: true, }) } @@ -27,11 +28,14 @@ export function download (id) { return request({ url: `/custom_build/download/${id}`, responseType: 'blob', + useServerErrorMessage: true, }) } export function getVersions () { return request({ url: '/custom_build/versions', + skipErrorMessage: true, + useServerErrorMessage: true, }) } diff --git a/admin-ui/src/api/github_build_config.js b/admin-ui/src/api/github_build_config.js index d663b66..616117f 100644 --- a/admin-ui/src/api/github_build_config.js +++ b/admin-ui/src/api/github_build_config.js @@ -1,15 +1,15 @@ import request from '@/utils/request' export function get () { - return request({ url: '/github_build_config/get' }) + return request({ url: '/github_build_config/get', skipErrorMessage: true, useServerErrorMessage: true }) } export function save (data) { - return request({ url: '/github_build_config/save', method: 'post', data }) + return request({ url: '/github_build_config/save', method: 'post', data, skipErrorMessage: true, useServerErrorMessage: true }) } export function getWorkflowTags () { - return request({ url: '/github_build_config/workflow_tags' }) + return request({ url: '/github_build_config/workflow_tags', skipErrorMessage: true, useServerErrorMessage: true }) } export function approveWorkflowRef (workflowTag) { @@ -17,22 +17,30 @@ export function approveWorkflowRef (workflowTag) { url: '/github_build_config/approve_workflow_ref', method: 'post', data: { confirm: true, workflow_tag: workflowTag }, + skipErrorMessage: true, + useServerErrorMessage: true, }) } export function generateKey () { - return request({ url: '/github_build_config/generate_key', method: 'post' }) + return request({ url: '/github_build_config/generate_key', method: 'post', skipErrorMessage: true, useServerErrorMessage: true }) } export function test () { - return request({ url: '/github_build_config/test', method: 'post' }) + return request({ url: '/github_build_config/test', method: 'post', skipErrorMessage: true, useServerErrorMessage: true }) } export function syncSecret () { - return request({ url: '/github_build_config/sync_secret', method: 'post' }) + return request({ url: '/github_build_config/sync_secret', method: 'post', skipErrorMessage: true, useServerErrorMessage: true }) } export function dispatchTest () { // B-009: confirm=true — это реальный билд (тратит минуты Actions), не дешёвый чек. - return request({ url: '/github_build_config/dispatch_test', method: 'post', data: { confirm: true } }) + return request({ + url: '/github_build_config/dispatch_test', + method: 'post', + data: { confirm: true }, + skipErrorMessage: true, + useServerErrorMessage: true, + }) } diff --git a/admin-ui/src/utils/i18n/en.json b/admin-ui/src/utils/i18n/en.json index a657a90..0aa06eb 100644 --- a/admin-ui/src/utils/i18n/en.json +++ b/admin-ui/src/utils/i18n/en.json @@ -20,6 +20,21 @@ "Password": { "One": "Password" }, + "ShowPassword": { + "One": "Show password" + }, + "HidePassword": { + "One": "Hide password" + }, + "GeneratedKeyLabel": { + "One": "Generated encryption key" + }, + "GeneratedKeyWarning": { + "One": "Copy this key now; it will not be shown again." + }, + "GeneratedKeyCreated": { + "One": "A new encryption key was generated." + }, "LoginSuccess": { "One": "Login Success" }, @@ -38,6 +53,33 @@ "ParamRequired": { "One": "{param} is required" }, + "CustomClientPlatformRequired": { + "One": "Choose Windows as the supported build platform." + }, + "CustomClientVersionRequired": { + "One": "Choose the client version to build." + }, + "CustomClientAppNameRequired": { + "One": "Enter the artifact application name." + }, + "CustomClientHostRequired": { + "One": "Provide the ID server endpoint (hostname/IP and optional port)." + }, + "CustomClientKeyRequired": { + "One": "Provide the public key." + }, + "CustomClientApiServerRequired": { + "One": "Provide the API server URL." + }, + "CustomClientRelayServerRequired": { + "One": "Provide the relay endpoint (hostname/IP and optional port)." + }, + "CustomClientPermanentPasswordRequired": { + "One": "Provide a permanent password when connection management is hidden." + }, + "ClearSavedPassword": { + "One": "Clear saved password" + }, "HasBind": { "One": "Has bind" }, @@ -125,6 +167,42 @@ "WorkflowApprovalRequestFailed": { "One": "Approval was not recorded. Try again after reviewing the provider status." }, + "GithubPatPermissionsIntro": { + "One": "Required fine-grained PAT repository permissions:" + }, + "GithubPatPermissionMetadata": { + "One": "Metadata — Read" + }, + "GithubPatPermissionContents": { + "One": "Contents — Read" + }, + "GithubPatPermissionActions": { + "One": "Actions — Read and write" + }, + "GithubPatPermissionAdministration": { + "One": "Administration — Read and write" + }, + "GithubPatPermissionSecrets": { + "One": "Secrets — Read and write" + }, + "GithubPatPermissionsNote": { + "One": "Actions write is required for dispatch; Administration write is required for ruleset bypass metadata; Secrets write is required for secret synchronization. Empty value keeps the existing token." + }, + "GithubBuildSaveError": { + "One": "GitHub build settings could not be saved. Check the configuration and try again." + }, + "GithubBuildSaveSaving": { + "One": "Saving GitHub build settings..." + }, + "GithubBuildSaveSuccess": { + "One": "GitHub build settings saved." + }, + "GithubBuildRepositoryRequired": { + "One": "Enter a repository in owner/name format before continuing." + }, + "ViewBuildLog": { + "One": "View build log" + }, "Status": { "One": "Status" }, @@ -806,7 +884,7 @@ "One": "Host" }, "HostEndpointHint": { - "One": "Optional. Accepts a hostname or IP address with an optional port. RustDesk uses port 21116 only when the port is omitted." + "One": "Provide the ID server endpoint: a hostname or IP address with an optional port. RustDesk uses port 21116 when omitted." }, "HostEndpointPlaceholder": { "One": "e.g. your-server.com or your-server.com:21116 (default when omitted)" @@ -818,7 +896,7 @@ "One": "Relay Server" }, "RelayEndpointHint": { - "One": "Optional. Accepts a hostname or IP address with an optional port. RustDesk uses port 21117 only when the port is omitted." + "One": "Provide the relay endpoint: a hostname or IP address with an optional port. RustDesk uses port 21117 when omitted." }, "RelayEndpointPlaceholder": { "One": "e.g. your-server.com or your-server.com:21117 (default when omitted)" @@ -943,6 +1021,9 @@ "BuildStatus": { "One": "Status" }, + "BuildHistoryStatusChanged": { + "One": "Build status updated: {param}." + }, "Pending": { "One": "Pending" }, diff --git a/admin-ui/src/utils/i18n/ru.json b/admin-ui/src/utils/i18n/ru.json index 60e561a..367a5e4 100644 --- a/admin-ui/src/utils/i18n/ru.json +++ b/admin-ui/src/utils/i18n/ru.json @@ -20,6 +20,21 @@ "Password": { "One": "Пароль" }, + "ShowPassword": { + "One": "Показать пароль" + }, + "HidePassword": { + "One": "Скрыть пароль" + }, + "GeneratedKeyLabel": { + "One": "Созданный ключ шифрования" + }, + "GeneratedKeyWarning": { + "One": "Скопируйте этот ключ сейчас — повторно он показан не будет." + }, + "GeneratedKeyCreated": { + "One": "Создан новый ключ шифрования." + }, "LoginSuccess": { "One": "Успешный вход" }, @@ -38,6 +53,33 @@ "ParamRequired": { "One": "Поле {param} обязательно" }, + "CustomClientPlatformRequired": { + "One": "Выберите поддерживаемую платформу сборки (Windows)." + }, + "CustomClientVersionRequired": { + "One": "Выберите версию клиента для сборки." + }, + "CustomClientAppNameRequired": { + "One": "Введите имя приложения для артефакта." + }, + "CustomClientHostRequired": { + "One": "Укажите адрес ID-сервера (имя хоста/IP и необязательный порт)." + }, + "CustomClientKeyRequired": { + "One": "Укажите публичный ключ." + }, + "CustomClientApiServerRequired": { + "One": "Укажите URL API-сервера." + }, + "CustomClientRelayServerRequired": { + "One": "Укажите адрес relay-сервера (имя хоста/IP и необязательный порт)." + }, + "CustomClientPermanentPasswordRequired": { + "One": "Если управление подключением скрыто, укажите постоянный пароль." + }, + "ClearSavedPassword": { + "One": "Очистить сохранённый пароль" + }, "HasBind": { "One": "Связано" }, @@ -125,6 +167,45 @@ "WorkflowApprovalRequestFailed": { "One": "Подтверждение не записано. Проверьте состояние провайдера и повторите попытку." }, + "GithubPatPermissionsIntro": { + "One": "Необходимые разрешения репозитория для fine-grained PAT:" + }, + "GithubPatPermissionMetadata": { + "One": "Metadata — Read" + }, + "GithubPatPermissionContents": { + "One": "Contents — Read" + }, + "GithubPatPermissionActions": { + "One": "Actions — Read and write" + }, + "GithubPatPermissionAdministration": { + "One": "Administration — Read and write" + }, + "GithubPatPermissionSecrets": { + "One": "Secrets — Read and write" + }, + "GithubPatPermissionsNote": { + "One": "Для dispatch требуется Actions write; для метаданных обхода защиты workflow требуется Administration write; для синхронизации секретов требуется Secrets write. Пустое значение сохраняет текущий токен." + }, + "GithubBuildSaveError": { + "One": "Не удалось сохранить настройки GitHub Build. Проверьте конфигурацию и повторите попытку." + }, + "GithubBuildSaveSaving": { + "One": "Сохранение настроек GitHub Build..." + }, + "GithubBuildSaveSuccess": { + "One": "Настройки GitHub Build сохранены." + }, + "GithubBuildRepositoryRequired": { + "One": "Перед продолжением укажите репозиторий в формате owner/name." + }, + "ViewBuildLog": { + "One": "Просмотреть журнал сборки" + }, + "BuildHistoryStatusChanged": { + "One": "Статус сборки обновлён: {param}." + }, "Status": { "One": "Статус" }, @@ -683,13 +764,13 @@ "One": "Загрузить" }, "HostEndpointHint": { - "One": "Необязательное поле. Принимает имя хоста или IP-адрес с необязательным портом. RustDesk использует порт 21116 только если порт не указан." + "One": "Укажите адрес ID-сервера: имя хоста или IP-адрес с необязательным портом. RustDesk использует порт 21116, если порт не указан." }, "HostEndpointPlaceholder": { "One": "например, your-server.com или your-server.com:21116 (по умолчанию, если порт не указан)" }, "RelayEndpointHint": { - "One": "Необязательное поле. Принимает имя хоста или IP-адрес с необязательным портом. RustDesk использует порт 21117 только если порт не указан." + "One": "Укажите адрес relay-сервера: имя хоста или IP-адрес с необязательным портом. RustDesk использует порт 21117, если порт не указан." }, "RelayEndpointPlaceholder": { "One": "например, your-server.com или your-server.com:21117 (по умолчанию, если порт не указан)" diff --git a/admin-ui/src/utils/i18n/zh_CN.json b/admin-ui/src/utils/i18n/zh_CN.json index 07931ea..231e685 100644 --- a/admin-ui/src/utils/i18n/zh_CN.json +++ b/admin-ui/src/utils/i18n/zh_CN.json @@ -20,6 +20,21 @@ "Password": { "One": "密码" }, + "ShowPassword": { + "One": "显示密码" + }, + "HidePassword": { + "One": "隐藏密码" + }, + "GeneratedKeyLabel": { + "One": "生成的加密密钥" + }, + "GeneratedKeyWarning": { + "One": "请立即复制此密钥;之后将不再显示。" + }, + "GeneratedKeyCreated": { + "One": "已生成新的加密密钥。" + }, "LoginSuccess": { "One": "登录成功" }, @@ -38,6 +53,33 @@ "ParamRequired": { "One": "{param} 是必须的" }, + "CustomClientPlatformRequired": { + "One": "请选择受支持的构建平台(Windows)。" + }, + "CustomClientVersionRequired": { + "One": "请选择要构建的客户端版本。" + }, + "CustomClientAppNameRequired": { + "One": "请输入产物应用名称。" + }, + "CustomClientHostRequired": { + "One": "请输入 ID 服务器端点(主机名/IP,可选端口)。" + }, + "CustomClientKeyRequired": { + "One": "请输入公钥。" + }, + "CustomClientApiServerRequired": { + "One": "请输入 API 服务器 URL。" + }, + "CustomClientRelayServerRequired": { + "One": "请输入中继端点(主机名/IP,可选端口)。" + }, + "CustomClientPermanentPasswordRequired": { + "One": "隐藏连接管理时,请输入永久密码。" + }, + "ClearSavedPassword": { + "One": "清除已保存的密码" + }, "HasBind": { "One": "已绑定" }, @@ -125,6 +167,45 @@ "WorkflowApprovalRequestFailed": { "One": "审批未记录。请查看服务商状态后重试。" }, + "GithubPatPermissionsIntro": { + "One": "细粒度 PAT 所需的仓库权限:" + }, + "GithubPatPermissionMetadata": { + "One": "Metadata — Read" + }, + "GithubPatPermissionContents": { + "One": "Contents — Read" + }, + "GithubPatPermissionActions": { + "One": "Actions — Read and write" + }, + "GithubPatPermissionAdministration": { + "One": "Administration — Read and write" + }, + "GithubPatPermissionSecrets": { + "One": "Secrets — Read and write" + }, + "GithubPatPermissionsNote": { + "One": "触发 workflow 需要 Actions write;读取规则绕过元数据需要 Administration write;同步仓库密钥需要 Secrets write。留空将保留现有令牌。" + }, + "GithubBuildSaveError": { + "One": "无法保存 GitHub 构建设置。请检查配置后重试。" + }, + "GithubBuildSaveSaving": { + "One": "正在保存 GitHub 构建设置..." + }, + "GithubBuildSaveSuccess": { + "One": "GitHub 构建设置已保存。" + }, + "GithubBuildRepositoryRequired": { + "One": "继续之前,请输入 owner/name 格式的仓库。" + }, + "ViewBuildLog": { + "One": "查看构建日志" + }, + "BuildHistoryStatusChanged": { + "One": "构建状态已更新:{param}。" + }, "Status": { "One": "状态" }, @@ -696,13 +777,13 @@ "One": "上传" }, "HostEndpointHint": { - "One": "可选字段。支持主机名或 IP 地址,可选择是否指定端口。仅在未指定端口时,RustDesk 使用默认端口 21116。" + "One": "请输入 ID 服务器端点:主机名或 IP 地址,可选择是否指定端口。未指定端口时使用 21116。" }, "HostEndpointPlaceholder": { "One": "例如 your-server.com 或 your-server.com:21116(未填写端口时使用默认端口)" }, "RelayEndpointHint": { - "One": "可选字段。支持主机名或 IP 地址,可选择是否指定端口。仅在未指定端口时,RustDesk 使用默认端口 21117。" + "One": "请输入中继端点:主机名或 IP 地址,可选择是否指定端口。未指定端口时使用 21117。" }, "RelayEndpointPlaceholder": { "One": "例如 your-server.com 或 your-server.com:21117(未填写端口时使用默认端口)" diff --git a/admin-ui/src/utils/request.js b/admin-ui/src/utils/request.js index a409181..2f8efc6 100644 --- a/admin-ui/src/utils/request.js +++ b/admin-ui/src/utils/request.js @@ -12,6 +12,51 @@ const service = axios.create({ timeout: 50000, // request timeout }) +const getEnvelopeErrorMessage = data => { + const messages = [data?.message, data?.data?.message] + + return messages.find(message => typeof message === 'string' && message.trim()) || '' +} + +const redirectToLogin = config => { + if (!config?.skipAuthRedirect) { + removeToken() + window.location.reload() + } +} + +const redirectOnEnvelopeAuthFailure = (config, code) => { + if (code === 403) { + redirectToLogin(config) + } +} + +const redirectOnHttpAuthFailure = (config, status) => { + if (config?.useServerErrorMessage && (status === 401 || status === 403)) { + redirectToLogin(config) + } +} + +const GENERIC_RESPONSE_ERROR = 'Unable to process server response' + +const markInterceptorHandled = error => { + Object.defineProperty(error, 'interceptorHandled', { value: true }) + return error +} + +const getResponseErrorMessage = async error => { + if (!error.config?.useServerErrorMessage) return '' + let data = error.response?.data + if (error.config?.responseType === 'blob' && typeof Blob !== 'undefined' && data instanceof Blob) { + try { + data = JSON.parse(await data.text()) + } catch (_) { + return '' + } + } + return getEnvelopeErrorMessage(data) +} + // request interceptor service.interceptors.request.use( config => { @@ -70,22 +115,38 @@ service.interceptors.response.use( // Auth/API failures can still arrive as a JSON envelope with HTTP 200. // Parse those responses so a failed download is not saved as a .zip file. return response.data.text().then(text => { - const res = JSON.parse(text) + let res + try { + res = JSON.parse(text) + } catch (_) { + const malformedResponseError = new Error(GENERIC_RESPONSE_ERROR) + if (response.config.useServerErrorMessage && !response.config.skipErrorMessage) { + ElMessage({ + message: GENERIC_RESPONSE_ERROR, + type: 'error', + duration: 5 * 1000, + }) + return Promise.reject(markInterceptorHandled(malformedResponseError)) + } + return Promise.reject(malformedResponseError) + } + if (res.code !== 0) { - ElMessage({ - message: res.message || 'error', - type: 'error', - duration: 5 * 1000, - }) - - if (res.code === 403) { - removeToken() - window.location.reload() + if (!response.config.skipErrorMessage) { + ElMessage({ + message: response.config.useServerErrorMessage + ? getEnvelopeErrorMessage(res) || GENERIC_RESPONSE_ERROR + : res.message || 'error', + type: 'error', + duration: 5 * 1000, + }) } + + redirectOnEnvelopeAuthFailure(response.config, res.code) return Promise.reject(res) } return response - }).catch(error => Promise.reject(error)) + }) } const res = response.data @@ -106,27 +167,28 @@ service.interceptors.response.use( }) } - if (res.code === 403 && !response.config.skipAuthRedirect) { - removeToken() - window.location.reload() - } + redirectOnEnvelopeAuthFailure(response.config, res.code) return Promise.reject(res) } else { return res } }, - error => { + async error => { if (error.code === 'ECONNABORTED' && error.message.indexOf('timeout') > -1) { error.message = 'Connection Time Out!' } if (!error.config?.skipErrorMessage) { + const message = await getResponseErrorMessage(error) ElMessage({ - message: error.message, + message: error.config?.useServerErrorMessage + ? message || GENERIC_RESPONSE_ERROR + : error.message, type: 'error', duration: 5 * 1000, }) } + redirectOnHttpAuthFailure(error.config, error.response?.status) return Promise.reject(error) }, ) diff --git a/admin-ui/src/views/custom-client/index.vue b/admin-ui/src/views/custom-client/index.vue index 29b0bea..7cc3050 100644 --- a/admin-ui/src/views/custom-client/index.vue +++ b/admin-ui/src/views/custom-client/index.vue @@ -7,49 +7,99 @@ pulse="warning" /> - + - - + +
+ {{ p.name }} {{ p.platform }} - {{ T('Delete') }} - - {{ T('SaveAsPreset') }} + +
+
+ {{ T('SaveAsPreset') }} +
+
+ {{ T('Delete') }} +
+
+
{{ T('Platform') }} - + - - - - - -