commit 85332bbc560dc979a51340174a6deb929d8ab75a Author: m1saka Date: Tue Oct 13 09:34:47 2026 +0800 Initial commit: DSH Web Manager diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..48980a6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.mimocode +.git +**/node_modules +web/dist +data +bin +.env +output +**/output +**/*.tsbuildinfo +.playwright-cli +**/*.sqlite* diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5de42e8 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Copy to .env (ignored by git). Use an absolute dedicated data directory. +DSH_HOST_DATA_ROOT=/var/lib/dsh-mgr +DSH_ADMIN_USERNAME=admin +# Supply a unique strong password before starting; no default credential. +DSH_ADMIN_PASSWORD= +# Numeric group from: stat -c %g /var/run/docker.sock +DOCKER_GID= +# Exact docker info --format {{.DockerRootDir}}; mounted read-only for statfs. +DSH_DOCKER_ROOT_DIR= +# Optional exact extension allowlist, e.g. .txt,.md,.json,.yaml,.go +# Empty permits arbitrary workspace files; this is not malware/content scanning. +UPLOAD_ALLOWED_EXTENSIONS= +# Optional proxy networks. X-Forwarded-For is ignored unless the peer is in these CIDRs. +PANEL_PUBLIC_URL=http://127.0.0.1:8088 +TRUSTED_PROXY_CIDRS= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a786f61 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.mimocode/ +node_modules/ +web/dist/ +data/ +bin/ +.env +output/ +.playwright-cli/ +*.sqlite* diff --git a/ACCEPTANCE.md b/ACCEPTANCE.md new file mode 100644 index 0000000..70e2cb4 --- /dev/null +++ b/ACCEPTANCE.md @@ -0,0 +1,188 @@ +# DESIGN.md 验收记录 + +## 2026-10-12 可信团队试运行加固(本地自动化验收) + +范围:本机或仅可信账号的团队,可由管理员通过公网 HTTPS 反代访问。未修改 Nginx/Caddy,未部署面板或操作现有实例。本节仅说明当前代码的本地自动化验收,不将历史部署证据当作本次公网实测。 + +已修复: + +- 登录来源30次/分钟、有界1024来源桶、8个并发槽;Origin/严格JSON校验先于额度消耗;保留账号失败锁定。默认不信任XFF,仅配置的可信代理CIDR可提供来源链,非法链回退peer。非法公网URL/CIDR在创建数据目录前拒绝;URL规范化大小写与默认端口。 +- 实例文件传输改为短锁登记lease,不再持全局生命周期锁传输;同实例启停/删除/修改/升级/恢复冲突返回409。配置JSON/multipart锁外有界接收(并发4、30秒读deadline),JSON响应在解锁后发送。 +- runtime token按容器ID/StartedAt加密持久化,Host分别缓存;日志流式扫描64MiB上限、完整帧校验、逐行选最新启动token。上游401/403失效cookie,下次请求重新交换,不重放写请求。 +- 恢复快照复制失败清理本次半成品,保留有效快照;清理失败明确500及审计。目录列表10000项上限,用量和快照分批遍历。 +- 构建容量测试注入可控探测,生产2GiB阈值不变;更新模板7文档、实例范围集成脚本和一键验收入口。 + +实际通过: + +- `bash scripts/check-trial.sh` 退出0:Go全量测试、全量race、vet、build;前端32测试及生产构建;初始化3测试;集成脚本语法检查。全量race在此脚本中使用刚刚分包成功运行的Go测试缓存;API包单独完整race约54秒通过。此前一次全量并行race被工具60秒上限终止,不计通过。 +- `node --test scripts/check-trial.test.mjs`:11项通过(包括每阶段失败即停)。 +- `TestSecurityTLSCookieJarLifecycle`:真实本地TLS HTTP服务/cookiejar登录、me、refresh轮换、logout、旧会话拒绝;来源隔离、伪造头、非法配置、并发限流回归通过。不是公网证书/反代验收。 +- 慢上传/下载/配置body/错误JSON不持锁、同实例busy、失败恢复三次不耗名额回归及race通过。 +- runtime专项race覆盖9MiB raw/framed日志、逐字节分片、截断帧拒绝、面板对象重建、Host切换、启动时间变化、磁盘权限/加密/篡改、写盘失败不缓存、cookie拒绝后重交换。Docker及上游由测试HTTP服务模拟。 +- Compose用占位环境变量只读解析,公网URL及可信代理CIDR正确展开。 + +剩余边界与部署前检查: + +- 未重新构建/启动真实runtime镜像,未执行真实Docker生命周期、浏览器或实际公网域名验收;未调用模型、搜索或收费服务。必须在部署入口确认登录/刷新、iframe、WS/SSE和插件设置工作。 +- 必须设置实际 `PANEL_PUBLIC_URL` 和准确的 `TRUSTED_PROXY_CIDRS`;代理及后端可达范围由管理员负责。公网并不意味着支持不可信租户。 +- 旧实例首次成功连接前若启动token已轮转丢失,需维护窗口重启该实例再连接;任意上游token主动轮转不保证自动恢复。软配额、同源插件、已建立WS/SSE不即时撤销等既有边界保留。 +- 前端仍有Monaco大chunk警告。本轮未补许可证、依赖漏洞全量扫描或开源发布材料,不作开源发布就绪声明。 + +结论:本地代码与自动化验收通过,可进入本机/可信团队受控试运行的部署验证;不宣称实际公网部署已验收或长期生产SLA已达标。 + +## Free Search 模板5(部署及真实iframe验收完成) + +- 最终面板已部署:6067f9e8bd12aff649c3b8cc662c37a7a14eebec2d686b84a0d965ee599a4dc5,镜像sha256:6f397fba50a9c3469c1e5ae1ed685e6a4fe90f08ed91d947f38eca9c0b3a4932;原配置/挂载保留,旧面板停止留作回滚。正常API构建记录617457ffdd4c168c58f9cb21e7a591cc成功,模板5可用。 +- 专用新实例5dfabbdde3339c8df7bb616e1c65dce9真实iframe:Installed2、Free Search0.8.5 Running、Configure打开完整设置;前缀describe/credentials-status/mutate均200。Bing地区及缓存时长修改后保存,再恢复zh-CN/5并确认;普通DSH页面重新加载200。截图output/playwright/free-search-settings.png。未执行搜索/引擎测试/在线更新/密钥配置。 +- 实际HTTP匿名401、恶意Origin403、缺失Origin403,根路径请求拒绝;跨用户/public Host由代理自动化测试覆盖,本轮未另做实际公开域名或真实跨用户浏览器验证。全量race TEST_EXIT=0(/tmp/dsh-free-search-race.log)。 +- 上游0.8.5已确认UI限制:单改Bing地区不会置dirty,保存按钮不启用;修改缓存时长后可一起保存。未擅改上游发布包,不把此缺陷视为已修复。 +- 测试实例已经正常API删除,bind数据保留。旧63b实例仍为recipe4,容器ID/StartedAt13:01:01.657475588Z/RestartCount0保持,未安装或启用free-search。 + +- 固定内置dsh-free-search0.8.5及三项普通依赖原始tarball,SHA-512校验;新profile正式依赖及bundle默认启用,旧profile不注入,mobile旧路径保留。runtime镜像dsh-web:0.2.0-rc.2-5,ID sha256:aea9e1ed8614b30457c1d60274a68175e85c1716734e4bca396205bffa885eb6。 +- 断网/非root/只读rootfs启动、两个插件inventory及listBundles、settings describe200、无重复宿主peers或绝对symlink、离线恢复和禁用状态保留、recipe4 mobile-only恢复通过。证据output/recipe5-build.log及recipe5-smoke.log。未调用搜索、引擎测试、收费服务或模型。 +- 面板精确适配8个settings路径,先登录/实例归属/Origin检查再转换loopback Host及Origin,并重新交换Host绑定cookie;客户端fetch仅重写同源精确路径。真实HTTP upstream测试涵盖公开Host、body/query、普通资源、匿名/跨用户/恶意或缺失Origin拒绝,Node测试覆盖Request/URL/string及流语义。Go全量/vet、初始化3测试和聚焦代理race通过。 +- 上游会接管等于deepseek-official的搜索及默认http抓取(包含显式同名值);其他自定义provider保留。默认Bing但自动回退可能使用已有收费API凭据,不承诺零费用。 +- 早期部署曾被权限规则拒绝,后续用户授权继续,主会话接手完成上述部署和验收;实际公网域名未单独验收。 + +## 管理员重设与用户自助修改密码验收 + +- 已部署 `dsh-mgr:password-complete`(sha256:83189ad42e35b5a7db3ba74e27f62adcd35f4c45635be35d4f5e568b37c54c75)至127.0.0.1:40411。面板Config除Image外、HostConfig及Mounts一致,旧面板停止保留供回滚;DSH runtime ID/启动时间/重启计数未变。 +- 管理员入口:设置→用户与审计→用户列表→重设密码。普通用户及管理员自助入口:设置→账号→修改密码,必须验证当前密码。 +- 专用成员pwdqa_1791643434738真实Playwright CLI验收:管理员重设、确认不一致错误、新密码登录;成员错误当前密码保留登录、确认不一致错误、成功修改返回登录页及新密码登录均通过。没有修改既有用户密码。 +- 两次改密后旧密码及旧access/session请求401,携带原CSRF的旧refresh请求403(session已删除),新session刷新200。审计显示user.reset_password和user.change_password,不含密码。 +- Go全量race通过(/tmp/dsh-password-race-final.log,TEST_EXIT=0),vet、前端21测试及生产构建通过;既有Monaco大chunk警告仍在。 +- 证据:output/playwright/password-member-empty-form.png、password-admin-reset-empty-form.png;/tmp/dsh-password-browser-evidence.txt。测试会话已退出;因无删除用户API,专用成员保留,随机测试密码未持久化。 +- 限制:已建立的WS/SSE不会被主动即时断开,后续请求及重连拒绝旧会话。保留的旧面板是部署回滚副本,不是数据库时间点备份。 + +## 模板4:修复插件列表不可见(取代模板3管理可用性结论) + +- 根因:仅host依赖和bundle可使插件运行,但DSH管理UI按profile dependencies判断installed,模板3未登记而被过滤。模板4固定tarball正式依赖,补缺失workspace的no-auto-peers配置,保留已有manifest/bundle禁用状态。 +- runtime `dsh-web:0.2.0-rc.2-4`:sha256:d967800c73888cefba67b8ea14b923c14032f96058311630a37210219877fcdd。面板已部署模板4并经正常API登记镜像。 +- 当前实例63b337c848bf23156270068ff9e5cf38已备份、升级并补mobile依赖,容器2e2d6c75946a65eb7b39a11d72e2e3d23394c3d92d6508cfe25a8fa827a450fb,配置/数据保留。备份73e27b58f421868de00b07958f88113d及此前备份保留。 +- 实际面板iframe触屏浏览器:Plugins新增Installed1,dsh-web-mobile v3.0.5,详情组件Running;真实开关关闭显示Off、再次开启恢复运行,最终保持启用。截图output/playwright/plugin-touch/recipe4-installed-detail.png。UI显示Uninstall但未执行卸载;无独立参数配置表单。 +- 初始化3测试、Go全量/race/vet通过。断网非root只读冒烟验证正式inventory、上游listBundles、无重复peers/绝对symlink,以及仅manifest/patch/lockfile恢复后离线安装并保留disabled状态。当前实例未另做disabled后容器重启测试。 +- 迁移曾因旧package.json.lock及.plugin-manager/run.json阻塞并回滚;锁PID16与新容器安装进程PID16相同。确认实例完全停止后将两份记录改名保留,停机直接升级成功。未删除配置数据;该上游纯PID锁风险未通过自动删锁规避,后续遇到需停所有profile写入者再人工核验恢复。 + +## 默认内置 dsh-web-mobile 增量验收 + +- 模板修订3固定内置 `dsh-web-mobile@3.0.5`,校验发布包SHA-512与宿主peer版本,不执行插件生命周期脚本。当前限定DSH rc.1/rc.2;真实镜像测试使用rc.2。 +- 新建空web profile默认加入mobile bundle;已有manifest或其他profile文件不覆盖、不强制启用。初始化拒绝符号链接目录,幂等/已有配置保留及符号链接测试均通过。 +- 发布包原始权限导致非root加载失败,已通过镜像内 `chmod -R a+rX` 修复。修复后 `node scripts/test-mobile-image.mjs` 通过:断网、非root、只读rootfs启动,manifest与实际dump-config均含mobile插件。专用冒烟容器及其tmpfs测试数据已删除。 +- 验收面板 `127.0.0.1:40411` 已更新至 `dsh-mgr:plugin3-active`(sha256:06bca1aae39accbac0721af723082e8a35a0c9cb2129e4cb942015b48fbffd85),HTTP 200。正常构建API成功登记rc.2模板3,镜像API状态available。旧面板停止保留供回滚,现有模板2实例未重启或自动加入插件。 +- 本轮通过:初始化2测试、Go全量测试/race/vet、前端19测试及生产构建(仅既有Monaco大chunk警告)。 +- 后续浏览器验收发现单纯放入node_modules仍会导入失败:DSH运行时只解析安装依赖图。已将内置包加入宿主package.json dependencies,且冒烟增加stdout/stderr激活失败检查(不输出token)。最终runtime ID:sha256:30ca773ce5d70a3a540a8f4aae149c83733102f8dfcd6a77ef9794bd992bde8e。此前只检查dump-config的成功结果不等于运行时激活成功,以下复验取代它。 +- Playwright CLI独立loopback runtime触屏复验:coarse pointer=true、maxTouchPoints=1;含dsh-web-mobile/client.js的实际模块请求200;四个data-mobile-nav标记生效。首页及tap展开目录后viewport/client/scroll/body宽度均390。截图:output/playwright/plugin-touch/rc2-3-fixed-default-390.png、rc2-3-fixed-drawer-390.png。临时runtime已删除。 +- 边界:本次插件复验未覆盖面板iframe、实体手机或模型交互;额外目录关闭tap超时,未计通过。前轮专用临时runtime token曾在工具输出回显,已删除该容器使服务不可访问,未涉及旧实例凭据。插件还包含响应压缩、会话删除入口和推理档位补全,不是纯CSS;未执行模型调用。 + +## 2026-10-10 移动端内嵌工作台增量验收 + +用户追加需求:登录默认 iframe 内嵌 DSH,设置切换容器,移动端底部导航。已实现并部署至原验收地址,镜像 dsh-mgr:mobile-renew(sha256:392c8bb64c822572005625df5d662774e1f780a1c83121caa249fafe6a16d01f)。 + +- 真实浏览器390×844及320×640:外层scrollWidth等于viewport宽度;iframe底边分别780/576,与底栏顶边一致,无底栏遮挡。桌面1440×900保留侧栏。 +- 设置切换第二个真实容器、整页刷新后选择保留;停止所选实例显示启动提示而非自动切换;删除测试实例后回到可用实例。测试实例已删除,bind数据保留。 +- 设置往返iframe DOM标记保留,未重载;退出登录iframe卸载;重新登录默认iframe已验证。 +- 清除dsh_access模拟过期,真实iframe续期refresh200并回到原DSH地址,无加载错误。修复iframe导航续期及SPA X-Frame-Options冲突;使用SAMEORIGIN与frame-ancestors self保留跨源嵌入限制。 +- Go全量测试、race及vet通过;前端19测试/生产构建通过。截图:output/playwright/design-final/mobile-settings.png及page-2026-10-10T09-29-51-590Z.png、page-2026-10-10T09-34-19-112Z.png。 +- 验证为Chrome小屏视口,不代表iOS/Android真机软键盘兼容已全测。同源iframe不是安全沙箱;保留受信任团队部署边界。 + +以 DESIGN.md 为目标,不通过修改设计降低标准。以下最新结论覆盖后文早期进度记录。 + +## 2026-10-10 最终验收结案 + +**结论:按用户确认的默认路径代理、软配额及功能等价替代范围,验收通过并结案。** 用户在最终结构化确认中明确选择“接受功能等价替代”:标准库 Docker HTTP 客户端替代 SDK、每实例独立 bridge 替代共享 bridge、服务端保管 DSH cookie 替代下发浏览器、React+CSS 替代 shadcn(保留 Monaco)、部分模块集中于 internal/api。上述选型不再是待确认项;并未修改 DESIGN.md 来降低要求。 + +结案时再次确认 /healthz 返回 ok,dsh-design-check 正在运行 dsh-mgr:design-final,用户1000:1000、只读根文件系统、内存限制1073741824字节。此次结案仅更新验收文档,没有新增代码变更;沿用下列已完成测试证据。 + +默认路径代理、软配额方案的 M1–M7 功能已实现,真实主要流程通过;故障边界结合自动化注入测试验收,不声称所有故障均在宿主实际制造。可选 XFS/ZFS、socket-proxy、公网 TLS 未实测。 + +| 范围 | 最新证据 | +|---|---| +| M1 镜像 | rc.1/rc.2 recipe2实际构建,rc.2 smoke通过,精确版本/非root/只读/pnpm10.11.0 | +| M2 生命周期 | 最终面板完整API通过;test-intent-live.mjs验证预约自动创建、两个真实运行实例、第三创建与预约409、删除释放 | +| M3 代理 | 新浏览器DSH/空白会话/插件资源/SSE200;最终WS 1连接、5发送、5接收;面板替换自动重新接入网络;独立DSH页超过5分钟refresh200且PTY可操作 | +| M4 工作区 | 33项真实HTTP+43项多用户断言;ZIP/Range/穿越;PTY读取上传文件;直接调用镜像内DSH read/write实现在/workspace往返(无模型调用);扩展名白名单.txt成功/.exe415 | +| M5 配置备份 | Monaco键盘编辑、保存启动、bundle/脱敏dump-config;浏览器备份创建/下载/错误密码拒绝/正确密码导入恢复 | +| M6 升级回滚 | rc.1→rc.2;manifest加入is-number@7.0.0验证依赖安装/恢复/迁移;不可达依赖触发失败,原ID/版本/停止状态恢复,数据marker保留。fixture验证安装机制,不代表任意第三方插件兼容 | +| M7 管理安全 | JWT/refresh/隔离/配额/审计;最终面板1000:1000、只读、1GiB、socket组989;管理员显式回收停止孤儿容器通过且bind目录保留 | + +故障注入回归覆盖:构建去重/限频、磁盘不足/未知磁盘、npm精确版本、镜像缺失、重启恢复、失败释放预约、镜像引用保护、备份资源与旧包、路径/符号链接/配额并发和回滚。见internal/api/*_test.go及internal/backup/*_test.go;没有填满宿主磁盘或删除共享镜像。 + +真实验收发现并修复:dump-config顶层YAML列表误拒绝;代理日志旧token污染启动token提取。新增测试并容器复验通过。 + +最终镜像: +- panel dsh-mgr:design-final:sha256:d2da2f35e00e15fc0da7afc5183c49e40be6215bd4c5d89145e48f1034617cc5 +- rc.2 recipe2:sha256:7bd09f12b4b60fa9d13358e99bae14caafc79d3a0936e70cf590fdd1af64998e +- rc.1 recipe2:sha256:1bb553deb3cef25c4529f93526d8cd3282d218df22787be2dc5d5762defaf0c4 + +通过命令:go test ./... -timeout 45s;go test -race ./... -timeout 90s;go vet ./...;npm --prefix web test(12项);npm --prefix web run build。前端有Monaco大chunk警告。 + +证据:output/playwright/design-final/;/tmp/dsh-design-check.8ekWaC/{api-check-results.txt,isolation-results.json};/tmp/dsh-design-plugin-rollback-verified.log;/tmp/dsh-design-cross-version.log。保留专用面板及一个浏览器实例;临时实例删除但数据保留,未推送镜像、未调用付费模型。 + +用户确认的孤儿策略:管理员二次确认回收身份/挂载核验通过、无实例/升级引用的停止容器;不自动纳管,不触碰运行中或来源不明容器,不删数据/网络。其他架构替代见下表;不声称逐字遵循SDK、组件库或目录示意。 + +## 早期进度记录(以下由上方最新结论覆盖) + +## 当前实现与待验收 + +| 设计章节 | 代码已接入 | 尚需真实证据 | +|---|---|---| +| §1–6 | sibling路径映射、每用户两实例、持久预约/取消/失败恢复、生命周期与身份对账 | 模板2非root全流程、刷新/重启预约恢复、并发名额 | +| §5 | 精确npm版本、模板/平台身份、构建去重/限频/磁盘预检、镜像usage/missing及管理员清理 | 真实构建与删除、外部删镜像、空间/网络故障及重启对账 | +| §7–8 | 路径反代、归属鉴权、服务端token/cookie桥接、WS/SSE | 新部署的新浏览器完整流程、重启重新认证、长时续期 | +| §9 | 文件管理、目录ZIP、写前配额检查/串行化、周期巡检、管理员额度更新 | 新UI文件流程、agent实际读写、面板各写入路径配额覆盖 | +| §10 | 密码加密tar.gz v2、配置/资源目录/manifest、外部包导入、旧key包兼容、停启/健康检查/回滚 | 真实资源往返、原密码/错误密码、无key新包、旧key包、插件恢复失败回滚 | +| §11 | 本地Monaco、YAML校验、bundle与脱敏dump-config预览、保存后启动验证 | 新浏览器编辑/预览/错误路径;当前仍需先停止再保存 | +| §12 | 预升级密码备份、home快照、成功删旧、失败回滚及恢复原运行状态 | 不同DSH版本、真实插件重建/离线失败、故障矩阵与数据保留 | +| §13–16 | 短JWT/refresh轮换/失败锁定、审计、资源限制、非root部署配置 | 模板2非root数据写入和socket访问、新浏览器及DSH独立页长JWT续期 | +| §17–19 | 默认路径反代与软配额;已有回归测试及旧版集成记录 | 必需流程未关闭,不标记M1–M7整体完成;硬quota为可选增强 | + +备份包含六个固定配置文件及skins、pets、agent-presets、.agent-presets资源树和manifest,不包含node_modules、会话或工作区。新密码包不依赖backup.key;保留旧密钥包时仍须保管原key。备份/恢复/升级不再统一要求用户手动停机;操作中断或回滚失败不保证自动灾难恢复。 + +## 合理替代方案(不改变验收目标) + +| DESIGN选型 | 当前实现 | 等价目标与边界 | +|---|---|---| +| 官方Docker SDK | stdlib net/http Docker Engine client,API v1.47 | 仍须验证编排、构建流、错误处理和daemon兼容性,不能以未用SDK认定功能缺失 | +| 共享bridge | 每实例独立bridge,面板接入 | 减少实例直接互通;不等于完整出站或宿主网络隔离 | +| DSH cookie改写后下发浏览器 | 服务端交换/缓存cookie,反代注入并移除上游Set-Cookie | 保留面板登录与实例归属鉴权;WS/SSE、重启失效与续期仍需实测 | +| shadcn/ui | 普通React组件及CSS,保留本地Monaco | 不要求替换组件库,但交互、错误反馈和可用性仍须浏览器验收 | + +代码主要按internal/api、internal/docker、internal/backup、internal/store组织,与设计目录示意不同;不宣称逐字一致。默认软配额符合§9/§18路线,XFS/ZFS硬quota依赖运维选择,不列为默认必过项。npm 0.2.0-rc.2不支持--public-url,当前使用路径代理而不传该参数。 + +## 历史证据与本轮边界 + +本轮已取得真实证据: +- 非root面板 `dsh-design-check`(1000:1000、只读rootfs、socket附加组989)已启动;33项HTTP认证/刷新/配额/ZIP/Range检查通过,报告 `/tmp/dsh-design-check.8ekWaC/api-check-results.txt`。 +- 两普通用户隔离与预约43项断言通过,包括越权拒绝、两名额及取消释放;报告同目录 `isolation-results.json`。未把预约名额测试算作真实runtime并发测试。 +- 浏览器基础UI登录、配额、ZIP、镜像状态、版本确认与预约页面通过,证据 `output/playwright/design-final/`。实例相关Monaco/备份/长JWT待runtime模板2就绪。 +- 模板2实际构建进行中,详细安装日志持续有进度;尚无runtime2启动通过结论。 + +- 既有记录包含旧版真实Docker文件/实例/备份恢复及同镜像替换流程;同镜像替换不能证明跨DSH版本迁移。 +- 旧浏览器证据目录output/playwright/browsercheck18091/记录插件加载、空白会话及WS/SSE,不代表模型生成、PTY或真实插件安装通过,也不能替代模板2和新增UI。 +- 上轮记录的go test -race ./... -timeout 90s、go vet ./...、前端12项测试及前后端构建通过,仅为当时检查结果;冻结后须重跑,不能据此宣称全完成。 +- **仍需关闭:模板2非root真实部署、新浏览器新增功能、插件跨版本/离线失败、长时JWT续期**。主代理进行中,结论与证据待回填;本文件不将此前权限拒绝误写为当前持续阻塞。 +- 真实测试仅限已授权的本项目测试资源;工具再次拒绝时不绕过。不得隐式使用实际模型密钥或付费调用,不自动删除未知孤儿容器。 +# 默认工作区 /workspace(模板 6,已由下述模板 7 数据根方案取代) + +- 当前用户实例已通过 DSH 界面添加并选择 `/workspace`,刷新后仍保留;原 `normal` 工作区和历史会话未移动。 +- 空实例初始化通过 DSH workspaceRegistry 注册准确的 `/workspace`,重复启动复用默认 ID;有历史工作区或会话时不强制迁移。 +- 真实断网非 root 容器首次初始化、插件加载、备份恢复及旧模板兼容验证通过,详见 `/tmp/dsh-recipe6-test.log`。 +- 面板已部署 `dsh-mgr:workspace6`;正常构建 API 记录 `e9a368bbb66e51e6c8f2439a8e94698a` 成功,recipe_rev=6,镜像 `sha256:38e04308da602da22e8df49d54166da1835922d50a3fa485711b7c5f6479d287`。 +- 面板部署保留配置和挂载,旧面板停止留作回滚;现有 DSH 实例未重启,构建前后实例记录一致。 + +## 模板 7:完整 DSH_HOME 改为 /workspace + +### 管理交互优化验收 + +- 配额静态展示与模态编辑、常用预设、未改变禁提交;密码明确按钮、原生模态、显示隐藏、即时UTF-8校验、取消清空及焦点恢复。账号创建规则对齐,移动用户卡片和退出分区。 +- 前端26测试、生产构建通过。Playwright真实测试账号 uxqa_1791711946466:配额10→20→10 GiB;管理员重设→登录→自助修改→退出→新密码登录均通过;取消清空、焦点恢复、确认不一致禁提交、320/390px对话框不越界通过。 +- 测试账号最终重新随机化密码、撤销会话,账号保留;未修改真实账号配额或密码。 +- 正式面板 dsh-mgr:ux-polish 已部署,挂载集合核验相同(Docker返回顺序不同),DSH容器953ed34d034f未重启。正式入口重新加载后配额/密码对话框检查通过。 +- 截图:output/playwright/ux-before-settings.png、ux-before-admin.png、ux-deployed-settings.png、ux-deployed-quota.png、ux-mobile-quota.png、ux-mobile-password-320.png。浏览器密码管理器用户名提示尚存;不声称全面无bug或完整无障碍合规。 + +- 依用户澄清,实例 home 直接挂载 /workspace,四个 HOME 环境变量同步;/data/dsh-home 为兼容软链接。旧共享目录保留为 /shared,不合并、不覆盖历史文件。 +- 面板文件接口以 instance 参数校验归属、跟随所选实例;旧共享文件保留显式入口。账号总配额不变,恢复期间禁止写入,实例文件请求与升级互斥。 +- 当前实例 63b337c848bf23156270068ff9e5cf38 正常升级成功,容器 953ed34d034faed9e0f8c7373964dd0156450b1d65c2965c73a539a83a226934,运行镜像 sha256:bd541770aaaf5a846ecb898ec80863c8a356524eb7f4b8053b0eb646d6e10fad。 +- 构建 6c3589998c431acc809b5307c30e8e1e 成功,真实离线 smoke 验证环境变量、旧路径链接及同文件读写。升级中修复宿主路径转换和 pnpm 非交互日志边界;早期失败自动回滚,快照和备份保留。 +- 最终验证:DSH 代理 GET 200;实例文件 API 可见 profiles/storages,storages/normal 可见四项历史报告/笔记;旧共享接口可访问。未调用模型或搜索。 +- 前端 23 测试/build 通过,后端全量及 vet 曾通过,race 通过。最终定向 Workspace/Upgrade/Orphan 测试通过;最后一次全量受 Docker 磁盘不足 2 GiB 策略影响,3 个构建测试失败,不记为全绿。浏览器交互验证被权限拒绝,未绕过;HTTP 验证不替代浏览器验收。 diff --git a/API.md b/API.md new file mode 100644 index 0000000..434ffaf --- /dev/null +++ b/API.md @@ -0,0 +1,88 @@ +# DSH Web 面板 API(MVP) + +默认监听 127.0.0.1:8088。JSON 错误统一为 {error:string}。登录使用 HttpOnly session/access/refresh cookie;写请求需 X-CSRF-Token 和精确同源 Origin(未配置 PANEL_PUBLIC_URL 时也校验)。access JWT 有效期5分钟,refresh轮换且受24小时session限制。 + +## 配置 +- UPLOAD_ALLOWED_EXTENSIONS:可选逗号分隔扩展名白名单(例如 .txt,.md);空表示不限制。大小写不敏感,非内容/恶意软件检测。 + +## 管理员孤儿回收 + +GET /api/orphans 返回可回收的完整容器ID数组。DELETE /api/orphans?id=&confirm=<相同完整id> 二次核验后非强制删除,保留bind数据及网络。仅接受本项目身份/挂载匹配、已停止、无实例或升级引用的容器;普通用户403,确认不匹配400,状态不安全409。 + +## 其他配置 +- LISTEN_ADDR(默认 127.0.0.1:8088) +- DATA_ROOT(默认 ./data) +- HOST_DATA_ROOT(默认等于 DATA_ROOT 的绝对路径,用于 Docker bind source;容器部署必须显式设置宿主绝对路径) +- CONTAINER_DATA_ROOT(默认 DATA_ROOT) +- DOCKER_HOST(默认 unix:///var/run/docker.sock) +- PANEL_PUBLIC_URL(可选) +- ADMIN_USERNAME / ADMIN_PASSWORD(首次启动创建管理员;密码必须 12–72 字节,不设置则拒绝启动,不打印密码) +- WEB_DIR(可选静态前端目录) +- PANEL_CONTAINER_ID(面板容器名/ID,用于加入实例网络;宿主进程运行时留空) +- DEFAULT_QUOTA_BYTES(默认10GiB) +- DSH_DOCKER_ROOT_VISIBLE(与DockerRootDir相同路径的明确映射,用于构建前容量检查;未知容量拒绝新构建) + +## 认证 +- POST /api/login {username,password} → {user,csrf_token};user 字段为 id,username,admin,quota_bytes,created_at +- POST /api/logout → {ok:true} +- POST /api/refresh → {ok:true}(刷新HttpOnly cookie,需Origin和CSRF) +- GET /api/me → {user,csrf_token} +- POST /api/me/password {current_password,password} → {ok:true,reauthenticate:true}(普通用户及管理员仅修改本人;不接收目标 ID,未知 JSON 字段返回 400)。需有效 access/session、同源 Origin 和 X-CSRF-Token;新密码为 UTF-8 12–72 字节,非法 JSON、缺少当前密码或长度不符返回 400。错误当前密码返回 400 而非 401,客户端不应因此退出登录。未登录 401,Origin/CSRF 不符 403(失效 session 在 guard 中也可能返回 403),已认证的非 POST 请求返回 405。 + - 与登录、管理员重设共用 authLoginMu,等待后复验会话与当前密码;沿用账户登录失败计数,累计 5 次错误锁定 15 分钟,锁定期间返回 429(即使当前密码正确),已登录不能绕过。认证存储故障返回 503。 + - 同一事务保存 bcrypt hash、撤销本人全部 session/refresh、清除失败计数,并记录 user.change_password 审计(操作者和目标均为本人 ID,detail 为空,无密码/hash/token);事务失败返回 500 并完整回滚,其他用户不受影响。成功后客户端应清理登录状态并重新登录;旧密码、旧 access/session/refresh 不再可用。已建立的 runtime WS/SSE 不主动断开,下一次请求或重连重新鉴权。 + +## 管理 +- GET /api/users(管理员) +- POST /api/users {username,password,admin?,quota_bytes?}(管理员) +- PATCH /api/users {id,quota_bytes}(管理员修改正数配额) +- POST /api/users/reset-password {id,password} → {ok:true,reauthenticate:boolean}(仅管理员;需有效 access/session、同源 Origin 和 X-CSRF-Token)。密码按 UTF-8 字节计为 12–72 字节,以 bcrypt 保存;缺少 ID、非法 JSON 或长度不符返回 400,目标不存在 404,未认证 401,非管理员或 Origin/CSRF 不符 403(失效 session 在 guard 中也可能返回 403),已认证的非 POST 请求返回 405。 + - 同一事务更新密码、删除目标全部 session/refresh、清除目标登录失败锁定,并记录 user.reset_password 审计(操作者 ID、目标 ID、时间,detail 为空,不含密码/hash/token)。任何数据库失败返回 500 并回滚,不影响其他用户会话。与登录串行,等待后重新检查管理员会话和权限。 + - 允许重设自身,此时 reauthenticate:true,当前会话也立即失效,客户端应清理登录状态并回到登录页;重设他人返回 false。旧密码不能再登录,旧 access/refresh 不能发起新的认证请求。已建立的 runtime WS/SSE 不会被主动立即断开:代理仅在请求/握手时鉴权,下一次请求或重连会被拒绝;现有 runtime 页面刷新认证脚本不是即时断连保证。 +- GET /api/audit(管理员) + +## 镜像与构建 +- GET /api/images +- DELETE /api/images?image_id=sha256:...(管理员;保护实例、活动任务、升级及全部Docker容器引用,不force) +- GET /api/latest(查询 npm dist-tags.latest) +- GET /api/builds +- POST /api/builds {version}(固定版本,异步;版本/模板/平台去重,新任务每用户每小时最多3个,共享复用不扣次数) + +## 实例 +- GET /api/instances → Instance[];字段 id,uid,name,version,image_id,status,container_id,created_at +- POST /api/instances {name,image_id,version} → Instance(每用户最多 2 个) +- POST /api/instances/{id}/start|stop|restart +- DELETE /api/instances/{id} → {ok:true} +- POST /api/intents {name,version}(固定精确版本,持久预留构建后创建名额) +- GET /api/intents(当前用户意图) +- DELETE /api/intents/{id}(仅取消等待,不取消共享构建) + +所有成功 JSON 列表均为顶层数组(不是 data 包装);错误均为 {error:string}。实现与验收仍在进行,不应仅凭接口列在此处认定已验收。 +- GET /api/instances/{id}/logs +- /dsh/{id}/...(登录和 owner 校验;需同 Docker 网络) + +## 工作区 +- GET /api/ws/tree?path=. +- GET /api/ws/usage +- POST /api/ws/mkdir {path} +- POST /api/ws/rename {path,new_path} +- DELETE /api/ws/delete?path=... +- POST /api/ws/upload multipart file,path +- GET /api/ws/download?path=...:普通文件保持原始流与 Range 支持;目录返回 `application/zip` 附件(目录名.zip),空 path 或 `.` 下载整个工作区(workspace.zip)。ZIP 条目相对所选目录,保留空目录,不额外包裹顶层目录,使用 Store 模式(不压缩),不支持 ZIP Range。 + - ZIP 先校验有界文件清单,再逐文件流式输出,不生成临时归档,不持 workspace/global 锁跨网络传输。每次最多 10,000 个文件/目录条目、64 层、10 GiB 文件总大小;进程最多同时 4 个目录下载,超出分别返回 413 / 429;扫描和传输合计限时 2 分钟。 + - 路径必须位于工作区内;下载拒绝任意路径分量的符号链接、跨挂载点、特殊文件和多硬链接普通文件。目录含上述条目或含反斜杠/冒号的不安全 ZIP 文件名时整体拒绝(400),不静默漏文件。依赖 Linux openat2,内核不支持时安全失败。 + - 下载不是原子快照:流式读取前复核文件身份、大小、类型及修改时间;并发修改、取消或传输错误可能导致连接中断/不完整 ZIP,请停止写入后重试。单文件下载不受 ZIP 数量、大小、并发和时限约束。 + +## profile / 备份 +- GET /api/instances/{id}/profile +- PUT /api/instances/{id}/profile {content,restart?}(停机原子写;restart=true启动并检查就绪) +- GET /api/instances/{id}/profile-preview(bundle清单和脱敏dump-config,字符串值隐藏;运行中才能dump) +- POST /api/instances/{id}/backups {password}(当前登录密码加密;自动恢复原运行状态) +- POST /api/instances/{id}/backups multipart file,password(导入外部包,password为原包密码;不立即覆盖home) +- GET /api/instances/{id}/backups +- GET /api/instances/{id}/backups?download={backup_id} +- POST /api/instances/{id}/restore {backup_id,password}(原备份密码;旧平台key包可留空) +- POST /api/instances/{id}/upgrade {image_id,password}(当前登录密码,加密pre-upgrade备份) + +Profile写入要求实例先停止。备份/还原/升级会受控停启;恢复保留原运行状态,升级成功运行新版,失败恢复旧状态。模板2入口先准备插件再启动应用。删除实例仅删除容器与网络并移除索引,保留数据目录。升级失败恢复实例home,不撤销共享workspace或外部系统副作用。 + +已实现并通过核心集成检查:镜像复用验证、Docker 生命周期、token/cookie 桥接、加密备份恢复、同镜像替换升级。WS/SSE 代理有单元测试,真实完整会话待验证。硬配额与跨公网租户域名隔离未实现;当前 dsh 发行版不支持 --public-url。默认仅 loopback 监听,不应直接暴露公网。完整限制见 README.md。 diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..9905e5d --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,411 @@ +# DeepSeek Harness 多租户容器管理平台 — 设计文档 + +> 状态:本机管理面板核心流程已实现并通过真实 Docker 集成检查;生产隔离、硬配额等仍有差距,以 README.md 的实现边界与验证范围为准。下文是目标设计,不代表所有条目已实现。 +> 目标项目:`deepseek-ai/deepseek-harness`(npm CLI `@deepseek-ai/dsh`,命令 `dsh web`) +> 控制面技术栈:Go + 前端 SPA +> 部署形态:单机 Docker daemon;面板自身亦运行于容器中 +> 鉴权/配额:内置账号 + 目录配额 + +--- + +## 0. 调研结论与硬约束 + +> 实现阶段实测更正:本地 npm `0.2.0-rc.2` 支持 `--trusted-host`,但拒绝 `--public-url`;下文基于 upstream master 的 public-url 描述不能直接用于该发行版。实际启动仅传 web/no-open/port/trusted-host,代理负责外部路径。`scripts/probe-runtime.mjs` 已验证默认 token 交换为 303 + cookie、认证首页 200;完整代理以集成测试为准。 + +针对本体 `deepseek-ai/deepseek-harness`(npm CLI `@deepseek-ai/dsh`,命令 `dsh web`)确认: + +| 事实 | 来源 | 对设计的影响 | +|---|---|---| +| **无官方 Docker 镜像/Dockerfile** | repo 根、`apps/`、`.gitlab-ci.yml` 均无 | 必须自建镜像包装 `dsh web` | +| **`dsh web` 默认监听 `127.0.0.1:3080`,且启动时拒绝 `--host 0.0.0.0`**(安全原因) | `packages/bundle/web-app/README.md` | 容器内必须跑反向代理绑 `0.0.0.0` 转发到 loopback | +| 端口/主机是 **CLI flag**(`--port/--host/--public-url/--trusted-host/--no-open`),**无 `DSH_PORT/DSH_HOST` 环境变量** | 同上 | 容器启动参数化靠 CMD args,不靠 env | +| 数据根 = `$DSH_HOME`(默认 `~/.dsh`) | `docs/user/guide/providers.md`、CLI README | 每实例独立 `DSH_HOME`,bind-mount 即可保留数据 | +| **多实例硬约束:每个容器必须独立 `DSH_HOME` + 独立端口**(task-board 单写者) | architecture.md | 一用户两容器 ⇒ 两个 `DSH_HOME`,不能共享 | +| 模型/API Key 存储:密钥在 `$DSH_HOME/.credentials.yaml`(YAML,write-only),模型/provider 配置在 `$DSH_HOME/profiles//cordis.patch.yml`;`cordis.patch.yml` 只存 `apiKeyEnv` 引用 | `providers.md` | 备份"模型"= 备份这两个文件 | +| **"profile 文件" = `profiles/web/cordis.patch.yml`(YAML,用户可编辑的 patch 层)**;profile 目录还有 `package.json`(含插件依赖 + `dsh.profile` bundle 清单)+ `node_modules`(pnpm 装的插件,可重建) | `apps/cli/README.md` | 编辑 profile = 编辑 `cordis.patch.yml`;插件清单在 `package.json` | +| 升级:用户数据全在 `DSH_HOME`(npm 安装树之外),升级 `@deepseek-ai/dsh` 后数据保留;`node_modules` 由 pnpm 重建 | README、CLI README | "保留数据 + 重启新版容器"可行 | +| 公网部署:`--public-url ` + `--trusted-host`;代理必须 **剥前缀、保留 Host、把 `Path=/` 的 cookie 改写为 mount + 加 Secure** | `docs/user/guide/public-deployments.md` | 面板反代要处理 cookie/前缀,是最复杂点 | +| 启动 URL 带 **per-process token**,首次访问换签名 cookie | 同上 | 面板需从容器日志取 token,服务端代换 cookie | + +> 结论:DSH 不是为容器多租户设计,但靠"自建镜像 + 容器内反代 + 面板反代桥接 token/cookie + 每实例独立 DSH_HOME"可稳态支撑。 + +--- + +## 1. 目标与范围 + +- 通过 docker.sock 在单机上**创建/启停/删除**多个 `dsh web` 容器,**每用户最多 2 个**。 +- 每用户一个**独立工作区**,支持文件**上传/下载/删除/重命名**,并有**容量配额**。 +- 工作区**映射进每个 dsh 容器**(agent 在工作区内干活)。 +- **备份/还原 dsh 配置**(含模型与插件清单)。 +- **编辑 profile 文件**(`cordis.patch.yml`)。 +- **升级**:保留数据,用新版本镜像重启容器。 +- **本地镜像管理**:面板查询 DSH 最新版本,用户手动触发本机构建;创建实例时选择本地已有版本,或手动构建最新版后创建。不需要私有镜像仓库,不推送生成的镜像。 +- 控制面:Go 后端 + SPA 前端;内置账号 + 目录配额;**面板自身容器化运行**。 + +--- + +## 2. 总体架构 + +面板不是宿主进程,而是挂了 `docker.sock` 的**兄弟容器**(sibling),通过 sock 编排 dsh 兄弟容器,并通过同一 bridge 网络反代它们。 + +``` +┌────────────── 宿主 ──────────────────┐ +│ docker.sock ──┐ │ +│ /var/lib/dsh-mgr ──┐ (bind) │ +│ │ │ +│ ┌─────────────────▼─────────────▼───┐ │ dsh-net (bridge) +│ │ 面板容器 dsh-mgr │ │ :80/:443 发布 +│ │ - /data ← /var/lib/dsh-mgr │ │ +│ │ - /var/run/docker.sock │ │ +│ │ - SPA + API + 反代 + 编排引擎 │ │ +│ └─────────┬───────────────────────────┘ │ +│ │ sock │ +│ ┌─────────▼───────────┐ ┌───────────────┐ │ +│ │ dsh-web: 容器1 │ │ 容器2 ... │ │ 不发布端口 +│ │ caddy:8080 → 3080 │ │ │ │ /data/dsh-home、/workspace ← 宿主 bind +│ └─────────────────────┘ └───────────────┘ │ +└─────────────────────────────────────────────┘ +``` + +组件: +1. **面板服务**(Go 容器):REST API + 静态 SPA + 反向代理子系统 + docker client + 配额/备份/升级引擎。 +2. **dsh-web 镜像**(自建):Node + `@deepseek-ai/dsh` + caddy + 入口脚本。 +3. **dsh-net**:内部 bridge 网络,容器不发布主机端口,仅面板可达。 +4. **(可选)socket-proxy**:面板与 sock 之间的白名单代理。 + +--- + +## 3. 宿主路径 ↔ 面板内路径映射(关键) + +Docker bind 的 `Source` 必须是**守护进程所在宿主路径**,不是面板容器内路径。面板容器把宿主 `/var/lib/dsh-mgr` 挂到 `/data`,于是同一文件树有两个视角: + +| 用途 | 面板直接读写(容器视角) | 创建 dsh 容器 bind.Source(宿主视角) | +|---|---|---| +| 用户工作区 | `/data/workspaces//files/...` | `/var/lib/dsh-mgr/workspaces//files` | +| 实例 DSH_HOME | `/data/workspaces//instances//home/...` | `/var/lib/dsh-mgr/workspaces//instances//home` | +| 备份包 | `/data/backups///...` | (不入容器,仅面板读写) | + +面板配置: +``` +HOST_DATA_ROOT=/var/lib/dsh-mgr # 传给 docker API 的 bind 源 +CONTAINER_DATA_ROOT=/data # 面板自己 fs 操作的根 +``` + +- 所有"面板直接动文件"(文件 API、备份 tar、profile 读写)用 `CONTAINER_DATA_ROOT`。 +- 所有"喂给 docker API 的 bind.Source / build context"用 `HOST_DATA_ROOT` 拼接。 +- 封装 `toHostPath(containerPath)` 翻译函数集中调用,避免散落拼接出错——这是 sibling 容器最常见的 bug。 + +--- + +## 4. 数据与目录布局(宿主机) + +``` +/var/lib/dsh-mgr/ + db.sqlite # 用户、实例、配额、备份索引 + workspaces// + files/ # 用户工作区(面板文件 API + 各 dsh 容器共享挂载) + └─ <用户自由目录结构> + instances// + home/ # DSH_HOME(bind → 容器 /data/dsh-home) + .credentials.yaml # API Key(机密) + cordis.patch.yml # home 级 patch + profiles/web/ + package.json # 插件依赖 + dsh.profile bundle 清单 + cordis.patch.yml # profile 级 patch(= "profile 文件") + node_modules/ # 插件实体(可重建,不入备份) + skins/ pets/ agent-presets/ .agent-presets/ + dsh-ssh.json # 0600 + sessions/ worktrees/ # 运行态(不入配置备份) + backups/// + .tar.gz # 配置备份包 + # 镜像层由本机 Docker daemon 管理,不作为此目录下的文件保存;DB 记录镜像索引 +``` + +**配额作用域**:`workspaces//` 整棵树(files + 所有 instances 的 home)。每用户一个配额单位。 + +--- + +## 5. 自建 DSH 镜像设计 + +``` +FROM node:22-slim +# 1) 装 dsh CLI(版本参数化) +ARG DSH_VERSION=0.2.0-rc.2 +RUN npm i -g @deepseek-ai/dsh@${DSH_VERSION} +# 2) 装 caddy(容器内反代,绕过 dsh 拒绝 0.0.0.0) +COPY caddy /usr/bin/caddy +COPY Caddyfile /etc/caddy/Caddyfile # :8080 { reverse_proxy 127.0.0.1:3080 } +COPY entrypoint.sh /entrypoint.sh +ENV DSH_HOME=/data/dsh-home +WORKDIR /workspace +ENTRYPOINT ["/entrypoint.sh"] +# CMD 由面板按实例注入:dsh web --no-open --port 3080 --public-url --trusted-host +``` + +- `entrypoint.sh`:`tini` 接管信号 → 起 caddy(后台)→ `exec dsh "$@"`。dsh 绑 loopback:3080,caddy 绑 0.0.0.0:8080。 +- **存储方式**:生成的镜像仅保存在本机 Docker daemon,不推送 registry,也不从 registry 拉取成品 `dsh-web` 镜像。所有用户共享面板管理的本地镜像目录。 +- **版本查询**:面板从 npm 查询 `@deepseek-ai/dsh` 的 `dist-tags.latest`,展示精确版本和查询时间;查询不触发构建或升级。不通过版本字符串排序猜测最新版。查询失败时保留本地版本列表,缓存结果需标明过期。 +- **版本固定**:用户确认构建时锁定页面展示的精确版本,后端校验其确实存在于 npm 元数据中;构建过程中不再次解析 `latest`,Dockerfile 不安装浮动的 `@latest`。 +- **本机构建**:Docker API `POST /build` 接受 tar 流;面板将内置 `/image/` 的 Dockerfile+caddy+entrypoint+Caddyfile 打包后提交给 daemon,并通过 Docker API 的 buildargs 传入 `DSH_VERSION`。用户不能提交任意 Dockerfile、构建上下文、镜像地址或其他构建参数。 +- **镜像身份**:tag 使用 `dsh-web:-`,其中 `recipeRev` 标识镜像模板修订;记录精确 DSH 版本、模板修订、架构、本地 image ID 和构建时间。创建实例使用已确认的 image ID,不依赖可能被覆盖的 tag。 +- **构建任务**:手动触发后异步执行,展示排队、构建中、验证中、成功、失败状态及脱敏日志;任务持久化。同版本、模板修订和架构的并发请求复用同一任务,已有可用镜像直接复用。默认全局串行构建,并限制用户触发频率、任务时长及日志大小,构建前检查磁盘空间。 +- **可用性判定**:Docker build 成功后,用临时隔离容器验证版本和 DSH 启动就绪;验证通过才加入可选择列表。失败保留错误记录,不自动替换已有可用镜像。面板重启后对账未完成任务及本地镜像,不盲目重复构建。 +- **本地清单**:以 Docker inspect/list 和面板管理标签对账,展示精确版本、构建时间、可用状态及使用情况。外部删除导致 image ID 不存在时标记缺失,不再允许新建;管理员清理时保护被实例、待执行任务或回滚流程引用的镜像,不自动运行全局 prune。 +- **网络要求**:仅本地保存不代表离线构建;首次构建仍可能需要拉取基础镜像及下载 npm 包。网络故障不影响选择已存在的本地可用镜像。 + +**创建实例交互**: +1. **选择本地版本**:从本地可用列表选择版本,校验 image ID 仍存在后创建。 +2. **构建最新版并创建**:查询并展示精确版本 → 用户确认 → 事务预留实例名额 → 构建或复用任务 → 验证成功 → 用该 image ID 创建实例。等待构建的实例也计入每用户 2 个名额;构建/创建失败或取消时清理未完成资源并释放名额,不回退到其他版本。取消某个用户的等待不取消其他用户共享的构建任务。 +3. 面板另提供独立的“构建最新版”按钮,仅生成本地镜像,不创建或升级实例,也不占实例名额。 + +--- + +## 6. 容器生命周期(docker.sock + socket-proxy) + +Go 端用 `github.com/docker/docker/client`: + +- **创建**: + - Image = 本地可用镜像的固定 image ID(对应精确 DSH 版本及模板修订,见 §5) + - Env:`DSH_HOME=/data/dsh-home`(密钥不入 env;用户在 dsh UI 里设 key,落到 `.credentials.yaml`) + - Binds(Source 用 **HOST 路径**): + - `workspaces//instances//home` → `/data/dsh-home` + - `workspaces//files` → `/workspace` + - Network = `dsh-net`(不发布端口) + - Cmd = `web --no-open --port 3080 --public-url https:///dsh// --trusted-host ` + - Labels:`dsh-mgr.uid`、`dsh-mgr.instId`、`dsh-mgr.version`、`dsh-mgr.role=dsh` + - 资源:`--memory=2g --cpus=2`(可配);`--tmpfs /tmp`;只读 rootfs + 必要写入挂载 +- **操作**:start/stop/restart/remove/logs/inspect/exec,全走 SDK。 +- **2 实例上限**:按逻辑实例在 DB 事务中预留名额,等待构建/创建中的实例也占名额;失败清理后释放。Docker labels 用于对账,不作为并发名额控制。 +- **对账**:面板启动时按 label 枚举容器与 DB 对账(孤儿容器回收或纳管)。 +- **sock 安全**:面板挂裸 sock = 等效宿主 root。缓解: + 1. **socket-proxy**(推荐):只放行白名单 API(containers/exec/images/networks/volumes/logs/build),关 plugins/daemon/swarm。面板连 proxy 而非裸 sock。 + 2. 面板自身非 root、只读 rootfs + tmpfs /tmp、资源限制。dsh 容器不 `--privileged`、去 cap。 + +--- + +## 7. 网络与访问方式 + +- 面板**启动时 ensure** `dsh-net`(bridge)存在,并把自身加入(compose 声明或运行时 `network connect`)。 +- dsh 容器挂 `dsh-net`,**不发布主机端口**。 +- 面板反代 `/dsh//` → `http://.dsh-net:8080`,容器名 = instId。 +- **访问方式(待定,见 §18)**:默认推荐"路径前缀反代 + token 桥接"(§8),面板天然为 ingress。备选:子域名(需泛 DNS+TLS)/ 直发端口(MVP,隔离弱)。 + +--- + +## 8. 访问与认证(token 桥接 + cookie 改写) + +> 此为最复杂点。推荐方案:路径前缀反代 + token 桥接。 + +- 面板路由 `/dsh//*` → 反代到 `http://.dsh-net:8080`,**剥掉 `/dsh/` 前缀**,**保留浏览器 Host**,加 `X-Forwarded-*`。 +- **token 桥接**:容器启动后,面板从 `docker logs` 流式抓 dsh 打印的 `http://127.0.0.1:3080/?token=xxx` 里的 token;用户首次打开 `/dsh//` 时,面板服务端用该 token 打一次容器换 cookie,再把 `Set-Cookie` 的 `Path=/` 改写为 `Path=/dsh/`、加 `Secure`/`HttpOnly`/`SameSite=Lax` 下发给浏览器。后续请求带该 cookie 透传。 +- **鉴权**:面板先校验用户登录 + 该 instId 归属当前 uid,再放行反代。dsh 自身 token cookie 为第二层。 +- **WebSocket/SSE**:反代需支持 WS 升级;cookie 改写后需实测 WS 握手是否正常(关键风险)。 + +## 9. 工作区文件管理 + 容量配额 + +**文件 API**(path 校验:解析后必须在 `files/` 内,拒 `..`/绝对路径): +- `GET /api/ws/tree?path=` 列目录(带 size/mtime) +- `POST /api/ws/mkdir`、`POST /api/ws/rename`、`DELETE /api/ws/delete` +- `POST /api/ws/upload`(分片/流式,**写前查配额**) +- `GET /api/ws/download?path=`(单文件流;目录自动 zip) +- `GET /api/ws/usage`(当前用量/配额) + +**配额实现**(容器化面板的约束): +- **默认:软配额**(无特权,随处可跑)。面板写前 `du` 校验 + 周期巡检 + 写入并发限流。并发大文件可能短暂超限。 +- **可选硬配额**(二选一,需运维确认): + - **XFS prjquota**:面板 `--privileged` + `cap_add:[SYS_ADMIN,CHOWN]` + 把宿主 XFS 挂载点 bind 进来,面板内对 `/data` 跑 `xfs_quota`/`chattr -p`。每用户唯一 project ID(uid+1000)。 + - **ZFS refquota**(若宿主用 ZFS):面板 `--privileged` + zfsutils,每用户 `zfs create pool/users/ && zfs set refquota=10G`,再 bind 该 dataset 到 `/data/workspaces/`。 + - 或在宿主跑**极小特权配额 sidecar**(gRPC/HTTP),面板远程调它做 quota 操作——面板自身保持非特权。 +- 配额值存 DB,按用户可配(默认 10GiB)。面板展示用量。 + +> 具体选型见 §18 待拍板。 + +--- + +## 10. 配置备份与还原(含模型与插件) + +**备份内容**(tar.gz,**排除可重建/运行态**): + +| 包含 | 说明 | +|---|---| +| `.credentials.yaml` | 模型 API Key(机密)→ 备份包加密 | +| `cordis.patch.yml`(home 级) | home patch | +| `profiles/web/package.json` | **插件清单**(含 dsh.profile bundle 列表) | +| `profiles/web/cordis.patch.yml` | **模型/provider 配置** + apiKeyEnv 引用 | +| `profiles/web/pnpm-lock.yaml`(若有) | 插件版本锁 | +| `skins/ pets/ agent-presets/ .agent-presets/` | 用户资源 | +| `dsh-ssh.json` | SSH 凭据(0600,加密入包) | +| `manifest.json` | dsh 版本、备份 schema 版、时间戳 | + +**排除**:`node_modules/`(pnpm 重建)、`worktrees/`、`sessions/`、日志。 + +**备份包加密**:用用户密码派生密钥(Argon2id → AES-GCM)加密整包;密钥不落库。下载/上传均加密包。 + +**备份流程**:停容器(或仅停写)→ tar 指定文件 → 加密 → 落 `backups///` + 索引入库 → 重启容器。支持立即下载。 + +**还原流程**:校验+解密包 → 停容器 → 备份当前 home 为 `.pre-restore.bak` → 覆盖配置文件(不动 node_modules)→ **按 `package.json` 重建插件**(`dsh plugin --profile web install` 或 entrypoint 首启时 pnpm reconcile)→ 启容器 → 健康检查 → 失败则回滚 `.pre-restore.bak`。 + +--- + +## 11. profile 文件编辑 + +- 编辑对象:`profiles/web/cordis.patch.yml`(YAML)。 +- 前端用 Monaco 编辑器;后端 `GET` 返回原文、`POST` 校验(YAML 合法 + 可选 `--dump-config-schema` 比对)后原子写(临时文件 + rename,`dsh-atomic-write` 风格)。 +- 保存后**重启容器**使生效(dsh 有 `dsh-hmr`,但配置类变更稳妥起见重启;后续可探热更)。 +- 只读展示 `dsh.profile`(bundle 清单)与 `--dump-config` 预览(面板 `docker exec` 跑 `dsh --profile web --dump-config` 取回渲染)。 +- **不暴露** `.credentials.yaml` 原文(write-only 语义)。 + +--- + +## 12. 升级流程(保留数据 + 重启新版容器) + +``` +1. 用户选择本地可用目标版本;若需最新版,先手动触发 §5 构建并等待验证成功。锁定本地 image ID 后才进入备份/停机流程,不自动 pull 成品镜像 +2. 自动备份当前配置(§10)→ 标记 pre-upgrade 备份 +3. docker stop (不 remove,保留作回滚) +4. docker create :同名 instId、同 binds(home + files)、同 labels、新 image、 + Cmd 按新版本要求重算 --public-url/--trusted-host +5. entrypoint 首启:若 profiles/web/node_modules 缺失/与 package.json 不一致 → pnpm install 重建插件 +6. docker start → 面板抓 token、健康检查(轮询 caddy :8080) +7. 成功:docker rm ;失败:stop 、start 、标记回滚、提示用户 +``` + +关键:**bind 不变 ⇒ 数据零丢失**;`node_modules` 在新镜像里按 `package.json` 重建(跨版本兼容性由 pnpm/dsh-plugin-manager 保证)。 + +--- + +## 13. 安全与资源 + +- **docker.sock**:面板容器能访问 sock 即等效宿主 root;socket-proxy 白名单 + 面板非 root + 只读 rootfs 缓解。 +- dsh 容器:**不 `--privileged`**、去 CAP_NET_RAW 等、只读 rootfs、tmpfs /tmp、mem/cpu 限制、不挂 sock。 +- 面板账号:bcrypt + JWT(短期 access + refresh),失败锁定。 +- 备份包加密、`.credentials.yaml` 不经 API 明文返回。 +- 路径穿越校验、上传大小/类型白名单(可配)。 +- 审计日志:容器操作/备份/还原/升级/profile 改动入库。 + +--- + +## 14. 技术栈与项目结构 + +- **后端**:Go 1.22+,`net/http`(或 chi/gin),`github.com/docker/docker/client`,`github.com/docker/go-connections`,sqlite(`modernc.org/sqlite` 纯 Go 驱动,免 CGO),Argon2id、AES-GCM。 +- **前端**:Vite + React + TypeScript,Monaco(profile 编辑),shadcn/ui。 + +``` +dsh-mgr/ + cmd/server/ # Go 入口 + internal/ + auth/ # 账号/JWT + docker/ # 容器编排(镜像、生命周期、对账、路径翻译) + workspace/ # 文件 API、配额(软 du + 可选 xfs/zfs) + proxy/ # /dsh/ 反代 + token 桥接 + cookie 改写 + backup/ # 备份/还原/加密 + upgrade/ # 升级编排 + profile/ # cordis.patch.yml 编辑 + store/ # sqlite schema/查询 + api/ # HTTP handlers + web/ # SPA + image/ # 自建 dsh-web 镜像(Dockerfile + Caddyfile + entrypoint.sh + caddy 二进制) + deploy/ # 面板自身 docker-compose / socket-proxy / systemd +``` + +--- + +## 15. 数据模型(sqlite 概要) + +- `users(id, username, pass_hash, quota_bytes, created_at)` +- `instances(id, uid, name, dsh_version, image_id, build_id, status, container_id, created_at, ...)` +- `backups(id, uid, inst_id, path, size, schema_ver, created_at, kind=auto|manual|pre-upgrade|pre-restore)` +- `images(image_id, version, recipe_rev, platform, tag, status, built_at)`(本地镜像索引,status=validating|available|failed|missing;与 Docker 实际状态对账) +- `image_builds(id, requested_by, version, recipe_rev, platform, status, image_id, error, created_at, finished_at)`(持久化构建任务;同版本+模板修订+架构的活动任务去重) +- `audit(id, uid, action, target, detail, at)` + +--- + +## 16. 面板自身部署(compose 示例) + +```yaml +services: + dsh-mgr: + image: dsh-mgr:latest + restart: unless-stopped + ports: + - "80:80" # 前置 TLS 反代或直接 443 + user: "65532:65532" # 非 root + environment: + HOST_DATA_ROOT: /var/lib/dsh-mgr + CONTAINER_DATA_ROOT: /data + PANEL_PUBLIC_URL: https://dsh.example.com + DSH_NET_NAME: dsh-net + DEFAULT_QUOTA_BYTES: 10737418240 + DOCKER_HOST: unix:///var/run/docker.sock # 或指向 socket-proxy + volumes: + - /var/lib/dsh-mgr:/data + - /var/run/docker.sock:/var/run/docker.sock:ro # 或 socket-proxy + networks: [dsh-net] + read_only: true + tmpfs: [/tmp] + deploy: { resources: { limits: { memory: 1g } } } + # 硬配额(XFS)可选: privileged: true; cap_add: [SYS_ADMIN,CHOWN]; volumes: [xfs mount] + + # 可选 socket-proxy + socket-proxy: + image: tecnativa/docker-socket-proxy + environment: + CONTAINERS: 1; EXEC: 1; IMAGES: 1; NETWORKS: 1; VOLUMES: 1; LOGS: 1; BUILD: 1 + POST: 1 + volumes: [/var/run/docker.sock:/var/run/docker.sock:ro] + networks: [dsh-net] + restart: unless-stopped + +networks: + dsh-net: { driver: bridge } +``` + +--- + +## 17. 关键风险与待验证 + +1. **token/cookie 桥接**(§8)最脆——需实测 dsh token 换 cookie 在反代+前缀下的行为,确认 cookie 改写后 **WS/SSE 正常**(dsh web 可能有 WebSocket)。 +2. **插件首启重建**:跨 dsh 版本 `pnpm install` 在 entrypoint 无网时失败——可能需升级时由面板 `docker exec` 联网重建后再切流量。 +3. **XFS/ZFS 硬配额**依赖宿主 FS 与特权/agent;否则退化为软配额。 +4. **dsh agent 工作目录**:`dsh-fs-local`/`dsh-fs-sandbox` 的根是否等于 cwd——需确认 `/workspace` 作为 cwd 即可让 agent 在工作区操作,或需额外配置 fs root。 +5. dsh 版本仍为 `0.2.0-rc.*`(RC),API/flag 可能变动——需维护版本兼容矩阵。 +6. **sibling bind 路径翻译**:`toHostPath` 出错会导致 bind 挂错目录或挂空目录,需单测覆盖。 +7. **本地镜像构建**:验证 npm 查询失败仍可选本地版本、并发构建去重、失败释放实例名额、面板重启对账、镜像被外部删除,以及构建磁盘不足等路径;构建最新版不保证兼容,必须通过启动验证。 + +--- + +## 18. 待拍板决策点(open) + +**已确认:镜像仅保存在本机;面板支持查询并手动构建最新版;创建实例可选本地已有版本,或构建最新版后创建。不需要 registry/CI 镜像分发。** + +> 以下访问方式和配额选型仍待确认;此前审查发现的隔离、文件安全和升级回滚问题仍需另行修订。 + +1. **访问方式**:路径前缀反代 + token 桥接(推荐,面板天然 ingress)/子域名(需泛 DNS+TLS)/直发端口(MVP,隔离弱)。 + - **推荐默认**:路径前缀反代。 +2. **配额**:宿主 FS 是 XFS / ZFS / 其他?是否接受"特权面板"或"宿主配额 sidecar"做硬配额?还是软配额默认即可? + - **推荐默认**:软配额(du)起步,硬配额作为后续增强;若宿主为 XFS/ZFS 且可接受特权,则上硬配额。 + +--- + +## 19. 里程碑 + +1. **M1 镜像 + 单容器手动跑通**:自建本地 `dsh-web` 镜像,验证精确版本、caddy+dsh 启动与就绪。 +2. **M2 面板骨架**:账号/鉴权 + npm 最新版查询 + 本地镜像列表/手动构建任务 + 创建时选本地版本或构建最新版 + docker 编排(创建/启停/删除/2 上限)+ 对账 + 路径翻译。 +3. **M3 反代 + token 桥接**:`/dsh//` 可用(含 WS)。 +4. **M4 工作区文件 API + 软配额**。 +5. **M5 备份/还原 + profile 编辑**。 +6. **M6 升级流程 + 回滚**。 +7. **M7 SPA 完善 + 审计 + 资源限制 + 可选硬配额**。 + +--- + +## 附:调研依据(关键 URL) + +- 仓库:https://github.com/deepseek-ai/deepseek-harness +- README:https://raw.githubusercontent.com/deepseek-ai/deepseek-harness/master/README.md +- CLI README:https://raw.githubusercontent.com/deepseek-ai/deepseek-harness/master/apps/cli/README.md +- web-app README:https://raw.githubusercontent.com/deepseek-ai/deepseek-harness/master/packages/bundle/web-app/README.md +- providers 指南:https://raw.githubusercontent.com/deepseek-ai/deepseek-harness/master/docs/user/guide/providers.md +- 公网部署指南:https://raw.githubusercontent.com/deepseek-ai/deepseek-harness/master/docs/user/guide/public-deployments.md +- npm 包:https://registry.npmjs.org/@deepseek-ai/dsh/latest diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..334e3be --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM node:24-bookworm-slim AS web +WORKDIR /src/web +COPY web/package*.json ./ +RUN npm ci +COPY web/ ./ +RUN npm run build + +FROM golang:1.26-bookworm AS backend +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY cmd/ ./cmd/ +COPY internal/ ./internal/ +COPY image/ ./image/ +RUN CGO_ENABLED=0 go build -trimpath -o /out/dsh-mgr ./cmd/server + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=backend /out/dsh-mgr /usr/local/bin/dsh-mgr +COPY --from=web /src/web/dist ./web/dist +COPY image/ ./image/ +USER 1000:1000 +EXPOSE 8088 +ENTRYPOINT ["/usr/local/bin/dsh-mgr"] diff --git a/OPERATIONS.md b/OPERATIONS.md new file mode 100644 index 0000000..72f8934 --- /dev/null +++ b/OPERATIONS.md @@ -0,0 +1,64 @@ +# 本机或可信团队试运行运维 + +本版本面向本机或受信任团队,不应直接用于公网不可信租户。DSH 与面板使用同源路径代理;Docker socket 权限等价于宿主 root。 + +## 启动 + +1. 复制 `.env.example` 为 `.env`,填写专用绝对目录 `DSH_HOST_DATA_ROOT` 和 12–72 字节的随机管理员密码。限制 `.env` 的读取权限,不要提交。 +2. 面板以 UID/GID 1000 运行。首次部署前由管理员创建专用数据目录并赋予 1000:1000 所有权、0700 权限;不要对已有未知目录递归改权限。将 `stat -c %g /var/run/docker.sock` 的结果填入 `.env` 的 `DOCKER_GID`,以附加组访问 socket。然后运行 `docker compose up --build -d`。默认访问 `http://127.0.0.1:8088`;远程管理优先使用 SSH 本地端口转发,保留此访问地址。 +3. 登录后在镜像页提交精确 DSH 版本或查询最新版再手动构建。已有本地镜像会复用,但必须通过启动验证才允许创建实例。镜像不推送至仓库。 +4. 创建实例后从面板进入 DSH。不要给 runtime 发布宿主端口或挂载 Docker socket。 + +首次启动创建管理员。修改环境变量不会重设已有账号密码。容器内 `/data` 必须对应 `HOST_DATA_ROOT` 指向的同一宿主目录,否则 sibling 容器会挂载错误数据。 + +新镜像构建前必须检查 Docker 存储空间:将 `docker info --format '{{.DockerRootDir}}'` 返回的绝对目录填入 `DSH_DOCKER_ROOT_DIR`。Compose 将该目录以相同路径只读挂载,面板只用 statfs 检查至少 2 GiB 可用空间;这不是授权浏览或修改 Docker 内部文件。映射无法确认、不可访问或空间不足时,新构建拒绝,已有本地镜像复用仍可进行。不要用面板数据盘冒充 Docker 存储盘。 + +## 公网 HTTPS 入口(仅可信账号) + +反向代理与证书由部署管理员管理,面板仍建议只监听代理可达的私有入口。设置 `PANEL_PUBLIC_URL=https://你的域名`(仅 origin,不含业务子路径);Compose 默认值仍为本机 HTTP。此值决定 Cookie 的 Secure 属性及 Origin/CSRF 校验,不从客户端的 X-Forwarded-Proto/Host 推断。 + +默认只按直连来源 IP 限流,不信任 X-Forwarded-For。经反代部署时,设置 `TRUSTED_PROXY_CIDRS` 为面板实际看到的代理来源地址/CIDR,且确保只有这些代理能连接后端、代理正确维护转发链;不要填写全网 CIDR。未设置时,代理后所有用户将共享该代理来源的每分钟登录额度。公网开放前应实际验证不同客户端来源识别、登录/刷新/退出、DSH iframe、WS/SSE及插件设置接口。 + +账号由管理员创建,不支持不可信租户。软配额、同源插件与 Docker socket 权限边界不因 HTTPS 而改变;本版本不承诺抵御分布式拒绝服务。 + +## 配置与全量备份 + +面板提供加密配置与资源备份:固定配置、插件清单/锁文件、skins、pets、agent-presets、.agent-presets及manifest;不包含会话、node_modules或工作区。新包用创建时的登录密码经Argon2id派生密钥、AES-GCM加密,不依赖或创建 `backup.key`。导入/恢复使用原备份密码;修改登录密码不会重加密旧包。旧版密钥包仍兼容,只有这类包依赖原 `backup.key`,应单独安全保管,不能因升级删除。所有包均可能含凭据。 + +配置备份自动停止原运行实例并恢复运行;恢复先校验包,再快照、写入、启动验证,失败尝试回滚,成功保留原运行/停止状态。当前模板7启动前按清单协调插件;历史跨版本及离线失败验证范围见 ACCEPTANCE.md,不保证任意第三方插件兼容。配置内容上限32 MiB/4096文件,备份入口最多保留20个包;快照和保留上限需管理员监控清理。此流程不能代替全量备份。 + +要获得一致的全量备份,先通过面板停止所有实例,再停止面板,使用宿主备份工具复制专用数据目录(包含数据库、可能存在的 WAL 文件、工作区、实例 home、快照和密钥)。只停止面板不会自动停止 sibling 实例。恢复时保留权限和所有权,将整个目录恢复到同一部署的配置路径;不要将运行中的数据库文件单独覆盖。备份应在隔离环境定期验证。 + +## 升级与故障 + +运行时认证材料由面板首次成功连接实例时提取,并使用 `auth-signing.key` 派生的密钥加密保存在数据根的 `runtime-auth/`(不在用户home内)。按容器 ID 和启动时间绑定,不会将旧启动周期凭据用于新容器;不同 Host 分别缓存 cookie。全量备份应连同签名密钥和该目录保存,不要向用户公开。 + +运行时返回401/403时会使该Host的cookie缓存失效,下次请求重新交换。不会自动重放写请求,避免插件操作重复执行;遇到认证失败请重新加载页面后确认操作结果。若runtime自行轮转了启动token且旧token也不可交换,需要管理员处理,不能保证任意上游认证机制变更自动恢复。 + +升级到本版本后应在维护窗口逐个访问现有实例,完成首次认证持久化。若在首次成功连接前,启动token已被Docker日志轮转删除,本版本无法凭空恢复;先保存工作,再由管理员安排重启相关实例并访问验证。不要为此删除或重建实例。初次日志扫描上限64 MiB,常规每文件10 MiB/3份保留在此范围内。 + +- 实例升级选择已经启动验证的本地镜像并提供当前登录密码,面板自动停机、创建密码配置备份与home快照、启动验证及失败回滚,无需先手动停止。home快照最大1 GiB;允许受约束的内部相对链接以支持pnpm,拒绝不安全链接/特殊文件。模板7的 `/workspace` 是实例home,包含在home回滚内;旧共享数据不回滚。跨DSH版本历史验证范围见 ACCEPTANCE.md。 +- Profile是例外:当前需先停止实例,再使用“保存并启动”;页面包含Monaco和脱敏配置预览。不要把备份/升级自动停启理解为Profile可在线改写。 +- 若实例进入 `error`,不要直接修改数据库状态强制启动。保留原容器、实例目录与 `upgrade_journal` 记录,检查实际容器与 journal 的旧/新容器 ID、阶段、快照路径是否一致。 +- 人工恢复前停止相关容器并另做完整备份。中断点不同,不能统一用一次目录覆盖或状态修改处理;当前无自动灾难恢复命令。 +- 删除实例会删除其容器与网络,但保留数据目录。升级快照暂无自动清理;不要删除仍被未完成 journal 引用的快照。 + +## 容量与网络 + +实例文件请求只在检查归属和登记使用状态时取得生命周期锁,上传/下载期间不持全局生命周期锁。同一实例存在文件操作时,启停、删除、配置修改、恢复及升级返回 409,等待传输结束后重试。文件树单目录最多列出 10000 项,超限返回 413;用量与快照遍历仍有总条目/深度上限。此限制不是文件系统硬配额。 + +用户软配额覆盖工作区与实例目录,已接入上传、Profile增长、恢复/升级快照的写前检查及写入串行化,并每5分钟巡检记录超额/恢复审计;覆盖范围见 ACCEPTANCE.md。它不能阻止DSH直接写盘或插件安装增长。监控数据目录和Docker存储空间,快照也消耗容量。硬quota需宿主文件系统和管理员配置,是可选增强,不是默认路线必做项。 + +每个实例配置独立bridge、非root、只读根文件系统和资源上限,但并非完整网络安全边界:未限制访问宿主、局域网或互联网。公网不可信租户需另外设计独立origin、可信TLS入口与出站策略,并按威胁模型评估硬配额。不要给runtime发布端口;server-held DSH cookie不下发浏览器,不能代替面板归属鉴权。 + +## 验收 + +按版本分层记录的验收结果见 [ACCEPTANCE.md](ACCEPTANCE.md)。以下命令是检查入口;历史通过记录不能替代当前版本回归,自动化测试不能替代实际公网域名与浏览器验收。 + +```sh +bash scripts/check-trial.sh +``` + +该脚本执行本地自动化测试、race、静态检查及前后端构建,不启动容器、不安装依赖、不部署。需提前准备 Go/Node/npm 和前端依赖。可通过 `TEST_TMPDIR` 指定已存在的临时目录。镜像构建单测已隔离真实磁盘空闲量;生产磁盘检查仍保留。 + +`scripts/test-panel.mjs` 必须指向独立测试部署,通过 `TEST_PANEL_URL`、`TEST_USERNAME`、`TEST_PASSWORD` 指定访问参数;`TEST_UPGRADE=1` 包含同镜像替换升级。它会创建及删除测试实例、留下保留策略要求的数据,并可能构建或复用本地 runtime 镜像。不要对生产账号运行。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..30aa177 --- /dev/null +++ b/README.md @@ -0,0 +1,196 @@ +# DSH Web Manager + +面向本机或受信任团队的DSH容器管理面板,使用Go、React和SQLite。目标见 [DESIGN.md](DESIGN.md),接口见 [API.md](API.md),部署和恢复见 [OPERATIONS.md](OPERATIONS.md)。不适合直接作为公网不可信多租户平台。 + +本项目定位为本机或可信团队试运行,可由管理员通过 HTTPS 反代提供公网入口;不支持不可信租户。选型差异及真实验证边界见 [ACCEPTANCE.md](ACCEPTANCE.md)。 + +## DSH 工作台与移动端 + +登录后默认进入内嵌 DSH 工作台。在“设置”中选择当前账号的实例;选择仅保存在当前浏览器并按账号区分,不保存登录凭据。没有实例时先到“实例”创建,停止或异常的实例需先启动或处理。切换实例会重新加载内嵌页面,但不会停止原容器。 + +移动端使用底部导航:DSH、实例、文件、设置;桌面保留侧栏。镜像管理和管理员功能从设置进入。切换面板栏目会保留内嵌 DSH 页面,提供重新加载及独立窗口入口。此为同源 iframe,不是安全隔离边界,仍仅适合本机或受信任团队。 + +## 当前实现 + +- 内置账号、管理员配额、短JWT与refresh轮换、失败锁定及审计。 +- 精确版本查询、本地镜像异步构建/验证/清理、持久创建预约与事务名额(每用户最多两个实例)、启动对账。预约不再仅保存在当前页面。 +- 实例启停删除、独立home(模板7挂载为 `/workspace`)、每实例bridge、归属鉴权及路径反代;DSH认证cookie由服务端保管。 +- 文件上传/下载/重命名/删除及目录ZIP;软配额写前检查、写入串行化与周期巡检。上传最大32 MiB且不覆盖已有文件,删除目录须先清空;删除实例保留数据。 +- 密码加密配置备份、资源目录与manifest、外部加密包导入、恢复健康检查及回滚。备份不再限于六个固定文件;不包含会话、工作区及node_modules。 +- 备份/恢复/升级自动编排停启;升级保存密码配置备份和home快照,失败尝试回滚。Profile使用本地Monaco及脱敏预览;目前仍需先停止实例,页面“保存并启动”随后验证就绪。 + +新备份使用创建时的登录密码经Argon2id派生密钥、AES-GCM加密,不依赖或创建 `backup.key`。导入/恢复须使用原备份密码,修改登录密码不会重加密旧包。旧版密钥包仍兼容,保留这些包时必须保管原 `backup.key`。 + +## 启动面板 + +需要Linux 5.6+(openat2/renameat2)、Go 1.25+、Node/npm及本机Docker daemon。Docker socket权限等价宿主root。 + +```sh +npm --prefix web ci +npm --prefix web run build +go build -o bin/dsh-mgr ./cmd/server +# 首次启动设置ADMIN_USERNAME及12–72字节强密码ADMIN_PASSWORD +./bin/dsh-mgr +``` + +默认访问 http://127.0.0.1:8088,数据在 `./data`。修改环境密码不会重置已有账号。容器部署使用 `.env.example` 和 `compose.yaml`,按运维文档准备UID/GID 1000数据目录、Docker socket附加组及Docker存储目录映射,再执行 `docker compose up --build -d`。Compose仅发布loopback。 + +## 公网 HTTPS 反向代理(Nginx / Caddy) + +以下两种方案任选一种,假设反代运行在面板所在宿主机,域名为 `dsh.example.com`,后端为 `http://127.0.0.1:8088`。将示例域名、证书路径替换为实际值。仅支持独立域名根路径部署,不要挂在 `/panel/` 等子路径下;所有路径(包括 `/api/` 和 `/dsh/`)统一转发到面板,不直接代理或公开 runtime 的端口。公网入口仍仅供可信账号使用,不是多租户安全隔离方案。 + +### 面板环境变量 + +使用 Compose 时在项目 `.env` 中设置;直接运行二进制时需通过进程环境传入(二进制不会自动读取 `.env`): + +```dotenv +PANEL_PUBLIC_URL=https://dsh.example.com +# 仅适用于反代和面板二进制均在宿主机、通过127.0.0.1连接的情况 +TRUSTED_PROXY_CIDRS=127.0.0.1/32 +``` + +`PANEL_PUBLIC_URL` 必须与浏览器访问的 origin 一致,不带路径;非标准 HTTPS 端口需包含端口号。它控制 Origin/CSRF 校验和 Secure Cookie,不依赖 `X-Forwarded-Proto` 推断。修改环境变量后需重启面板进程;Compose 部署应执行 `docker compose up -d panel` 使容器读取新环境,单纯 `docker compose restart` 不会更新环境。 + +注意 Docker 网络差异:宿主机反代访问 Compose 发布端口时,面板看到的来源可能是 Docker 网桥网关,而不是 `127.0.0.1`。此时应将 `TRUSTED_PROXY_CIDRS` 改为确认过的实际代理来源 IP `/32`(IPv6 用 `/128`),不要照抄上述值,也不要信任 `0.0.0.0/0`、`::/0` 或整个不受控内网。未配置信任时,转发头会被忽略,所有用户可能共享代理 IP 的登录额度。可通过后端连接观测确认来源,Docker 网络 inspect 只能提供候选网关信息,不能替代实际确认。 + +如果反代也运行在容器内,`127.0.0.1` 指向反代容器自身,不能使用下面的后端地址。应让反代与面板加入专用 Docker 网络,将后端改为 `panel:8088`(实际服务名),并按面板看到的代理容器地址设置可信来源;不要在同一受信网络放入不可信容器。后端8088和runtime端口不应直接暴露公网。 + +### Nginx + +示例可放入由 `http {}` 引入的 `/etc/nginx/conf.d/dsh.conf`;`map` 必须位于 `http` 上下文,不能放在 `server` 或 `location` 内。证书需提前申请并配置续期;示例不负责签发证书。 + +```nginx +map $http_upgrade $dsh_connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 80; + server_name dsh.example.com; + return 308 https://dsh.example.com$request_uri; +} + +server { + listen 443 ssl; + server_name dsh.example.com; + + ssl_certificate /etc/letsencrypt/live/dsh.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/dsh.example.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + + # 为multipart封装留余量;应用仍执行自己的上传/备份上限 + client_max_body_size 64m; + client_body_timeout 60s; + + location / { + proxy_pass http://127.0.0.1:8088; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $dsh_connection_upgrade; + + # 单层公网边缘代理:覆盖客户端提供的转发来源,不能原样信任 + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $http_host; + + proxy_buffering off; + proxy_request_buffering off; + proxy_cache off; + proxy_connect_timeout 10s; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + send_timeout 3600s; + } +} +``` + +`Upgrade/Connection` 支持 WebSocket,关闭响应缓冲可及时传送 SSE;读写超时指相邻读写的空闲时间,不是保证连接永久存活。不要改写或删除 `Origin`、Cookie、`X-CSRF-Token`,不要额外添加阻止同源 iframe 的 `X-Frame-Options: DENY`。 + +保存后先检查,再由管理员重载: + +```sh +sudo nginx -t +# 仅在检查成功后执行 +sudo systemctl reload nginx +``` + +### Caddy + +将以下内容加入 `/etc/caddy/Caddyfile`。域名 DNS 应指向该主机,80/443 需按证书签发方式可达;Caddy 默认自动申请/续期 HTTPS 证书并将 HTTP 重定向到 HTTPS。 + +```caddyfile +dsh.example.com { + request_body { + max_size 64MB + } + + reverse_proxy 127.0.0.1:8088 { + # 单层公网边缘代理,覆盖外部传入的XFF + header_up X-Forwarded-For {http.request.remote.host} + flush_interval -1 + transport http { + dial_timeout 10s + } + } +} +``` + +Caddy 自动支持 WebSocket,默认保留请求 Host,并设置转发协议信息;`flush_interval -1` 用于及时刷新流式响应。此处不设置较短的响应读取/流生命周期超时,避免中断长时间 SSE/终端连接。Caddy 重载可能关闭现有 WebSocket,客户端需重连。 + +```sh +sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile +# 仅在检查成功后执行(使用systemd安装时) +sudo systemctl reload caddy +``` + +以上示例均假设客户端直接连接这层反代。若前方还有 CDN、负载均衡或第二层代理,不能直接依赖这里的 `$remote_addr` / `remote.host` 得到真实客户端 IP;需要在边缘代理单独配置严格的可信上游及来源解析,并相应调整转发策略,否则用户会共享上一层代理的限流额度。 + +### 部署后检查 + +- 确认 HTTP 跳转 HTTPS,证书有效,公网只能到达反代而不能直接访问面板8088或runtime端口。 +- 通过实际域名完成登录、刷新、退出,确认无 Origin/CSRF 403,认证 Cookie 带 Secure;直接使用后端HTTP测试不能替代HTTPS验收。 +- 打开内嵌 DSH、终端/WebSocket、SSE及插件设置;空闲后重连、登录续期均需验证。 +- 验证文件上传/下载及备份导入;应用限制仍有效,反代64MB上限不代表单文件可上传64MB。 +- 使用不同真实客户端确认登录限流来源隔离;测试应使用专用账号,避免锁定实际账号。 + +这些是配置模板,不代表已在你的公网域名部署或验收;应用侧测试和运维边界见 [ACCEPTANCE.md](ACCEPTANCE.md) 与 [OPERATIONS.md](OPERATIONS.md)。 + +## 用户密码重设 + +所有已登录用户(包括管理员)可在「设置 → 账号 → 修改密码」输入当前密码、新密码及确认新密码。当前密码必须验证通过;成功后撤销该账号全部旧会话并返回登录页。忘记当前密码时仍需联系管理员,不提供匿名找回入口。 + +管理员可在「设置 → 用户与审计 → 用户列表 → 重设密码」为用户设置新密码(12–72 UTF-8字节,二次输入及确认)。重设会撤销目标账号全部登录会话和refresh令牌、清除登录失败锁定;旧密码不能再登录,审计仅记录操作者和目标,不记录密码。重设管理员自身会返回登录页。已建立的DSH WebSocket/SSE连接不保证在重设瞬间断开,后续HTTP请求、连接重建与会话续期会被拒绝。 + +## 本地runtime构建 + +当前模板为修订7,同时内置 `dsh-web-mobile@3.0.5` 与 `dsh-free-search@0.8.5`(MIT,https://github.com/DDDMUC/dsh-free-search)。Free Search及其普通依赖固定版本、校验SHA-512并保留镜像内原始tarball;新profile默认正式安装并启用,已有profile不自动注入。模板4的mobile恢复路径继续保留。仅包含内置固定依赖的profile在启动前离线安装,不限制运行期插件管理器联网。 + +Free Search默认引擎为Bing,注册的统一provider ID为ddg;未设置或等于deepseek-official的搜索provider会被它接管(包括显式选择同名官方provider),默认http抓取也会被接管,其他自定义provider保留。名称含free不等于保证零费用:自动回退可能读取已有API凭据并调用计费服务。请在插件设置中按需禁用引擎;fallback顺序不是引擎白名单。内置集成不自动测试搜索、配置密钥或执行在线更新。 + +插件设置页的八个固定API路径由面板在实例归属、登录和同源检查后适配前缀及loopback Host,不开放匿名根路径接口。用户在插件内更新到其他版本可能改变接口或离线依赖,需重新验证兼容性。旧实例需显式安装/启用;升级镜像不会覆盖其已有插件选择。 + +模板修订4默认内置 `dsh-web-mobile@3.0.5`(MIT,来源 https://github.com/mexiaosqwq/dsh-web-mobile)。构建校验固定SHA-512、不执行插件安装脚本;新建web profile使用镜像内tarball作为正式依赖并默认启用bundle,可在Plugins的Installed列表查看及启停。为兼容旧profile仍保留宿主副本;UI可能显示Uninstall,但本轮未验证卸载行为,不承诺移除镜像内置副本。已有profile不会被覆盖或强制启用;模板3旧实例须在升级后补正式依赖登记,仅升级镜像不会自动出现在Installed列表。该插件按触屏能力启用移动布局,仅缩窄桌面窗口不一定触发。 + +依赖固定引用 `file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz`,首次安装与备份恢复无需下载该包。备份包含依赖和bundle清单,恢复目标必须提供相同tarball路径(模板4及兼容后续镜像);初始化会在此依赖存在且workspace缺失时补建官方no-auto-peers配置,保留已有workspace及禁用状态。 + +需要Docker、Bash、npm及基础镜像/npm网络访问;不推送镜像。 + +注意:dsh-web-mobile还包含响应压缩、移动会话删除入口和自定义模型推理档位补全,并非纯CSS。内置版本随镜像固定,不跟踪GitHub main或npm latest。 +模板4目前限定DSH 0.2.0-rc.1/rc.2,并校验已安装宿主的Cordis/DSH peer版本;其他版本需先更新兼容验证,不会自动安装第二套宿主依赖。插件没有独立参数配置页;它修改DSH原有移动界面与设置页。 + +```sh +bash scripts/build-image.sh # 查询latest并锁定精确版本 +bash scripts/build-image.sh 0.2.0-rc.2 # 或指定精确版本 +bash scripts/smoke-image.sh dsh-web:0.2.0-rc.2-7 +``` + +当前tag为 `dsh-web:-7`(模板修订7)。runtime以UID/GID 1000运行,home和workspace需允许该身份读写。DSH监听loopback:3080,Caddy监听容器内8080;不要发布runtime端口或挂载socket。原生模块需要可执行的 `/tmp`,保留nosuid/nodev和容量限制。存在插件清单时先协调插件,失败不启动应用;真实验证范围见 ACCEPTANCE.md。 + +## 设计替代与边界 + +合理替代方案为:stdlib HTTP Docker client而非官方SDK;每实例独立bridge而非共享bridge;服务端保管DSH cookie而非下发浏览器;普通React组件而非shadcn/ui(保留Monaco)。这些是选型差异,不降低功能、安全或验收标准,详见验收表。 + +同源路径代理仅适用于可信用户/插件,尚无独立origin及宿主/局域网出站防护。默认软配额不能阻止agent直接写满磁盘;硬quota是依赖宿主的可选增强,不是默认路线必做项。升级/恢复快照需额外容量,home快照上限1 GiB,模板7的 `/workspace` 就是实例home,会随home快照回滚;旧共享目录不随回滚恢复;中断及回滚失败仍可能需要管理员恢复。 diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..6cbe0bc --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,137 @@ +package main + +import ( + "context" + "errors" + "fmt" + "git.misaka.ren/m1saka/dsh-web/internal/api" + "git.misaka.ren/m1saka/dsh-web/internal/store" + "golang.org/x/crypto/bcrypt" + "log" + "net/http" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" +) + +func main() { + if err := run(); err != nil { + log.Fatal(err) + } +} +func run() error { + if err := api.ValidateAuthConfig(); err != nil { + return err + } + root, err := filepath.Abs(getenv("CONTAINER_DATA_ROOT", getenv("DATA_ROOT", "./data"))) + if err != nil { + return err + } + if err = os.MkdirAll(root, 0700); err != nil { + return err + } + if os.Getenv("HOST_DATA_ROOT") == "" { + os.Setenv("HOST_DATA_ROOT", root) + } + s, err := store.Open(filepath.Join(root, "db.sqlite")) + if err != nil { + return err + } + defer s.Close() + var count int + if err = s.DB.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil { + return err + } + if count == 0 { + password := os.Getenv("ADMIN_PASSWORD") + if len(password) < 12 || len(password) > 72 { + return fmt.Errorf("first startup requires ADMIN_PASSWORD of 12–72 bytes") + } + hash, e := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if e != nil { + return e + } + if err = s.CreateUser(store.User{ID: store.ID(), Username: getenv("ADMIN_USERNAME", "admin"), Admin: true, Quota: api.DefaultQuota()}, string(hash)); err != nil { + return err + } + } + panel := api.New(s, root) + if err := panel.ConfigError(); err != nil { + return err + } + checkCtx, checkCancel := context.WithTimeout(context.Background(), 20*time.Second) + if err := panel.Reconcile(checkCtx); err != nil { + log.Printf("startup reconciliation unavailable; Docker connectivity must be checked: %v", err) + } + checkCancel() + if err := panel.StartImageBuilds(); err != nil { + return fmt.Errorf("image build recovery: %w", err) + } + backend := panel.Handler() + static := http.FileServer(http.Dir(getenv("WEB_DIR", "web/dist"))) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "no-referrer") + if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/dsh/") { + backend.ServeHTTP(w, r) + return + } + if r.URL.Path == "/healthz" { + w.Write([]byte("ok")) + return + } + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "method not allowed", 405) + return + } + // The embedded runtime visits the same-origin SPA renewal bridge. + // Keep cross-origin framing blocked without blocking that bridge. + w.Header().Set("X-Frame-Options", "SAMEORIGIN") + w.Header().Set("Content-Security-Policy", "frame-ancestors 'self'") + static.ServeHTTP(w, r) + }) + srv := &http.Server{Addr: getenv("LISTEN_ADDR", "127.0.0.1:8088"), Handler: handler, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 20} + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if err := panel.StartIntents(ctx); err != nil { + return fmt.Errorf("creation intent recovery: %w", err) + } + go panel.RunQuotaMonitor(ctx) + return serve(ctx, srv) +} + +func serve(ctx context.Context, srv *http.Server) error { + drained := make(chan struct{}) + finished := make(chan struct{}) + defer close(finished) + go func() { + defer close(drained) + select { + case <-ctx.Done(): + case <-finished: + return + } + c, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := srv.Shutdown(c); err != nil { + log.Printf("graceful shutdown deadline reached: %v", err) + _ = srv.Close() + } + }() + log.Printf("dsh panel listening on %s", srv.Addr) + err := srv.ListenAndServe() + if errors.Is(err, http.ErrServerClosed) { + <-drained + return nil + } + return err +} +func getenv(k, d string) string { + if v := os.Getenv(k); v != "" { + return v + } + return d +} diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go new file mode 100644 index 0000000..c33270a --- /dev/null +++ b/cmd/server/main_test.go @@ -0,0 +1,88 @@ +package main + +import ( + "context" + "io" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestInvalidAuthConfigFailsBeforeDataCreation(t *testing.T) { + for _, key := range []string{"PANEL_PUBLIC_URL", "TRUSTED_PROXY_CIDRS"} { + t.Run(key, func(t *testing.T) { + t.Setenv("PANEL_PUBLIC_URL", "") + t.Setenv("TRUSTED_PROXY_CIDRS", "") + t.Setenv(key, "invalid") + root := filepath.Join(t.TempDir(), "not-created") + t.Setenv("CONTAINER_DATA_ROOT", root) + if err := run(); err == nil || !strings.Contains(err.Error(), key) { + t.Fatalf("unexpected error: %v", err) + } + if _, err := os.Stat(root); !os.IsNotExist(err) { + t.Fatalf("data directory created: %v", err) + } + }) + } +} + +func TestServeWaitsForActiveHandler(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + address := make(chan string, 1) + entered := make(chan struct{}) + release := make(chan struct{}) + srv := &http.Server{Addr: "127.0.0.1:0", BaseContext: func(l net.Listener) context.Context { + address <- l.Addr().String() + return context.Background() + }, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(entered) + <-release + _, _ = w.Write([]byte("finished")) + })} + done := make(chan error, 1) + go func() { done <- serve(ctx, srv) }() + var addr string + select { + case addr = <-address: + case <-time.After(5 * time.Second): + t.Fatal("server did not listen") + } + requestDone := make(chan error, 1) + go func() { + resp, err := http.Get("http://" + addr) + if err == nil { + _, err = io.Copy(io.Discard, resp.Body) + resp.Body.Close() + } + requestDone <- err + }() + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("handler did not start") + } + cancel() + select { + case err := <-done: + close(release) + t.Fatalf("returned before handler completed: %v", err) + case <-time.After(100 * time.Millisecond): + } + close(release) + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("shutdown stalled") + } + if err := <-requestDone; err != nil { + t.Fatal(err) + } +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..519c6b8 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,39 @@ +services: + panel: + container_name: dsh-mgr-panel + build: . + image: dsh-mgr:local + restart: unless-stopped + stop_grace_period: 20s + user: "1000:1000" + group_add: + - ${DOCKER_GID:?Set the numeric group ID of the Docker socket} + ports: + - '127.0.0.1:8088:8088' + environment: + LISTEN_ADDR: 0.0.0.0:8088 + DATA_ROOT: /data + CONTAINER_DATA_ROOT: /data + HOST_DATA_ROOT: ${DSH_HOST_DATA_ROOT:?Set an absolute host data directory} + PANEL_PUBLIC_URL: ${PANEL_PUBLIC_URL:-http://127.0.0.1:8088} + TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-} + WEB_DIR: /app/web/dist + UPLOAD_ALLOWED_EXTENSIONS: ${UPLOAD_ALLOWED_EXTENSIONS:-} + ADMIN_USERNAME: ${DSH_ADMIN_USERNAME:-admin} + ADMIN_PASSWORD: ${DSH_ADMIN_PASSWORD:?Set a strong initial admin password} + DOCKER_HOST: unix:///var/run/docker.sock + DSH_DOCKER_ROOT_VISIBLE: ${DSH_DOCKER_ROOT_DIR:?Set the exact DockerRootDir reported by docker info} + PANEL_CONTAINER_ID: dsh-mgr-panel + volumes: + - ${DSH_HOST_DATA_ROOT}:/data + - /var/run/docker.sock:/var/run/docker.sock + - ${DSH_DOCKER_ROOT_DIR}:${DSH_DOCKER_ROOT_DIR}:ro + read_only: true + tmpfs: + - /tmp:size=256m + mem_limit: 1g + logging: + driver: json-file + options: + max-size: 10m + max-file: '3' diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3a334c3 --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module git.misaka.ren/m1saka/dsh-web + +go 1.25 + +require modernc.org/sqlite v1.34.5 + +require golang.org/x/crypto v0.28.0 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.26.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5dc4eaa --- /dev/null +++ b/go.sum @@ -0,0 +1,48 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g= +modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/image/Caddyfile b/image/Caddyfile new file mode 100644 index 0000000..008f263 --- /dev/null +++ b/image/Caddyfile @@ -0,0 +1,7 @@ +{ + admin off + auto_https off +} +:8080 { + reverse_proxy 127.0.0.1:3080 +} diff --git a/image/Dockerfile b/image/Dockerfile new file mode 100644 index 0000000..bc09b8f --- /dev/null +++ b/image/Dockerfile @@ -0,0 +1,30 @@ +FROM caddy:2.10.2 AS proxy +FROM node:24-bookworm-slim +ARG DSH_VERSION +# Keep the historical dependency-layer cache key; the template revision is set below. +ARG RECIPE_REV=3 +RUN apt-get update && apt-get install -y --no-install-recommends tini ca-certificates git procps && rm -rf /var/lib/apt/lists/* +RUN test -n "$DSH_VERSION" && npm install --global --foreground-scripts --loglevel=verbose --registry=https://registry.npmjs.org "@deepseek-ai/dsh@${DSH_VERSION}" +RUN npm install --global --ignore-scripts --registry=https://registry.npmjs.org pnpm@10.11.0 && test "$(pnpm --version)" = "10.11.0" +COPY install-mobile.mjs /usr/local/lib/dsh-mgr/install-mobile.mjs +RUN node /usr/local/lib/dsh-mgr/install-mobile.mjs +COPY install-free-search.mjs /usr/local/lib/dsh-mgr/install-free-search.mjs +RUN node /usr/local/lib/dsh-mgr/install-free-search.mjs +COPY install-workspace.mjs /usr/local/lib/dsh-mgr/install-workspace.mjs +RUN node /usr/local/lib/dsh-mgr/install-workspace.mjs +COPY init-profile.mjs /usr/local/lib/dsh-mgr/init-profile.mjs +COPY --from=proxy /usr/bin/caddy /usr/bin/caddy +# Strip upstream file capabilities without granting any runtime capabilities. +# A fresh file copy omits security.capability xattrs; port 8080 needs none. +RUN cp /usr/bin/caddy /usr/bin/caddy.clean && mv /usr/bin/caddy.clean /usr/bin/caddy +COPY Caddyfile /etc/caddy/Caddyfile +COPY entrypoint.sh /usr/local/bin/dsh-entrypoint +RUN chmod 755 /usr/local/bin/dsh-entrypoint && mkdir -p /data /workspace /shared && ln -s /workspace /data/dsh-home && chown -R node:node /data /workspace /shared +ARG RECIPE_REV=7 +LABEL dsh-mgr.role=runtime dsh-mgr.version=$DSH_VERSION dsh-mgr.recipe=$RECIPE_REV +ENV DSH_HOME=/workspace HOME=/workspace XDG_DATA_HOME=/workspace/.local/share XDG_CONFIG_HOME=/workspace/.config +USER node +WORKDIR /workspace +EXPOSE 8080 +ENTRYPOINT ["/usr/bin/tini", "-g", "--", "/usr/local/bin/dsh-entrypoint"] +CMD ["web", "--no-open", "--port", "3080"] diff --git a/image/embed.go b/image/embed.go new file mode 100644 index 0000000..a01fc3f --- /dev/null +++ b/image/embed.go @@ -0,0 +1,6 @@ +package image + +import "embed" + +//go:embed Dockerfile Caddyfile entrypoint.sh install-mobile.mjs install-free-search.mjs install-workspace.mjs init-profile.mjs +var Files embed.FS diff --git a/image/entrypoint.sh b/image/entrypoint.sh new file mode 100644 index 0000000..e389c7a --- /dev/null +++ b/image/entrypoint.sh @@ -0,0 +1,44 @@ +#!/bin/bash +set -Eeuo pipefail + +export npm_config_ignore_scripts=true npm_config_ignore_pnpmfile=true +node /usr/local/lib/dsh-mgr/init-profile.mjs +if [[ -f "${DSH_HOME}/profiles/web/package.json" ]]; then + echo "dsh-mgr: plugin prestart reconciliation starting" >&2 + # pnpm otherwise probes registry metadata for the overlapping optional peer. + # Restrict offline mode to profiles containing only our fixed archives. + install_env=() + if node -e 'const m=require(process.env.DSH_HOME+"/profiles/web/package.json"); const pins={"dsh-mgr-workspace":"file:/usr/local/lib/dsh-mgr/dsh-mgr-workspace-1.0.0.tgz","dsh-web-mobile":"file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz","dsh-free-search":"file:/usr/local/lib/dsh-mgr/dsh-free-search-0.8.5.tgz"}; process.exit(Object.entries({...m.dependencies,...m.devDependencies,...m.optionalDependencies}).every(([n,v])=>pins[n]===v)?0:1)'; then + install_env=(npm_config_offline=true) + fi + if timeout --signal=TERM --kill-after=5s 120s env CI=true "${install_env[@]}" dsh plugin --profile web install --ignore-scripts; then + echo "dsh-mgr: plugin prestart reconciliation complete" >&2 + else + status=$? + echo "dsh-mgr: plugin prestart reconciliation failed (exit $status); application not started" >&2 + exit "$status" + fi +else + echo "dsh-mgr: no web plugin manifest; using bundled plugins" >&2 +fi + +caddy run --config /etc/caddy/Caddyfile --adapter caddyfile & +proxy_pid=$! +echo +dsh "$@" & +dsh_pid=$! +cleanup() { + trap - TERM INT + kill -TERM "$proxy_pid" "$dsh_pid" 2>/dev/null || true + wait "$proxy_pid" 2>/dev/null || true + wait "$dsh_pid" 2>/dev/null || true +} +trap cleanup EXIT +trap 'exit 143' TERM +trap 'exit 130' INT +# Either child exiting terminates the container; never leave a proxy-only instance. +set +e +wait -n "$proxy_pid" "$dsh_pid" +status=$? +set -e +exit "$status" diff --git a/image/init-profile.mjs b/image/init-profile.mjs new file mode 100644 index 0000000..d8fd0b5 --- /dev/null +++ b/image/init-profile.mjs @@ -0,0 +1,35 @@ +import { mkdirSync, existsSync, writeFileSync, readFileSync, readdirSync, lstatSync } from 'node:fs'; +import { join } from 'node:path'; +const home = process.env.DSH_HOME; +if (!home) throw new Error('DSH_HOME required'); +const profile = join(home, 'profiles/web'); +for (const dir of [home, join(home, 'profiles'), profile]) { + try { + const stat = lstatSync(dir); + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('unsafe profile directory'); + } catch (error) { if (error.code !== 'ENOENT') throw error; } +} +// Never rewrite an existing user manifest or patch. +if (!existsSync(profile) || readdirSync(profile).length === 0) { + mkdirSync(profile, { recursive: true }); + writeFileSync(join(profile, 'package.json'), JSON.stringify({ + name: 'dsh-profile-web', private: true, + dependencies: { 'dsh-mgr-workspace': 'file:/usr/local/lib/dsh-mgr/dsh-mgr-workspace-1.0.0.tgz', 'dsh-web-mobile': 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz', 'dsh-free-search': 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.8.5.tgz' }, + pnpm: { overrides: { + 'dsh-free-search>@deepseek-ai/schemastery': 'file:/usr/local/lib/dsh-mgr/schemastery-3.18.4.tgz', + '@deepseek-ai/schemastery>@deepseek-ai/cosmokit': 'file:/usr/local/lib/dsh-mgr/cosmokit-1.8.5.tgz', + '@deepseek-ai/schemastery>@standard-schema/spec': 'file:/usr/local/lib/dsh-mgr/spec-1.1.0.tgz', + } }, + dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', 'dsh-web-mobile', 'dsh-free-search', 'dsh-mgr-workspace'] } }, + }, null, 2), { flag: 'wx', mode: 0o600 }); + console.log('dsh-mgr: new web profile includes dsh-web-mobile@3.0.5 and dsh-free-search@0.8.5'); +} +const manifestPath = join(profile, 'package.json'); +const workspacePath = join(profile, 'pnpm-workspace.yaml'); +// Backups omit workspace settings; restore host-provided peers before pnpm runs. +if (existsSync(manifestPath) && !existsSync(workspacePath)) { + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + if (manifest.dependencies?.['dsh-web-mobile'] === 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz' || manifest.dependencies?.['dsh-free-search'] === 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.8.5.tgz') { + writeFileSync(workspacePath, 'packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n', { flag: 'wx', mode: 0o600 }); + } +} diff --git a/image/init-profile.test.mjs b/image/init-profile.test.mjs new file mode 100644 index 0000000..99be286 --- /dev/null +++ b/image/init-profile.test.mjs @@ -0,0 +1,64 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { execFileSync } from 'node:child_process'; +test('new profile enables bundle once and preserves existing configuration', () => { + const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-init-')); + try { + const run = () => execFileSync(process.execPath, [new URL('./init-profile.mjs', import.meta.url).pathname], {env:{...process.env, DSH_HOME:home}}); + run(); + const file=join(home,'profiles/web/package.json'); + const original=readFileSync(file,'utf8'); + assert.ok(JSON.parse(original).dsh.profile.bundles.includes('dsh-web-mobile')); + assert.ok(JSON.parse(original).dsh.profile.bundles.includes('dsh-free-search')); + assert.ok(JSON.parse(original).dsh.profile.bundles.includes('dsh-mgr-workspace')); + assert.equal(JSON.parse(original).dependencies['dsh-mgr-workspace'], 'file:/usr/local/lib/dsh-mgr/dsh-mgr-workspace-1.0.0.tgz'); + assert.equal(JSON.parse(original).dependencies['dsh-free-search'], 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.8.5.tgz'); + assert.equal(JSON.parse(original).pnpm.overrides['dsh-free-search>@deepseek-ai/schemastery'], 'file:/usr/local/lib/dsh-mgr/schemastery-3.18.4.tgz'); + assert.equal(JSON.parse(original).dependencies['dsh-web-mobile'], 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz'); + const workspace = join(home, 'profiles/web/pnpm-workspace.yaml'); + assert.match(readFileSync(workspace, 'utf8'), /autoInstallPeers: false/); + assert.match(readFileSync(workspace, 'utf8'), /nodeLinker: hoisted/); + run();assert.equal(readFileSync(file,'utf8'),original); + const legacy = JSON.parse(original); + delete legacy.dependencies['dsh-mgr-workspace']; + legacy.dsh.profile.bundles = legacy.dsh.profile.bundles.filter(name => name !== 'dsh-mgr-workspace'); + writeFileSync(file, JSON.stringify(legacy)); + run(); + assert.deepEqual(JSON.parse(readFileSync(file)), legacy); + const disabled = JSON.parse(original); + disabled.dsh.profile.bundles = disabled.dsh.profile.bundles.filter(name => name !== 'dsh-web-mobile'); + writeFileSync(file, JSON.stringify(disabled)); + writeFileSync(workspace, 'custom workspace'); + const patch = join(home, 'profiles/web/cordis.patch.yml'); + writeFileSync(patch, '[]'); + run(); + assert.deepEqual(JSON.parse(readFileSync(file)), disabled); + assert.equal(readFileSync(workspace, 'utf8'), 'custom workspace'); + assert.equal(readFileSync(patch, 'utf8'), '[]'); + const custom=JSON.stringify({custom:true}); + writeFileSync(file,custom);run();assert.equal(readFileSync(file,'utf8'),custom); + } finally { rmSync(home,{recursive:true,force:true}); } +}); +test('nonempty legacy profiles are not automatically injected', () => { + const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-legacy-')); + try { + const profile = join(home, 'profiles/web'); + mkdirSync(profile, { recursive: true }); + writeFileSync(join(profile, 'cordis.patch.yml'), '[]'); + execFileSync(process.execPath, [new URL('./init-profile.mjs', import.meta.url).pathname], { env: { ...process.env, DSH_HOME: home } }); + assert.equal(existsSync(join(profile, 'package.json')), false); + assert.equal(existsSync(join(profile, 'pnpm-workspace.yaml')), false); + } finally { rmSync(home, { recursive: true, force: true }); } +}); +test('rejects symlinked profile ancestors', () => { + const home=mkdtempSync(join(tmpdir(),'dsh-plugin-init-')); + const outside=mkdtempSync(join(tmpdir(),'dsh-plugin-outside-')); + try { + symlinkSync(outside,join(home,'profiles')); + assert.throws(()=>execFileSync(process.execPath,[new URL('./init-profile.mjs',import.meta.url).pathname],{env:{...process.env,DSH_HOME:home},stdio:'pipe'})); + assert.equal(existsSync(join(outside,'web/package.json')),false); + } finally {rmSync(home,{recursive:true,force:true});rmSync(outside,{recursive:true,force:true});} +}); diff --git a/image/install-free-search.mjs b/image/install-free-search.mjs new file mode 100644 index 0000000..f314897 --- /dev/null +++ b/image/install-free-search.mjs @@ -0,0 +1,40 @@ +import { createHash } from 'node:crypto'; +import { mkdirSync, writeFileSync, readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +const root = '/usr/local/lib/dsh-mgr'; +const packages = [ + ['dsh-free-search', '0.8.5', 'https://registry.npmjs.org/dsh-free-search/-/dsh-free-search-0.8.5.tgz', 'MT00Xtypw9uk9QsqaiRmtVbYwnPON+HJY1lZjYritEvuwSe9ahgG9NVO43lUI+Eje2umrqUapaeqLRYf34DrZw=='], + ['@deepseek-ai/schemastery', '3.18.4', 'https://registry.npmjs.org/@deepseek-ai/schemastery/-/schemastery-3.18.4.tgz', 'SSXO6tYuyrIqKVbmOnIq0s+riUywYzouFMcnBluHF9n4KMo2G8HvEzhCYj6pj2617/glOjbJuVInJ9hfONsjkg=='], + ['@deepseek-ai/cosmokit', '1.8.5', 'https://registry.npmjs.org/@deepseek-ai/cosmokit/-/cosmokit-1.8.5.tgz', 'LXsrlem9z8dq4sLflj2yuCuYX7KqhdzF5hZly3eZyuTo8d6oDSOXuEgC5DbQWKW+lksm6zmOutQLsP/ma6wR6A=='], + ['@standard-schema/spec', '1.1.0', 'https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz', 'l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=='], +]; +for (const [name, version, url, integrity] of packages) { + const response = await fetch(url, { signal: AbortSignal.timeout(120000) }); + if (!response.ok) throw new Error('download failed: ' + name); + const data = Buffer.from(await response.arrayBuffer()); + if (createHash('sha512').update(data).digest('base64') !== integrity) throw new Error('integrity mismatch: ' + name); + const archive = `${root}/${name.split('/').pop()}-${version}.tgz`; + writeFileSync(archive, data, { mode: 0o644 }); +} +const hostManifest = '/usr/local/lib/node_modules/@deepseek-ai/dsh/package.json'; +const destination = '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/dsh-free-search'; +mkdirSync(destination, { recursive: true }); +execFileSync('tar', ['-xzf', `${root}/dsh-free-search-0.8.5.tgz`, '--strip-components=1', '--no-same-owner', '-C', destination]); +execFileSync('chmod', ['-R', 'a+rX', destination]); +const pkg = JSON.parse(readFileSync(destination + '/package.json')); +if (pkg.name !== 'dsh-free-search' || pkg.version !== '0.8.5' || JSON.stringify(pkg.dependencies) !== JSON.stringify({ '@deepseek-ai/schemastery': '^3.18.4' })) throw new Error('unexpected free-search metadata'); +const require = createRequire(hostManifest); +const semver = createRequire('/usr/local/lib/node_modules/npm/package.json')('semver'); +for (const [name, range] of Object.entries({ ...pkg.peerDependencies, ...pkg.dependencies })) { + if (name === 'react') { + if (!['0.2.0-rc.1', '0.2.0-rc.2'].includes(require('./package.json').version)) throw new Error('unverified browser React host'); + continue; + } + const peer = require(name + '/package.json'); + if (!semver.satisfies(peer.version, range)) throw new Error('incompatible free-search host dependency: ' + name); +} +const host = JSON.parse(readFileSync(hostManifest)); +host.dependencies = { ...host.dependencies, 'dsh-free-search': '0.8.5' }; +writeFileSync(hostManifest, JSON.stringify(host, null, 2)); +console.log('Installed dsh-free-search@0.8.5 and pinned offline dependency archives (SHA-512 verified; no lifecycle scripts)'); diff --git a/image/install-mobile.mjs b/image/install-mobile.mjs new file mode 100644 index 0000000..9c6f819 --- /dev/null +++ b/image/install-mobile.mjs @@ -0,0 +1,44 @@ +import { createHash } from 'node:crypto'; +import { mkdirSync, writeFileSync, readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +const version = '3.0.5'; +const integrity = 'N3wmegFHSd23J0+xjotKyBq0ZbLXUbkWZ65E+aSw5pzB/nudhaNHWT2V3CSNaW/LX3s6AxAWB2APpfA0dLXJZQ=='; +const response = await fetch('https://registry.npmjs.org/dsh-web-mobile/-/dsh-web-mobile-3.0.5.tgz', { signal: AbortSignal.timeout(120000) }); +if (!response.ok) throw new Error('mobile plugin download failed'); +const data = Buffer.from(await response.arrayBuffer()); +if (createHash('sha512').update(data).digest('base64') !== integrity) throw new Error('mobile plugin integrity mismatch'); +const destination = '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/dsh-web-mobile'; +mkdirSync(destination, { recursive: true }); +const archive = '/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz'; +writeFileSync(archive, data, { mode: 0o644 }); +execFileSync('tar', ['-xzf', archive, '--strip-components=1', '--no-same-owner', '-C', destination]); +// Published tar modes can be owner-only; runtime is UID 1000, not build root. +execFileSync('chmod', ['-R', 'a+rX', destination]); +const pkg = JSON.parse(readFileSync(destination + '/package.json')); +if (pkg.version !== version || pkg.name !== 'dsh-web-mobile' || Object.keys(pkg.dependencies || {}).length) throw new Error('unexpected mobile package metadata'); +const require = createRequire('/usr/local/lib/node_modules/@deepseek-ai/dsh/package.json'); +const semver = createRequire('/usr/local/lib/node_modules/npm/package.json')('semver'); +for (const [name, range] of Object.entries(pkg.peerDependencies || {})) { + // React is provided by the browser shell, not a Node package in DSH. + // Limit that exception to the upstream-declared, tested RC hosts. + if (name === 'react') { + const host = require('./package.json').version; + if (!['0.2.0-rc.1', '0.2.0-rc.2'].includes(host)) throw new Error('unverified browser React host: ' + host); + continue; + } + let peer; + try { peer = require(name + '/package.json'); } + catch (error) { + if (pkg.peerDependenciesMeta?.[name]?.optional) continue; + throw new Error('missing required mobile peer: ' + name); + } + if (!semver.satisfies(peer.version, range)) throw new Error('incompatible mobile peer: ' + name + '@' + peer.version); +} +// DSH runtime resolution traverses the installation manifest, not arbitrary +// directories in node_modules. Declare the baked package in that graph. +const hostManifest = '/usr/local/lib/node_modules/@deepseek-ai/dsh/package.json'; +const host = JSON.parse(readFileSync(hostManifest)); +host.dependencies = { ...host.dependencies, 'dsh-web-mobile': version }; +writeFileSync(hostManifest, JSON.stringify(host, null, 2)); +console.log('Installed dsh-web-mobile@3.0.5 (SHA-512 verified; no lifecycle scripts)'); diff --git a/image/install-workspace.mjs b/image/install-workspace.mjs new file mode 100644 index 0000000..ba21c8a --- /dev/null +++ b/image/install-workspace.mjs @@ -0,0 +1,12 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +const root = '/usr/local/lib/dsh-mgr'; +const destination = root + '/workspace-package/package'; +mkdirSync(destination, { recursive: true }); +writeFileSync(destination + '/package.json', JSON.stringify({ + name: 'dsh-mgr-workspace', version: '1.0.0', type: 'module', main: 'index.js', + dsh: { bundle: { patch: 'cordis.patch.yml' } }, +})); +writeFileSync(destination + '/index.js', "export const inject = ['workspaceRegistry', 'sessions'];\nexport async function apply(ctx) {\n await ctx.workspaceRegistry.initializeDefault(() => '/workspace');\n}\n"); +writeFileSync(destination + '/cordis.patch.yml', "- insert:\n - id: dsh-mgr-workspace\n name: dsh-mgr-workspace\n"); +execFileSync('tar', ['-czf', root + '/dsh-mgr-workspace-1.0.0.tgz', '-C', root + '/workspace-package', 'package']); diff --git a/internal/api/AUTH_DESIGN.md b/internal/api/AUTH_DESIGN.md new file mode 100644 index 0000000..c28c034 --- /dev/null +++ b/internal/api/AUTH_DESIGN.md @@ -0,0 +1,22 @@ +# Authentication boundary (§13) + +- Real logins retain `dsh_session` as a database session binding and require a + five-minute HS256 access JWT in an additional HttpOnly cookie. JWT verification + fixes the algorithm and binds subject/session/issuer/audience/expiry. Bare + session IDs never authenticate. Internal callers and test fixtures must also + attach a signed access cookie. +- Refresh is a random 256-bit HttpOnly cookie, scoped to `/api`, with only its + SHA-256 hash stored in SQLite. The session has a 24-hour absolute lifetime. + Conditional SQL update makes rotation single-use even across concurrent requests. + Logout deletes the database session and its refresh binding. Both refresh and + logout retain mandatory exact Origin and session-bound CSRF checks, even when + access has expired. Other operations require valid access. +- Signing key is generated once under the configured data root, private mode + 0600, and reused across restarts. Initialization errors fail authentication closed. + Tokens never appear in JSON, URLs, audit detail, or application logs. +- Known accounts lock for 15 minutes after five failures; the counter/lock survive + restart. Successful login resets the counter; expired locks reset the counter. + Existing global throttling remains additional protection. +- Tests cover missing/tampered/expired access, database binding, refresh rotation + and replay, CSRF/Origin rejection, logout revocation, key reuse/permissions, + account isolation, restart persistence, lock expiry, and runtime compatibility. diff --git a/internal/api/admin.go b/internal/api/admin.go new file mode 100644 index 0000000..31575ab --- /dev/null +++ b/internal/api/admin.go @@ -0,0 +1,212 @@ +package api + +import ( + "database/sql" + "encoding/json" + "git.misaka.ren/m1saka/dsh-web/internal/store" + "golang.org/x/crypto/bcrypt" + "io" + "net/http" + "os" + "regexp" + "strconv" + "time" +) + +var usernamePattern = regexp.MustCompile("^[a-zA-Z0-9_-]{3,40}$") + +func DefaultQuota() int64 { + if n, err := strconv.ParseInt(os.Getenv("DEFAULT_QUOTA_BYTES"), 10, 64); err == nil && n > 0 { + return n + } + return 10 << 30 +} + +func (a *API) users(w http.ResponseWriter, r *http.Request) { + u, _, err := a.user(r) + if err != nil { + fail(w, 401, "authentication required") + return + } + if !u.Admin { + fail(w, 403, "administrator required") + return + } + switch r.Method { + case "GET": + rows, err := a.s.DB.Query("SELECT id,username,admin,quota,created FROM users ORDER BY created") + if err != nil { + fail(w, 500, "database error") + return + } + defer rows.Close() + out := []store.User{} + for rows.Next() { + var v store.User + var created int64 + if err := rows.Scan(&v.ID, &v.Username, &v.Admin, &v.Quota, &created); err != nil { + fail(w, 500, "database error") + return + } + v.Created = time.Unix(created, 0) + out = append(out, v) + } + writeJSON(w, out) + case "POST": + var in struct { + Username string + Password string + Admin bool + Quota int64 `json:"quota_bytes"` + } + if json.NewDecoder(r.Body).Decode(&in) != nil || !usernamePattern.MatchString(in.Username) || len(in.Password) < 12 || len(in.Password) > 72 || in.Quota < 0 { + fail(w, 400, "username must be 3–40 letters/digits/_/-, password 12–72 bytes, quota nonnegative") + return + } + if in.Quota == 0 { + in.Quota = DefaultQuota() + } + h, e := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost) + if e != nil { + fail(w, 500, "password error") + return + } + v := store.User{ID: store.ID(), Username: in.Username, Admin: in.Admin, Quota: in.Quota, Created: time.Now()} + if e = a.s.CreateUser(v, string(h)); e != nil { + fail(w, 409, "username already exists or database unavailable") + return + } + a.s.AddAudit(u.ID, "user.create", v.ID, "") + writeJSON(w, v) + case "PATCH": + var in struct { + ID string + Quota int64 `json:"quota_bytes"` + } + if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&in) != nil || in.ID == "" || in.Quota <= 0 { + fail(w, 400, "positive quota and user ID required") + return + } + result, err := a.s.DB.Exec("UPDATE users SET quota=? WHERE id=?", in.Quota, in.ID) + if err != nil { + fail(w, 500, "quota update failed") + return + } + count, _ := result.RowsAffected() + if count != 1 { + fail(w, 404, "user not found") + return + } + a.s.AddAudit(u.ID, "user.quota", in.ID, strconv.FormatInt(in.Quota, 10)) + writeJSON(w, map[string]bool{"ok": true}) + default: + fail(w, 405, "method not allowed") + } +} +func (a *API) resetPassword(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if r.Method != http.MethodPost { + fail(w, 405, "method not allowed") + return + } + u, _, err := a.user(r) + if err != nil { + fail(w, 401, "authentication required") + return + } + if !u.Admin { + fail(w, 403, "administrator required") + return + } + var in struct{ ID, Password string } + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)) + if decoder.Decode(&in) != nil || decoder.Decode(new(any)) != io.EOF || in.ID == "" || len(in.Password) < 12 || len(in.Password) > 72 { + fail(w, 400, "user ID and password 12–72 bytes required") + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost) + if err != nil { + fail(w, 500, "password error") + return + } + // Login must not issue a session using a hash read before this reset. + a.authLoginMu.Lock() + defer a.authLoginMu.Unlock() + u, _, err = a.user(r) + if err != nil { + fail(w, 401, "authentication required") + return + } + if !u.Admin { + fail(w, 403, "administrator required") + return + } + tx, err := a.s.DB.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, "password reset failed") + return + } + defer tx.Rollback() + var username string + err = tx.QueryRowContext(r.Context(), "SELECT username FROM users WHERE id=?", in.ID).Scan(&username) + if err == sql.ErrNoRows { + fail(w, 404, "user not found") + return + } + if err == nil { + _, err = tx.ExecContext(r.Context(), "UPDATE users SET pass_hash=? WHERE id=?", string(hash), in.ID) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), "DELETE FROM auth_refresh WHERE sid IN (SELECT id FROM sessions WHERE uid=?)", in.ID) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), "DELETE FROM sessions WHERE uid=?", in.ID) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), "DELETE FROM auth_failures WHERE username=?", username) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), "INSERT INTO audit(uid,action,target,detail,at) VALUES(?,?,?,?,?)", u.ID, "user.reset_password", in.ID, "", time.Now().Unix()) + } + if err == nil { + err = tx.Commit() + } + if err != nil { + fail(w, 500, "password reset failed") + return + } + writeJSON(w, map[string]bool{"ok": true, "reauthenticate": u.ID == in.ID}) +} + +func (a *API) audit(w http.ResponseWriter, r *http.Request) { + u, _, e := a.user(r) + if e != nil { + fail(w, 401, "authentication required") + return + } + if !u.Admin { + fail(w, 403, "administrator required") + return + } + if r.Method != "GET" { + fail(w, 405, "method not allowed") + return + } + rows, e := a.s.DB.Query("SELECT id,uid,action,target,detail,at FROM audit ORDER BY id DESC LIMIT 500") + if e != nil { + fail(w, 500, "database error") + return + } + defer rows.Close() + out := []map[string]any{} + for rows.Next() { + var id, at int64 + var uid, action, target, detail string + if rows.Scan(&id, &uid, &action, &target, &detail, &at) != nil { + fail(w, 500, "database error") + return + } + out = append(out, map[string]any{"id": id, "user_id": uid, "action": action, "target": target, "detail": detail, "created_at": time.Unix(at, 0)}) + } + writeJSON(w, out) +} diff --git a/internal/api/admin_password_test.go b/internal/api/admin_password_test.go new file mode 100644 index 0000000..56cc12a --- /dev/null +++ b/internal/api/admin_password_test.go @@ -0,0 +1,303 @@ +package api + +import ( + "encoding/json" + "fmt" + "golang.org/x/crypto/bcrypt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +const resetPath = "/api/users/reset-password" + +func TestResetPasswordConcurrentLogin(t *testing.T) { + a, req := authFixture(t) + if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='alice'"); err != nil { + t.Fatal(err) + } + admin := req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil).Result().Cookies() + start := make(chan struct{}) + results := make(chan *httptest.ResponseRecorder, 8) + for i := 0; i < 8; i++ { + go func() { <-start; results <- req("POST", "/api/login", resetLogin("bob", "password-123"), "", nil) }() + } + close(start) + w := req("POST", resetPath, resetBody("bob", resetSecret), cookieValue(admin, "dsh_csrf"), admin) + if w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + for i := 0; i < 8; i++ { + w := <-results + if w.Code != 200 && w.Code != 401 && w.Code != 429 { + t.Fatal(w.Code, w.Body.String()) + } + if w.Code == 200 { + if check := req("GET", "/api/me", "", "", w.Result().Cookies()); check.Code != 401 { + t.Fatal("old-password login retained session", check.Code) + } + } + } + var n int + if err := a.s.DB.QueryRow("SELECT count(*) FROM sessions WHERE uid='bob'").Scan(&n); err != nil || n != 0 { + t.Fatal("old login sessions", n, err) + } +} + +type resetSignalReader struct { + reader *strings.Reader + started chan struct{} + once sync.Once +} + +func (r *resetSignalReader) Read(p []byte) (int, error) { + r.once.Do(func() { close(r.started) }) + return r.reader.Read(p) +} + +func TestResetPasswordRechecksWaitingAdmin(t *testing.T) { + for _, revoke := range []string{"session", "admin"} { + t.Run(revoke, func(t *testing.T) { + a, req := authFixture(t) + if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='alice'"); err != nil { + t.Fatal(err) + } + admin := req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil).Result().Cookies() + reader := &resetSignalReader{reader: strings.NewReader(resetBody("bob", resetSecret)), started: make(chan struct{})} + r := httptest.NewRequest("POST", resetPath, reader) + r.Header.Set("Origin", a.origin) + r.Header.Set("X-CSRF-Token", cookieValue(admin, "dsh_csrf")) + for _, c := range admin { + r.AddCookie(c) + } + a.authLoginMu.Lock() + locked := true + defer func() { + if locked { + a.authLoginMu.Unlock() + } + }() + done := make(chan *httptest.ResponseRecorder, 1) + go func() { w := httptest.NewRecorder(); a.Handler().ServeHTTP(w, r); done <- w }() + select { + case <-reader.started: + case <-time.After(5 * time.Second): + t.Fatal("request did not reach decoder") + } + select { + case w := <-done: + t.Fatal("reset bypassed login lock", w.Code) + case <-time.After(150 * time.Millisecond): + } + query := "UPDATE users SET admin=0 WHERE id='alice'" + want := 403 + if revoke == "session" { + query = "DELETE FROM sessions WHERE uid='alice'" + want = 401 + } + if _, err := a.s.DB.Exec(query); err != nil { + t.Fatal(err) + } + a.authLoginMu.Unlock() + locked = false + select { + case w := <-done: + if w.Code != want { + t.Fatal(w.Code, w.Body.String()) + } + case <-time.After(5 * time.Second): + t.Fatal("reset did not finish") + } + hash, err := a.s.Password("bob") + if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte("password-123")) != nil { + t.Fatal("unauthorized reset", err) + } + }) + } +} + +const resetSecret = "new-password-123" + +func resetBody(id, password string) string { + b, _ := json.Marshal(map[string]string{"id": id, "password": password}) + return string(b) +} + +func TestResetPasswordValidation(t *testing.T) { + a, req := authFixture(t) + admin := req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil).Result().Cookies() + regular := req("POST", "/api/login", resetLogin("bob", "password-123"), "", nil).Result().Cookies() + if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='alice'"); err != nil { + t.Fatal(err) + } + csrf := cookieValue(admin, "dsh_csrf") + for _, tc := range []struct { + name, method, body, csrf string + cookies []*http.Cookie + code int + }{ + {"anonymous", "POST", resetBody("bob", resetSecret), "", nil, 401}, + {"regular", "POST", resetBody("alice", resetSecret), cookieValue(regular, "dsh_csrf"), regular, 403}, + {"csrf", "POST", resetBody("bob", resetSecret), "bad", admin, 403}, + {"method", "GET", "", csrf, admin, 405}, + {"missing id", "POST", resetBody("", resetSecret), csrf, admin, 400}, + {"short", "POST", resetBody("bob", strings.Repeat("a", 11)), csrf, admin, 400}, + {"long", "POST", resetBody("bob", strings.Repeat("a", 73)), csrf, admin, 400}, + {"unicode long", "POST", resetBody("bob", strings.Repeat("密", 25)), csrf, admin, 400}, + {"malformed", "POST", "{", csrf, admin, 400}, + {"trailing", "POST", resetBody("bob", resetSecret) + "{}", csrf, admin, 400}, + {"oversized", "POST", resetBody("bob", strings.Repeat("a", 4096)), csrf, admin, 400}, + {"missing target", "POST", resetBody("missing", resetSecret), csrf, admin, 404}, + } { + t.Run(tc.name, func(t *testing.T) { + w := req(tc.method, resetPath, tc.body, tc.csrf, tc.cookies) + if w.Code != tc.code { + t.Fatal(w.Code, w.Body.String()) + } + }) + } + for _, origin := range []string{"", "https://evil.test"} { + r := httptest.NewRequest("POST", resetPath, strings.NewReader(resetBody("bob", resetSecret))) + r.Header.Set("Origin", origin) + r.Header.Set("X-CSRF-Token", csrf) + for _, c := range admin { + r.AddCookie(c) + } + w := httptest.NewRecorder() + a.Handler().ServeHTTP(w, r) + if w.Code != 403 { + t.Fatal("origin accepted", w.Code) + } + } + for _, password := range []string{strings.Repeat("a", 12), strings.Repeat("密", 24)} { + if w := req("POST", resetPath, resetBody("bob", password), csrf, admin); w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + hash, err := a.s.Password("bob") + if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil { + t.Fatal("boundary hash", err) + } + } +} + +func resetLogin(username, password string) string { + b, _ := json.Marshal(map[string]string{"username": username, "password": password}) + return string(b) +} + +func TestResetPasswordLifecycle(t *testing.T) { + for _, target := range []string{"bob", "alice"} { + t.Run(target, func(t *testing.T) { + a, req := authFixture(t) + if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='alice'"); err != nil { + t.Fatal(err) + } + login := func(name string) []*http.Cookie { + w := req("POST", "/api/login", resetLogin(name, "password-123"), "", nil) + if w.Code != 200 { + t.Fatal(w.Code) + } + return w.Result().Cookies() + } + admin := login("alice") + targets := [][]*http.Cookie{login(target), login(target)} + other := "alice" + if target == "alice" { + other = "bob" + } + unaffected := login(other) + if _, err := a.s.DB.Exec("INSERT INTO auth_failures VALUES(?,5,?)", target, 9999999999); err != nil { + t.Fatal(err) + } + w := req("POST", resetPath, resetBody(target, resetSecret), cookieValue(admin, "dsh_csrf"), admin) + if w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + var result map[string]bool + if json.Unmarshal(w.Body.Bytes(), &result) != nil || !result["ok"] || result["reauthenticate"] != (target == "alice") { + t.Fatal(w.Body.String()) + } + for _, cs := range targets { + var remaining int + if err := a.s.DB.QueryRow("SELECT count(*) FROM auth_refresh WHERE sid=?", cookieValue(cs, "dsh_session")).Scan(&remaining); err != nil || remaining != 0 { + t.Fatal("old refresh row", remaining, err) + } + if w := req("GET", "/api/me", "", "", cs); w.Code != 401 { + t.Fatal("old access", w.Code) + } + if w := req("POST", "/api/refresh", "", cookieValue(cs, "dsh_csrf"), cs); w.Code != 403 { + t.Fatal("old refresh", w.Code) + } + } + for _, query := range []string{"SELECT count(*) FROM sessions WHERE uid=?", "SELECT count(*) FROM auth_failures WHERE username=?", "SELECT count(*) FROM auth_refresh WHERE sid IN (SELECT id FROM sessions WHERE uid=?)"} { + var n int + if err := a.s.DB.QueryRow(query, target).Scan(&n); err != nil || n != 0 { + t.Fatal(query, n, err) + } + } + if w := req("GET", "/api/me", "", "", unaffected); w.Code != 200 { + t.Fatal("other access", w.Code) + } + if w := req("POST", "/api/refresh", "", cookieValue(unaffected, "dsh_csrf"), unaffected); w.Code != 200 { + t.Fatal("other refresh", w.Code) + } + if w := req("POST", "/api/login", resetLogin(target, "password-123"), "", nil); w.Code != 401 { + t.Fatal("old password", w.Code) + } + if w := req("POST", "/api/login", resetLogin(target, resetSecret), "", nil); w.Code != 200 { + t.Fatal("new password", w.Code) + } + var actor, detail string + var n int + if err := a.s.DB.QueryRow("SELECT uid,detail,count(*) FROM audit WHERE action='user.reset_password' AND target=?", target).Scan(&actor, &detail, &n); err != nil || actor != "alice" || detail != "" || n != 1 { + t.Fatal("audit", actor, detail, n, err) + } + }) + } +} + +func TestResetPasswordRollback(t *testing.T) { + for _, step := range []struct{ table, event string }{{"users", "UPDATE"}, {"auth_refresh", "DELETE"}, {"sessions", "DELETE"}, {"auth_failures", "DELETE"}, {"audit", "INSERT"}} { + t.Run(step.table, func(t *testing.T) { + a, req := authFixture(t) + if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='alice'"); err != nil { + t.Fatal(err) + } + admin := req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil).Result().Cookies() + target := req("POST", "/api/login", resetLogin("bob", "password-123"), "", nil).Result().Cookies() + before, err := a.s.Password("bob") + if err != nil { + t.Fatal(err) + } + if _, err := a.s.DB.Exec("INSERT INTO auth_failures VALUES('bob',5,9999999999)"); err != nil { + t.Fatal(err) + } + if _, err := a.s.DB.Exec(fmt.Sprintf("CREATE TRIGGER reject_reset BEFORE %s ON %s BEGIN SELECT RAISE(ABORT,'forced failure'); END", step.event, step.table)); err != nil { + t.Fatal(err) + } + w := req("POST", resetPath, resetBody("bob", resetSecret), cookieValue(admin, "dsh_csrf"), admin) + if w.Code != 500 || strings.Contains(w.Body.String(), resetSecret) { + t.Fatal(w.Code, w.Body.String()) + } + after, err := a.s.Password("bob") + if err != nil || before != after { + t.Fatal("hash changed", err) + } + for query, want := range map[string]int{"SELECT count(*) FROM sessions WHERE uid='bob'": 1, "SELECT count(*) FROM auth_refresh": 2, "SELECT count(*) FROM auth_failures WHERE username='bob' AND failures=5 AND locked_until=9999999999": 1, "SELECT count(*) FROM audit WHERE action='user.reset_password'": 0} { + var n int + if err := a.s.DB.QueryRow(query).Scan(&n); err != nil || n != want { + t.Fatal(query, n, err) + } + } + if w := req("GET", "/api/me", "", "", target); w.Code != 200 { + t.Fatal("session changed", w.Code) + } + if w := req("POST", "/api/refresh", "", cookieValue(target, "dsh_csrf"), target); w.Code != 200 { + t.Fatal("refresh changed", w.Code) + } + }) + } +} diff --git a/internal/api/admin_quota_test.go b/internal/api/admin_quota_test.go new file mode 100644 index 0000000..1a4a2b5 --- /dev/null +++ b/internal/api/admin_quota_test.go @@ -0,0 +1,33 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestAdminQuotaUpdate(t *testing.T) { + a, sid := runtimeTestAPI(t, func(http.ResponseWriter, *http.Request) {}) + request := func() *httptest.ResponseRecorder { + w := httptest.NewRecorder() + a.users(w, runtimeRequest("PATCH", "/api/users", `{"id":"owner","quota_bytes":2048}`, sid)) + return w + } + if w := request(); w.Code != 403 { + t.Fatal(w.Code) + } + if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='owner'"); err != nil { + t.Fatal(err) + } + if w := request(); w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + u, err := a.s.UserByID("owner") + if err != nil || u.Quota != 2048 { + t.Fatalf("%+v %v", u, err) + } + t.Setenv("DEFAULT_QUOTA_BYTES", "12345") + if DefaultQuota() != 12345 { + t.Fatal("default quota ignored") + } +} diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..4256bfa --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,269 @@ +package api + +import ( + "encoding/json" + "git.misaka.ren/m1saka/dsh-web/internal/docker" + "git.misaka.ren/m1saka/dsh-web/internal/store" + "golang.org/x/crypto/bcrypt" + "io" + "net" + "net/http" + "os" + "strings" + "sync" + "time" +) + +type API struct { + s *store.Store + origin string + root string + docker *docker.Client + mu sync.Mutex + workspaceReaders map[string]int + loginMu sync.Mutex + loginSources map[string]loginSource + trustedProxies []*net.IPNet + loginSlots chan struct{} + loginConfigErr error + authKey []byte + authErr error + authLoginMu sync.Mutex +} + +func New(s *store.Store, root string) *API { + a := &API{s: s, root: root, docker: docker.New(os.Getenv("DOCKER_HOST")), workspaceReaders: make(map[string]int), loginSources: make(map[string]loginSource), loginSlots: make(chan struct{}, 8)} + a.origin, a.loginConfigErr = configuredOrigin(os.Getenv("PANEL_PUBLIC_URL")) + var proxyErr error + a.trustedProxies, proxyErr = configuredProxyCIDRs(os.Getenv("TRUSTED_PROXY_CIDRS"), a.loginConfigErr) + if a.loginConfigErr == nil { + a.loginConfigErr = proxyErr + } + if a.loginConfigErr != nil { + a.authErr = a.loginConfigErr + } else { + a.authErr = a.initAuth() + } + return a +} +func (a *API) Handler() http.Handler { + m := http.NewServeMux() + m.HandleFunc("/api/login", a.login) + m.HandleFunc("/api/refresh", a.refresh) + m.HandleFunc("/api/runtime-session.js", runtimeSessionScript) + m.HandleFunc("/api/logout", a.logout) + m.HandleFunc("/api/me", a.me) + m.HandleFunc("/api/me/password", a.changePassword) + m.HandleFunc("/api/instances", a.instances) + m.HandleFunc("/api/instances/", a.instance) + m.HandleFunc("/api/images", a.images) + m.HandleFunc("/api/latest", a.latest) + m.HandleFunc("/api/builds", a.builds) + m.HandleFunc("/api/intents", a.intents) + m.HandleFunc("/api/intents/", a.intent) + m.HandleFunc("/api/ws/", a.workspace) + m.HandleFunc("/api/users", a.users) + m.HandleFunc("/api/users/reset-password", a.resetPassword) + m.HandleFunc("/api/audit", a.audit) + m.HandleFunc("/api/orphans", a.orphans) + m.HandleFunc("/dsh/", a.proxy) + return a.guard(m) +} +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} +func fail(w http.ResponseWriter, n int, s string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(n) + writeJSON(w, map[string]string{"error": s}) +} +func (a *API) guard(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if a.loginConfigErr != nil { + fail(w, 503, "authentication configuration invalid") + return + } + r.Body = http.MaxBytesReader(w, r.Body, 64<<20) + if strings.HasPrefix(r.URL.Path, "/dsh/") { + next.ServeHTTP(w, r) + return + } + // API bodies and downloads are bounded; runtime WS/SSE above remain streaming. + controller := http.NewResponseController(w) + _ = controller.SetReadDeadline(time.Now().Add(60 * time.Second)) + _ = controller.SetWriteDeadline(time.Now().Add(5 * time.Minute)) + defer controller.SetReadDeadline(time.Time{}) + defer controller.SetWriteDeadline(time.Time{}) + if r.Method != "GET" && r.URL.Path != "/api/login" { + if !a.panelOriginAllowed(r) { + fail(w, 403, "origin denied") + return + } + c, e := r.Cookie("dsh_session") + if e != nil { + fail(w, 401, "authentication required") + return + } + _, csrf, e := a.s.SessionUser(c.Value) + if e != nil || r.Header.Get("X-CSRF-Token") != csrf { + fail(w, 403, "csrf denied") + return + } + if r.URL.Path != "/api/refresh" && r.URL.Path != "/api/logout" { + if _, _, e := a.user(r); e != nil { + fail(w, 401, "authentication required") + return + } + } + } + next.ServeHTTP(w, r) + }) +} +func (a *API) user(r *http.Request) (store.User, string, error) { + c, e := r.Cookie("dsh_session") + if e != nil { + return store.User{}, "", e + } + u, csrf, e := a.s.SessionUser(c.Value) + if e != nil { + return store.User{}, "", e + } + if e = a.verifyAccess(r, c.Value, u.ID); e != nil { + return store.User{}, "", e + } + return u, csrf, nil +} +func (a *API) login(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + fail(w, 405, "method not allowed") + return + } + if !a.panelOriginAllowed(r) { + fail(w, 403, "origin denied") + return + } + var in struct{ Username, Password string } + r.Body = http.MaxBytesReader(w, r.Body, 4096) + decoder := json.NewDecoder(r.Body) + if decoder.Decode(&in) != nil { + fail(w, 400, "invalid json") + return + } + if decoder.Decode(new(any)) != io.EOF { + fail(w, 400, "invalid json") + return + } + if !a.allowLoginSource(a.loginSource(r)) { + fail(w, 429, "too many login attempts; retry in a minute") + return + } + if a.authErr != nil { + fail(w, 503, "authentication unavailable") + return + } + if !a.acquireLoginSlot() { + fail(w, 503, "authentication overloaded") + return + } + defer func() { <-a.loginSlots }() + a.authLoginMu.Lock() + defer a.authLoginMu.Unlock() + locked, e := a.accountLocked(in.Username) + if e != nil { + fail(w, 503, "authentication unavailable") + return + } + if locked { + fail(w, 429, "account temporarily locked; retry later") + return + } + h, e := a.s.Password(in.Username) + if e != nil { + fail(w, 401, "invalid credentials") + return + } + if bcrypt.CompareHashAndPassword([]byte(h), []byte(in.Password)) != nil { + if e = a.failedLogin(in.Username); e != nil { + fail(w, 503, "authentication unavailable") + return + } + fail(w, 401, "invalid credentials") + return + } + u, e := a.s.UserByName(in.Username) + if e != nil { + fail(w, 401, "invalid credentials") + return + } + if _, e = a.s.DB.Exec("DELETE FROM auth_failures WHERE username=?", in.Username); e != nil { + fail(w, 503, "authentication unavailable") + return + } + sid, csrf, e := a.s.Session(u.ID) + if e != nil { + fail(w, 500, "session error") + return + } + token, e := refreshToken() + if e == nil { + _, e = a.s.DB.Exec("INSERT INTO auth_refresh(sid,token_hash) VALUES(?,?)", sid, refreshHash(token)) + } + if e != nil { + a.s.DeleteSession(sid) + fail(w, 500, "session error") + return + } + a.authCookies(w, r, sid, u.ID, csrf, token) + a.s.AddAudit(u.ID, "login", u.ID, "") + writeJSON(w, map[string]any{"user": u, "csrf_token": csrf}) +} +func (a *API) logout(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + fail(w, 405, "method not allowed") + return + } + if c, e := r.Cookie("dsh_session"); e == nil { + if _, e = a.s.DB.Exec("DELETE FROM sessions WHERE id=?", c.Value); e != nil { + fail(w, 500, "session error") + return + } + } + for _, name := range []string{"dsh_session", "dsh_access", "dsh_refresh", "dsh_csrf"} { + path := "/" + if name == "dsh_refresh" { + path = "/api" + } + http.SetCookie(w, &http.Cookie{Name: name, MaxAge: -1, Path: path, Secure: a.secureCookies(r), HttpOnly: name != "dsh_csrf", SameSite: http.SameSiteLaxMode}) + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, map[string]bool{"ok": true}) +} +func (a *API) me(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if r.Method != http.MethodGet { + fail(w, 405, "method not allowed") + return + } + u, c, e := a.user(r) + if e != nil { + fail(w, 401, "authentication required") + return + } + writeJSON(w, map[string]any{"user": u, "csrf_token": c}) +} + +func (a *API) panelOriginAllowed(r *http.Request) bool { + if a.loginConfigErr != nil { + return false + } + expected := a.origin + if expected == "" { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + expected = scheme + "://" + r.Host + } + return r.Header.Get("Origin") == expected +} diff --git a/internal/api/auth_helpers_test.go b/internal/api/auth_helpers_test.go new file mode 100644 index 0000000..fa54868 --- /dev/null +++ b/internal/api/auth_helpers_test.go @@ -0,0 +1,28 @@ +package api + +import ( + "net/http" + "sync" + "testing" + "time" +) + +var testAccessTokens sync.Map + +func registerTestAccess(t *testing.T, a *API, sid string) { + t.Helper() + if a.authErr != nil { + t.Fatal(a.authErr) + } + u, _, err := a.s.SessionUser(sid) + if err != nil { + t.Fatal(err) + } + testAccessTokens.Store(sid, a.accessJWT(sid, u.ID, time.Now().Add(accessLifetime))) + t.Cleanup(func() { testAccessTokens.Delete(sid) }) +} +func addTestAccess(r *http.Request, sid string) { + if token, ok := testAccessTokens.Load(sid); ok { + r.AddCookie(&http.Cookie{Name: "dsh_access", Value: token.(string)}) + } +} diff --git a/internal/api/auth_security.go b/internal/api/auth_security.go new file mode 100644 index 0000000..c8a27e4 --- /dev/null +++ b/internal/api/auth_security.go @@ -0,0 +1,166 @@ +package api + +import ( + "errors" + "net" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +type loginSource struct { + started time.Time + count int +} + +func configuredOrigin(raw string) (string, error) { + if raw == "" { + return "", nil + } + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.Hostname() == "" || u.User != nil || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" { + return "", errors.New("invalid PANEL_PUBLIC_URL") + } + if strings.Contains(raw, "#") || strings.HasSuffix(u.Host, ":") { + return "", errors.New("invalid PANEL_PUBLIC_URL") + } + port := u.Port() + if port != "" { + n, err := strconv.Atoi(port) + if err != nil || n < 1 || n > 65535 { + return "", errors.New("invalid PANEL_PUBLIC_URL") + } + port = strconv.Itoa(n) + } + host := strings.ToLower(u.Hostname()) + if net.ParseIP(host) == nil { + if len(host) > 253 { + return "", errors.New("invalid PANEL_PUBLIC_URL") + } + for _, label := range strings.Split(strings.TrimSuffix(host, "."), ".") { + if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { + return "", errors.New("invalid PANEL_PUBLIC_URL") + } + for _, c := range label { + if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') { + return "", errors.New("invalid PANEL_PUBLIC_URL") + } + } + } + } else if strings.Contains(host, ":") { + host = "[" + host + "]" + } + if port != "" && !(u.Scheme == "https" && port == "443" || u.Scheme == "http" && port == "80") { + host += ":" + port + } + return u.Scheme + "://" + host, nil +} + +func configuredProxyCIDRs(raw string, prior error) ([]*net.IPNet, error) { + if prior != nil || strings.TrimSpace(raw) == "" { + return nil, prior + } + var out []*net.IPNet + for _, value := range strings.Split(raw, ",") { + value = strings.TrimSpace(value) + if value == "" { + return nil, errors.New("invalid TRUSTED_PROXY_CIDRS") + } + _, network, err := net.ParseCIDR(value) + if err != nil { + return nil, errors.New("invalid TRUSTED_PROXY_CIDRS") + } + out = append(out, network) + } + return out, nil +} + +func ValidateAuthConfig() error { + _, err := configuredOrigin(os.Getenv("PANEL_PUBLIC_URL")) + _, err = configuredProxyCIDRs(os.Getenv("TRUSTED_PROXY_CIDRS"), err) + return err +} + +func (a *API) ConfigError() error { return a.loginConfigErr } + +func (a *API) acquireLoginSlot() bool { + a.loginMu.Lock() + defer a.loginMu.Unlock() + if a.loginSlots == nil { + a.loginSlots = make(chan struct{}, 8) + } + select { + case a.loginSlots <- struct{}{}: + return true + default: + return false + } +} + +func (a *API) loginSource(r *http.Request) string { + peer, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + peer = r.RemoteAddr + } + ip := net.ParseIP(peer) + if ip == nil { + return peer + } + trusted := false + for _, network := range a.trustedProxies { + if network.Contains(ip) { + trusted = true + break + } + } + if trusted { + parts := strings.Split(r.Header.Get("X-Forwarded-For"), ",") + for i := len(parts) - 1; i >= 0; i-- { + candidate := net.ParseIP(strings.TrimSpace(parts[i])) + if candidate == nil { + return ip.String() + } + isProxy := false + for _, network := range a.trustedProxies { + if network.Contains(candidate) { + isProxy = true + break + } + } + if !isProxy { + return candidate.String() + } + } + } + return ip.String() +} + +func (a *API) allowLoginSource(source string) bool { + now := time.Now() + a.loginMu.Lock() + defer a.loginMu.Unlock() + if a.loginSources == nil { + a.loginSources = make(map[string]loginSource) + } + for key, entry := range a.loginSources { + if now.Sub(entry.started) >= time.Minute { + delete(a.loginSources, key) + } + } + entry, ok := a.loginSources[source] + if !ok { + if len(a.loginSources) >= 1024 { + return false + } + entry = loginSource{started: now} + } + if entry.count >= 30 { + return false + } + entry.count++ + a.loginSources[source] = entry + return true +} diff --git a/internal/api/auth_security_test.go b/internal/api/auth_security_test.go new file mode 100644 index 0000000..556480c --- /dev/null +++ b/internal/api/auth_security_test.go @@ -0,0 +1,271 @@ +package api + +import ( + "fmt" + "io" + "net" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" +) + +func TestConfiguredOriginRejectsInvalidOrigins(t *testing.T) { + for _, raw := range []string{"https://", "https://:8443", "https://panel.test?", "https://panel.test:bad", "ftp://panel.test", "https://panel.test/path"} { + if _, err := configuredOrigin(raw); err == nil { + t.Errorf("accepted invalid origin %q", raw) + } + } + for _, raw := range []string{"https://panel.test", "https://panel.test:8443", "http://127.0.0.1:8088"} { + if got, err := configuredOrigin(raw); err != nil || got != raw { + t.Errorf("origin %q => %q, %v", raw, got, err) + } + } +} + +func TestLoginSourceNilMapAndInvalidForwardedChain(t *testing.T) { + a := &API{} + if !a.allowLoginSource("127.0.0.1") { + t.Fatal("nil source map rejected") + } + a.trustedProxies = mustCIDRs(t, "10.0.0.0/8") + r := httptest.NewRequest("POST", "http://panel.test/api/login", nil) + r.RemoteAddr = "10.0.0.1:1234" + r.Header.Set("X-Forwarded-For", "198.51.100.4,not-an-ip,10.0.0.2") + if got := a.loginSource(r); got != "10.0.0.1" { + t.Fatalf("invalid chain crossed trust boundary: %s", got) + } +} + +func mustCIDRs(t *testing.T, raw string) []*net.IPNet { + t.Helper() + proxies, err := configuredProxyCIDRs(raw, nil) + if err != nil { + t.Fatal(err) + } + return proxies +} + +func TestSecurityConfigValidation(t *testing.T) { + for _, raw := range []string{"https://panel.test:0", "https://panel.test:65536", "https://panel.test:", "https://panel.test#", "https://bad_host", "https://[bad]", "https://user@panel.test"} { + if _, err := configuredOrigin(raw); err == nil { + t.Errorf("accepted %q", raw) + } + } + for raw, want := range map[string]string{"https://PANEL.EXAMPLE:443/": "https://panel.example", "http://PANEL.EXAMPLE:80": "http://panel.example", "https://[::1]:443": "https://[::1]"} { + if got, err := configuredOrigin(raw); err != nil || got != want { + t.Errorf("%q => %q, %v", raw, got, err) + } + } + for _, raw := range []string{"bad", "10.0.0.1", "10.0.0.0/8,", ",", "10.0.0.0/33"} { + if _, err := configuredProxyCIDRs(raw, nil); err == nil { + t.Errorf("accepted CIDRs %q", raw) + } + } + for _, key := range []string{"PANEL_PUBLIC_URL", "TRUSTED_PROXY_CIDRS"} { + t.Run(key, func(t *testing.T) { + t.Setenv("PANEL_PUBLIC_URL", "") + t.Setenv("TRUSTED_PROXY_CIDRS", "") + t.Setenv(key, "invalid") + if ValidateAuthConfig() == nil { + t.Fatal("configuration accepted") + } + a := New(nil, t.TempDir()) + if a.ConfigError() == nil { + t.Fatal("missing config error") + } + for _, path := range []string{"/api/login", "/api/me", "/api/refresh", "/dsh/example/"} { + w := httptest.NewRecorder() + a.Handler().ServeHTTP(w, httptest.NewRequest("POST", path, strings.NewReader("{}"))) + if w.Code != 503 { + t.Fatalf("%s: %d", path, w.Code) + } + } + }) + } +} + +func TestSecurityTLSCookieJarLifecycle(t *testing.T) { + a, _ := authFixture(t) + a.origin = "" + a.loginSlots = nil + a.loginSources = nil + srv := httptest.NewTLSServer(a.Handler()) + defer srv.Close() + client := srv.Client() + client.Jar, _ = cookiejar.New(nil) + u, _ := url.Parse(srv.URL + "/api/me") + request := func(method, path, body string, status int) []*http.Cookie { + t.Helper() + r, _ := http.NewRequest(method, srv.URL+path, strings.NewReader(body)) + r.Header.Set("Origin", srv.URL) + r.Header.Set("X-CSRF-Token", cookieValue(client.Jar.Cookies(u), "dsh_csrf")) + resp, err := client.Do(r) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + if resp.StatusCode != status { + t.Fatalf("%s: %d %s", path, resp.StatusCode, data) + } + return resp.Cookies() + } + check := func(cookies []*http.Cookie, deleting bool) { + t.Helper() + if len(cookies) != 4 { + t.Fatalf("cookies: %v", cookies) + } + for _, c := range cookies { + path := "/" + if c.Name == "dsh_refresh" { + path = "/api" + } + if !c.Secure || c.HttpOnly != (c.Name != "dsh_csrf") || c.Path != path || c.SameSite != http.SameSiteLaxMode || (deleting && c.MaxAge != -1) { + t.Fatalf("cookie: %+v", c) + } + } + } + check(request("POST", "/api/login", `{"username":"alice","password":"password-123"}`, 200), false) + request("GET", "/api/me", "", 200) + old := cookieValue(client.Jar.Cookies(u), "dsh_refresh") + check(request("POST", "/api/refresh", "", 200), false) + if old == cookieValue(client.Jar.Cookies(u), "dsh_refresh") { + t.Fatal("refresh not rotated") + } + request("GET", "/api/me", "", 200) + check(request("POST", "/api/logout", "", 200), true) + if len(client.Jar.Cookies(u)) != 0 { + t.Fatal("logout left cookies") + } + request("GET", "/api/me", "", 401) +} + +func TestSecurityLoginBudgetsAndUntrustedHeaders(t *testing.T) { + a, _ := authFixture(t) + a.origin = "" + request := func(peer, origin, body string) int { + r := httptest.NewRequest("POST", "http://panel.test/api/login", strings.NewReader(body)) + r.RemoteAddr = peer + ":1234" + r.Header.Set("Origin", origin) + r.Header.Set("X-Forwarded-For", fmt.Sprintf("198.51.100.%d", len(a.loginSources))) + r.Header.Set("X-Forwarded-Proto", "https") + r.Header.Set("X-Forwarded-Host", "evil.test") + w := httptest.NewRecorder() + a.Handler().ServeHTTP(w, r) + return w.Code + } + for i := 0; i < 35; i++ { + if got := request("192.0.2.1", "https://evil.test", "{}"); got != 403 { + t.Fatal(got) + } + for _, body := range []string{"{", "{} garbage", "{} {}", strings.Repeat(" ", 4097)} { + if got := request("192.0.2.1", "http://panel.test", body); got != 400 { + t.Fatal(got) + } + } + } + if len(a.loginSources) != 0 { + t.Fatal("invalid requests consumed budget") + } + for i := 0; i < 30; i++ { + if got := request("192.0.2.1", "http://panel.test", "{}"); got != 401 { + t.Fatal(i, got) + } + } + if got := request("192.0.2.1", "http://panel.test", "{}"); got != 429 { + t.Fatal(got) + } + if got := request("192.0.2.2", "http://panel.test", "{}"); got != 401 { + t.Fatal("other source blocked", got) + } + if len(a.loginSources) != 2 { + t.Fatal("forwarded headers changed source") + } + r := httptest.NewRequest("POST", "http://panel.test", nil) + r.Header.Set("X-Forwarded-Proto", "https") + if a.secureCookies(r) { + t.Fatal("untrusted proto changed cookie policy") + } + a.origin = "https://panel.test" + if !a.secureCookies(r) { + t.Fatal("configured HTTPS not secure") + } +} + +func TestSecurityLoginSlotOverloadRecovery(t *testing.T) { + a, req := authFixture(t) + for i := 0; i < 8; i++ { + if !a.acquireLoginSlot() { + t.Fatal("slot unavailable") + } + } + if w := req("POST", "/api/login", "{}", "", nil); w.Code != 503 { + t.Fatal("overload not rejected", w.Code) + } + <-a.loginSlots + if w := req("POST", "/api/login", "{}", "", nil); w.Code != 401 { + t.Fatal("released slot not usable", w.Code) + } + if len(a.loginSlots) != 7 { + t.Fatal("request leaked slot") + } +} + +func TestSecurityConcurrentSlotsAndBoundedBuckets(t *testing.T) { + a := &API{} + var wg sync.WaitGroup + results := make(chan bool, 64) + for i := 0; i < 64; i++ { + wg.Add(1) + go func() { defer wg.Done(); results <- a.acquireLoginSlot() }() + } + wg.Wait() + close(results) + count := 0 + for ok := range results { + if ok { + count++ + } + } + if count != 8 { + t.Fatalf("slots: %d", count) + } + <-a.loginSlots + if !a.acquireLoginSlot() { + t.Fatal("released slot unavailable") + } + for i := 0; i < 1024; i++ { + if !a.allowLoginSource(fmt.Sprint(i)) { + t.Fatal(i) + } + } + if a.allowLoginSource("overflow") || len(a.loginSources) != 1024 { + t.Fatal("unbounded buckets") + } + a.loginSources["0"] = loginSource{started: time.Now().Add(-time.Minute), count: 30} + if !a.allowLoginSource("overflow") || len(a.loginSources) != 1024 { + t.Fatal("expired bucket not reclaimed") + } + b := &API{} + accepted := make(chan bool, 64) + for i := 0; i < 64; i++ { + wg.Add(1) + go func() { defer wg.Done(); accepted <- b.allowLoginSource("same") }() + } + wg.Wait() + close(accepted) + count = 0 + for ok := range accepted { + if ok { + count++ + } + } + if count != 30 { + t.Fatalf("concurrent budget: %d", count) + } +} diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go new file mode 100644 index 0000000..73c50fa --- /dev/null +++ b/internal/api/auth_test.go @@ -0,0 +1,93 @@ +package api + +import ( + "encoding/json" + "git.misaka.ren/m1saka/dsh-web/internal/store" + "golang.org/x/crypto/bcrypt" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" +) + +func TestAuthenticationAndAdmin(t *testing.T) { + s, e := store.Open(filepath.Join(t.TempDir(), "test.sqlite")) + if e != nil { + t.Fatal(e) + } + defer s.Close() + h, _ := bcrypt.GenerateFromPassword([]byte("test-password-123"), bcrypt.MinCost) + if e = s.CreateUser(store.User{ID: "admin", Username: "admin", Admin: true, Quota: 1024}, string(h)); e != nil { + t.Fatal(e) + } + a := New(s, t.TempDir()) + a.origin = "http://panel.test" + var access *http.Cookie + request := func(method, path, body, csrf string, cookie *http.Cookie) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, path, strings.NewReader(body)) + r.Header.Set("Origin", a.origin) + r.Header.Set("X-CSRF-Token", csrf) + if cookie != nil { + r.AddCookie(cookie) + if access != nil { + r.AddCookie(access) + } + } + w := httptest.NewRecorder() + a.Handler().ServeHTTP(w, r) + return w + } + if w := request("GET", "/api/me", "", "", nil); w.Code != 401 { + t.Fatal(w.Code) + } + w := request("POST", "/api/login", `{"username":"admin","password":"test-password-123"}`, "", nil) + if w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + var login struct { + CSRF string `json:"csrf_token"` + } + json.Unmarshal(w.Body.Bytes(), &login) + var cookie *http.Cookie + for _, c := range w.Result().Cookies() { + if c.Name == "dsh_access" { + access = c + } + if c.Name == "dsh_session" { + cookie = c + } + } + if cookie == nil || !cookie.HttpOnly || cookie.Secure { + t.Fatal("wrong local cookie settings") + } + if w = request("GET", "/api/users", "", "", cookie); w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + if w = request("POST", "/api/users", `{"username":"member","password":"member-password-123"}`, "", cookie); w.Code != 403 { + t.Fatal("CSRF accepted", w.Code) + } + if w = request("POST", "/api/users", `{"username":"member","password":"member-password-123"}`, login.CSRF, cookie); w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + if w = request("POST", "/api/logout", "", login.CSRF, cookie); w.Code != 200 { + t.Fatal(w.Code) + } + if w = request("GET", "/api/me", "", "", cookie); w.Code != 401 { + t.Fatal("session not revoked") + } +} + +func TestOriginRequiredWithoutPublicURL(t *testing.T) { + a := &API{} + for _, tc := range []struct { + origin string + want bool + }{{"", false}, {"http://evil.test", false}, {"http://panel.test", true}} { + r := httptest.NewRequest("POST", "http://panel.test/api/login", nil) + r.Header.Set("Origin", tc.origin) + if a.panelOriginAllowed(r) != tc.want { + t.Fatalf("origin %q", tc.origin) + } + } +} diff --git a/internal/api/auth_tokens.go b/internal/api/auth_tokens.go new file mode 100644 index 0000000..ea77c0e --- /dev/null +++ b/internal/api/auth_tokens.go @@ -0,0 +1,200 @@ +package api + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +const accessLifetime = 5 * time.Minute + +var errAuthentication = errors.New("authentication required") + +func (a *API) initAuth() error { + if err := os.MkdirAll(a.root, 0700); err != nil { + return err + } + path := filepath.Join(a.root, "auth-signing.key") + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err == nil { + key := make([]byte, 32) + if _, err = rand.Read(key); err == nil { + _, err = f.Write(key) + } + if err == nil { + err = f.Sync() + } + closeErr := f.Close() + if err != nil { + return err + } + if closeErr != nil { + return closeErr + } + } else if !os.IsExist(err) { + return err + } + info, err := os.Lstat(path) + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Mode().Perm() != 0600 { + return errors.New("unsafe signing key permissions") + } + a.authKey, err = os.ReadFile(path) + if err != nil { + return err + } + if len(a.authKey) != 32 { + return errors.New("invalid signing key") + } + _, err = a.s.DB.Exec(`CREATE TABLE IF NOT EXISTS auth_refresh(sid TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, token_hash TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS auth_failures(username TEXT PRIMARY KEY, failures INTEGER NOT NULL, locked_until INTEGER NOT NULL);`) + return err +} + +type accessClaims struct { + Subject string `json:"sub"` + Session string `json:"sid"` + Issuer string `json:"iss"` + Audience string `json:"aud"` + Expires int64 `json:"exp"` +} + +const jwtHeader = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + +func (a *API) accessJWT(sid, uid string, expires time.Time) string { + payload, _ := json.Marshal(accessClaims{uid, sid, "dsh-panel", "dsh-panel", expires.Unix()}) + body := jwtHeader + "." + base64.RawURLEncoding.EncodeToString(payload) + mac := hmac.New(sha256.New, a.authKey) + mac.Write([]byte(body)) + return body + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} +func (a *API) verifyAccess(r *http.Request, sid, uid string) error { + if a.authErr != nil || len(a.authKey) != 32 { + return errAuthentication + } + c, err := r.Cookie("dsh_access") + if err != nil { + return errAuthentication + } + p := strings.Split(c.Value, ".") + if len(p) != 3 || p[0] != jwtHeader { + return errAuthentication + } + sig, err := base64.RawURLEncoding.DecodeString(p[2]) + if err != nil { + return errAuthentication + } + mac := hmac.New(sha256.New, a.authKey) + mac.Write([]byte(p[0] + "." + p[1])) + if !hmac.Equal(sig, mac.Sum(nil)) { + return errAuthentication + } + data, err := base64.RawURLEncoding.DecodeString(p[1]) + if err != nil { + return errAuthentication + } + var claims accessClaims + if json.Unmarshal(data, &claims) != nil || claims.Subject != uid || claims.Session != sid || claims.Issuer != "dsh-panel" || claims.Audience != "dsh-panel" || claims.Expires <= time.Now().Unix() || claims.Expires > time.Now().Add(accessLifetime).Unix() { + return errAuthentication + } + return nil +} +func refreshToken() (string, error) { + b := make([]byte, 32) + _, err := rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b), err +} +func refreshHash(token string) string { + h := sha256.Sum256([]byte(token)) + return hex.EncodeToString(h[:]) +} +func (a *API) secureCookies(r *http.Request) bool { + secure := r.TLS != nil + if u, err := url.Parse(a.origin); err == nil && u.Scheme == "https" { + secure = true + } + return secure +} +func (a *API) authCookies(w http.ResponseWriter, r *http.Request, sid, uid, csrf, refresh string) { + for _, c := range []*http.Cookie{ + {Name: "dsh_session", Value: sid, Path: "/", HttpOnly: true}, + {Name: "dsh_access", Value: a.accessJWT(sid, uid, time.Now().Add(accessLifetime)), Path: "/", HttpOnly: true, MaxAge: int(accessLifetime.Seconds())}, + {Name: "dsh_refresh", Value: refresh, Path: "/api", HttpOnly: true}, + {Name: "dsh_csrf", Value: csrf, Path: "/"}, + } { + c.Secure = a.secureCookies(r) + c.SameSite = http.SameSiteLaxMode + http.SetCookie(w, c) + } + w.Header().Set("Cache-Control", "no-store") +} +func (a *API) refresh(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + fail(w, 405, "method not allowed") + return + } + if a.authErr != nil { + fail(w, 503, "authentication unavailable") + return + } + sid, err := r.Cookie("dsh_session") + if err != nil { + fail(w, 401, "authentication required") + return + } + u, csrf, err := a.s.SessionUser(sid.Value) + if err != nil { + fail(w, 401, "authentication required") + return + } + old, err := r.Cookie("dsh_refresh") + if err != nil { + fail(w, 401, "authentication required") + return + } + token, err := refreshToken() + if err != nil { + fail(w, 500, "session error") + return + } + result, err := a.s.DB.Exec("UPDATE auth_refresh SET token_hash=? WHERE sid=? AND token_hash=? AND EXISTS(SELECT 1 FROM sessions WHERE id=? AND expires>?)", refreshHash(token), sid.Value, refreshHash(old.Value), sid.Value, time.Now().Unix()) + if err != nil { + fail(w, 500, "session error") + return + } + n, err := result.RowsAffected() + if err != nil || n != 1 { + fail(w, 401, "authentication required") + return + } + a.authCookies(w, r, sid.Value, u.ID, csrf, token) + writeJSON(w, map[string]any{"user": u, "csrf_token": csrf}) +} +func (a *API) accountLocked(username string) (bool, error) { + var until int64 + err := a.s.DB.QueryRow("SELECT locked_until FROM auth_failures WHERE username=?", username).Scan(&until) + if err == sql.ErrNoRows { + return false, nil + } + return until > time.Now().Unix(), err +} +func (a *API) failedLogin(username string) error { + _, err := a.s.DB.Exec(`INSERT INTO auth_failures(username,failures,locked_until) VALUES(?,1,0) + ON CONFLICT(username) DO UPDATE SET + failures=CASE WHEN locked_until>0 AND locked_until<=? THEN 1 ELSE failures+1 END, + locked_until=CASE WHEN locked_until>0 AND locked_until<=? THEN 0 WHEN failures+1>=5 THEN ? ELSE 0 END`, username, time.Now().Unix(), time.Now().Unix(), time.Now().Add(15*time.Minute).Unix()) + return err +} diff --git a/internal/api/auth_tokens_test.go b/internal/api/auth_tokens_test.go new file mode 100644 index 0000000..9eb4f58 --- /dev/null +++ b/internal/api/auth_tokens_test.go @@ -0,0 +1,215 @@ +package api + +import ( + "bytes" + "git.misaka.ren/m1saka/dsh-web/internal/store" + "golang.org/x/crypto/bcrypt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +func authFixture(t *testing.T) (*API, func(string, string, string, string, []*http.Cookie) *httptest.ResponseRecorder) { + t.Helper() + s, e := store.Open(filepath.Join(t.TempDir(), "db")) + if e != nil { + t.Fatal(e) + } + t.Cleanup(s.Close) + h, _ := bcrypt.GenerateFromPassword([]byte("password-123"), bcrypt.MinCost) + for _, id := range []string{"alice", "bob"} { + if e = s.CreateUser(store.User{ID: id, Username: id}, string(h)); e != nil { + t.Fatal(e) + } + } + a := New(s, t.TempDir()) + a.origin = "https://panel.test" + return a, func(method, path, body, csrf string, cookies []*http.Cookie) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, path, strings.NewReader(body)) + r.Header.Set("Origin", a.origin) + r.Header.Set("X-CSRF-Token", csrf) + for _, c := range cookies { + r.AddCookie(c) + } + w := httptest.NewRecorder() + a.Handler().ServeHTTP(w, r) + return w + } +} +func cookieValue(cs []*http.Cookie, name string) string { + for _, c := range cs { + if c.Name == name { + return c.Value + } + } + return "" +} +func TestAccessRefreshLifecycle(t *testing.T) { + a, req := authFixture(t) + w := req("POST", "/api/login", `{"username":"alice","password":"password-123"}`, "", nil) + if w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + cs := w.Result().Cookies() + sid := cookieValue(cs, "dsh_session") + csrf := cookieValue(cs, "dsh_csrf") + for _, name := range []string{"dsh_access", "dsh_refresh"} { + v := cookieValue(cs, name) + if v == "" || strings.Contains(w.Body.String(), v) { + t.Fatal("missing/leaked", name) + } + } + for _, c := range cs { + if !c.Secure || (c.Name != "dsh_csrf" && !c.HttpOnly) { + t.Fatal("unsafe cookie", c.Name) + } + } + if w = req("GET", "/api/me", "", "", []*http.Cookie{{Name: "dsh_session", Value: sid}}); w.Code != 401 { + t.Fatal("missing access accepted", w.Code) + } + expired := append([]*http.Cookie{}, cs...) + for i, c := range expired { + if c.Name == "dsh_access" { + expired[i] = &http.Cookie{Name: c.Name, Value: a.accessJWT(sid, "alice", time.Now().Add(-time.Minute))} + } + } + if w = req("GET", "/api/me", "", "", expired); w.Code != 401 { + t.Fatal("expired access accepted") + } + if w = req("POST", "/api/refresh", "", "", expired); w.Code != 403 { + t.Fatal("refresh bypassed csrf", w.Code) + } + w = req("POST", "/api/refresh", "", csrf, expired) + if w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + fresh := w.Result().Cookies() + if cookieValue(fresh, "dsh_refresh") == cookieValue(cs, "dsh_refresh") { + t.Fatal("not rotated") + } + if w = req("POST", "/api/refresh", "", csrf, cs); w.Code != 401 { + t.Fatal("replay accepted", w.Code) + } + if w = req("GET", "/api/me", "", "", fresh); w.Code != 200 { + t.Fatal(w.Code) + } + if w = req("POST", "/api/logout", "", csrf, fresh); w.Code != 200 { + t.Fatal(w.Code) + } + if w = req("GET", "/api/me", "", "", fresh); w.Code != 401 { + t.Fatal("logout access valid") + } + if w = req("POST", "/api/refresh", "", csrf, fresh); w.Code == 200 { + t.Fatal("logout refresh valid") + } +} +func TestAccountLockAndPersistentKey(t *testing.T) { + a, req := authFixture(t) + for i := 0; i < 5; i++ { + if w := req("POST", "/api/login", `{"username":"alice","password":"wrong"}`, "", nil); w.Code != 401 { + t.Fatal(w.Code) + } + } + if w := req("POST", "/api/login", `{"username":"alice","password":"password-123"}`, "", nil); w.Code != 429 { + t.Fatal("not locked", w.Code) + } + if w := req("POST", "/api/login", `{"username":"bob","password":"password-123"}`, "", nil); w.Code != 200 { + t.Fatal("other account locked", w.Code) + } + b := New(a.s, a.root) + if !bytes.Equal(a.authKey, b.authKey) || b.authErr != nil { + t.Fatal("key not persistent", b.authErr) + } + fi, e := os.Stat(filepath.Join(a.root, "auth-signing.key")) + if e != nil || fi.Mode().Perm() != 0600 { + t.Fatal("unsafe key") + } + if locked, e := b.accountLocked("alice"); e != nil || !locked { + t.Fatal("lock not persistent", e) + } + if _, e = a.s.DB.Exec("UPDATE auth_failures SET locked_until=? WHERE username='alice'", time.Now().Add(-time.Second).Unix()); e != nil { + t.Fatal(e) + } + if w := req("POST", "/api/login", `{"username":"alice","password":"password-123"}`, "", nil); w.Code != 200 { + t.Fatal("lock did not expire", w.Code) + } +} + +func TestRefreshOriginConcurrentRotationAndJWTBinding(t *testing.T) { + a, req := authFixture(t) + w := req("POST", "/api/login", `{"username":"alice","password":"password-123"}`, "", nil) + cs := w.Result().Cookies() + sid := cookieValue(cs, "dsh_session") + csrf := cookieValue(cs, "dsh_csrf") + var stored string + if err := a.s.DB.QueryRow("SELECT token_hash FROM auth_refresh WHERE sid=?", sid).Scan(&stored); err != nil || stored != refreshHash(cookieValue(cs, "dsh_refresh")) { + t.Fatal("refresh not hashed", err) + } + for _, token := range []string{cookieValue(cs, "dsh_access") + "x", a.accessJWT("other", "alice", time.Now().Add(accessLifetime)), a.accessJWT(sid, "bob", time.Now().Add(accessLifetime)), "eyJhbGciOiJub25lIn0.e30."} { + if w = req("GET", "/api/me", "", "", []*http.Cookie{{Name: "dsh_session", Value: sid}, {Name: "dsh_access", Value: token}}); w.Code != 401 { + t.Fatal("invalid JWT accepted") + } + } + for _, origin := range []string{"", "https://evil.test"} { + r := httptest.NewRequest("POST", "/api/refresh", nil) + r.Header.Set("Origin", origin) + r.Header.Set("X-CSRF-Token", csrf) + for _, c := range cs { + r.AddCookie(c) + } + w = httptest.NewRecorder() + a.Handler().ServeHTTP(w, r) + if w.Code != 403 { + t.Fatal("origin accepted", w.Code) + } + } + var wg sync.WaitGroup + codes := make(chan int, 2) + for i := 0; i < 2; i++ { + wg.Add(1) + go func() { defer wg.Done(); codes <- req("POST", "/api/refresh", "", csrf, cs).Code }() + } + wg.Wait() + close(codes) + counts := map[int]int{} + for code := range codes { + counts[code]++ + } + if counts[200] != 1 || counts[401] != 1 { + t.Fatal(counts) + } + b := New(a.s, a.root) + r := httptest.NewRequest("GET", "/api/me", nil) + for _, c := range cs { + r.AddCookie(c) + } + w = httptest.NewRecorder() + b.Handler().ServeHTTP(w, r) + if w.Code != 200 { + t.Fatal("restart rejected access", w.Code) + } + a.s.DeleteSession(sid) + if w = req("GET", "/api/me", "", "", cs); w.Code != 401 { + t.Fatal("deleted binding accepted") + } +} + +func TestUnsafeAuthKeyFailsClosed(t *testing.T) { + a, req := authFixture(t) + if err := os.Chmod(filepath.Join(a.root, "auth-signing.key"), 0644); err != nil { + t.Fatal(err) + } + b := New(a.s, a.root) + if b.authErr == nil { + t.Fatal("unsafe key accepted") + } + a.authErr = b.authErr + if w := req("POST", "/api/login", `{"username":"alice","password":"password-123"}`, "", nil); w.Code != 503 { + t.Fatal(w.Code) + } +} diff --git a/internal/api/config.go b/internal/api/config.go new file mode 100644 index 0000000..8a4990b --- /dev/null +++ b/internal/api/config.go @@ -0,0 +1,656 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "git.misaka.ren/m1saka/dsh-web/internal/backup" + "git.misaka.ren/m1saka/dsh-web/internal/store" + "golang.org/x/crypto/bcrypt" + "gopkg.in/yaml.v3" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "syscall" + "time" +) + +var backupIDPattern = regexp.MustCompile("^[a-f0-9]{32}$") +var configBodySlots = make(chan struct{}, 4) + +func (a *API) config(w http.ResponseWriter, r *http.Request, i store.Instance, action string) { + controller := http.NewResponseController(w) + if r.Method != http.MethodGet || action != "backups" || r.URL.Query().Get("download") == "" { + var flush func() + w, flush = deferJSONResponse(w) + defer flush() + } + if r.Method != http.MethodGet { + select { + case configBodySlots <- struct{}{}: + defer func() { + r.Body = http.NoBody + <-configBodySlots + }() + default: + fail(w, 503, "configuration uploads busy; retry later") + return + } + limit := int64(4096) + multipartUpload := action == "backups" && strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") + if action == "profile" { + limit = 2 << 20 + } else if multipartUpload { + limit = min(64<<20, backup.MaxEncryptedBytes+16384) + } + if !configReadBody(w, r, controller, limit) { + return + } + if multipartUpload { + defer func() { + if r.MultipartForm != nil { + r.MultipartForm.RemoveAll() + } + }() + if r.ParseMultipartForm(1<<20) != nil { + fail(w, 400, "invalid or oversized backup upload") + return + } + } + } + a.mu.Lock() + locked := true + defer func() { + if locked { + a.mu.Unlock() + } + }() + if r.Method != http.MethodGet && a.workspaceReaders[i.ID] > 0 { + fail(w, 409, "instance files busy; retry later") + return + } + if r.Method != http.MethodGet { + workspaceMu.Lock() + defer workspaceMu.Unlock() + } + current, e := a.s.GetInstance(i.ID) + if e != nil || current.UID != i.UID { + fail(w, 404, "not found") + return + } + i = current + if i.Status == "error" || a.upgradeRecoveryRequired(i) { + fail(w, 409, "instance recovery required") + return + } + home := filepath.Join(a.root, "workspaces", i.UID, "instances", i.ID, "home") + dir := filepath.Join(a.root, "backups", i.UID, i.ID) + storage, e := os.OpenRoot(a.root) + if e != nil { + fail(w, 500, "storage unavailable") + return + } + defer storage.Close() + if configCheckPath(storage, filepath.ToSlash(filepath.Join("backups", i.UID, i.ID, "archive"))) != nil || configCheckPath(storage, filepath.ToSlash(filepath.Join("workspaces", i.UID, "instances", i.ID, "home", "profile"))) != nil { + fail(w, 400, "unsafe storage path") + return + } + if e = os.MkdirAll(dir, 0700); e != nil { + fail(w, 500, "backup storage unavailable") + return + } + wasRunning := false + if r.Method != "GET" { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + c, e := a.docker.Inspect(ctx, i.ContainerID) + if e != nil { + fail(w, 502, "container inspection failed") + return + } + wasRunning = c.State.Running + if c.State.Paused || c.State.Restarting { + fail(w, 409, "unsupported container state") + return + } + if c.State.Running && action == "profile" { + fail(w, 409, "stop instance before changing configuration or backing up") + return + } + } + switch action { + case "profile": + parent, e := upgradeRoot(a.root, i) + if e != nil { + fail(w, 400, "unsafe home") + return + } + defer parent.Close() + if e = configCheckPath(parent, "home/profiles/web/cordis.patch.yml"); e != nil { + fail(w, 400, "unsafe profile") + return + } + root, e := configProfileRoot(parent, r.Method == "PUT") + if e != nil { + if r.Method == "GET" && errors.Is(e, os.ErrNotExist) { + writeJSON(w, map[string]string{"content": ""}) + return + } + fail(w, 404, "home unavailable") + return + } + defer root.Close() + const path = "cordis.patch.yml" + if r.Method == "GET" { + f, e := root.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) + if errors.Is(e, os.ErrNotExist) { + writeJSON(w, map[string]string{"content": ""}) + return + } + if e != nil { + fail(w, 400, "profile unavailable") + return + } + defer f.Close() + st, e := f.Stat() + if e != nil || !st.Mode().IsRegular() || st.Size() > 1<<20 { + fail(w, 400, "invalid profile") + return + } + b, e := io.ReadAll(io.LimitReader(f, 1<<20)) + if e != nil { + fail(w, 500, "read failed") + return + } + writeJSON(w, map[string]string{"content": string(b)}) + return + } + if r.Method != "PUT" { + fail(w, 405, "method not allowed") + return + } + var in struct { + Content string `json:"content"` + Restart bool `json:"restart"` + } + if json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<20)).Decode(&in) != nil || len(in.Content) > 1<<20 { + fail(w, 400, "invalid profile") + return + } + var document any + if yaml.Unmarshal([]byte(in.Content), &document) != nil { + fail(w, 400, "invalid YAML") + return + } + var previous int64 + if st, err := root.Lstat(path); err == nil { + if !st.Mode().IsRegular() { + fail(w, 400, "invalid profile") + return + } + previous = st.Size() + } else if !errors.Is(err, os.ErrNotExist) { + fail(w, 400, "profile unavailable") + return + } + if e = a.quotaGrowth(i.UID, int64(len(in.Content))-previous); e != nil { + fail(w, 413, "quota exceeded or usage unavailable") + return + } + tmp := ".panel-" + store.ID() + f, e := root.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if e != nil { + fail(w, 500, "profile write failed") + return + } + defer root.Remove(tmp) + _, e = f.WriteString(in.Content) + if e == nil { + e = f.Chown(1000, 1000) + } + if e == nil { + e = f.Sync() + } + f.Close() + if e == nil { + e = root.Rename(tmp, path) + } + if e != nil { + fail(w, 500, "profile write failed") + return + } + a.s.AddAudit(i.UID, "profile.save", i.ID, "") + if in.Restart { + if a.configRestart(i) != nil { + fail(w, 502, "profile saved but restart failed") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second) + defer cancel() + if a.upgradeReady(ctx, i.ContainerID, i.ID, a.runtimeHost(r)) != nil { + a.s.SetInstance(i.ID, "error") + fail(w, 502, "profile saved but runtime readiness failed") + return + } + a.s.SetInstance(i.ID, "running") + } + writeJSON(w, map[string]bool{"ok": true}) + case "backups": + if r.Method == "GET" { + if id := r.URL.Query().Get("download"); id != "" { + if !backupIDPattern.MatchString(id) { + fail(w, 400, "invalid backup ID") + return + } + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", "attachment; filename="+id+".dshbak") + root, e := os.OpenRoot(dir) + if e != nil { + fail(w, 404, "backup unavailable") + return + } + defer root.Close() + f, e := root.OpenFile(id+".dshbak", os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) + if e != nil { + fail(w, 404, "backup unavailable") + return + } + defer f.Close() + st, e := f.Stat() + if e != nil || !st.Mode().IsRegular() { + fail(w, 400, "invalid backup") + return + } + a.mu.Unlock() + locked = false + http.ServeContent(w, r, id+".dshbak", st.ModTime(), f) + return + } + entries, e := os.ReadDir(dir) + if e != nil { + fail(w, 500, "backup listing failed") + return + } + out := []map[string]any{} + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".dshbak" { + continue + } + st, e := entry.Info() + if e != nil || !st.Mode().IsRegular() { + continue + } + id := entry.Name()[:len(entry.Name())-7] + kind, schema := "legacy", 0 + _ = a.s.DB.QueryRow("SELECT kind,schema FROM backups WHERE id=? AND uid=? AND instance_id=?", id, i.UID, i.ID).Scan(&kind, &schema) + out = append(out, map[string]any{"id": id, "size": st.Size(), "created_at": st.ModTime(), "kind": kind, "schema": schema}) + } + writeJSON(w, out) + return + } + if r.Method != "POST" { + fail(w, 405, "method not allowed") + return + } + entries, _ := os.ReadDir(dir) + if len(entries) >= 20 { + fail(w, 409, "backup retention limit reached (20); administrator cleanup required") + return + } + id := store.ID() + path := filepath.Join(dir, id+".dshbak") + kind, schema := "manual", 2 + if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") { + kind, schema = "imported", 0 + source, _, err := r.FormFile("file") + if err != nil { + fail(w, 400, "backup file required") + return + } + defer source.Close() + dest, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + fail(w, 500, "backup storage unavailable") + return + } + n, err := io.Copy(dest, io.LimitReader(source, backup.MaxEncryptedBytes+1)) + closeErr := dest.Close() + if err != nil || closeErr != nil || n > backup.MaxEncryptedBytes { + os.Remove(path) + fail(w, 400, "invalid or oversized backup upload") + return + } + key, _ := a.configKey() + _, cleanup, err := backup.Prepare(path, r.FormValue("password"), key) + clear(key) + if err != nil { + os.Remove(path) + fail(w, 400, "invalid backup or original backup password") + return + } + cleanup() + } else { + var in struct { + Password string `json:"password"` + } + if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&in) != nil || len(in.Password) == 0 || len(in.Password) > 72 { + fail(w, 400, "login password required") + return + } + var hash string + if a.s.DB.QueryRow("SELECT pass_hash FROM users WHERE id=?", i.UID).Scan(&hash) != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(in.Password)) != nil { + fail(w, 403, "incorrect login password") + return + } + if wasRunning { + if e = a.configStop(r.Context(), i.ContainerID); e != nil { + a.s.SetInstance(i.ID, "error") + fail(w, 502, "runtime stop failed") + return + } + } + if e = backup.CreateWithPassword(home, path, in.Password, backup.Metadata{DSHVersion: i.Version, CreatedAt: time.Now().UTC()}); e != nil { + if wasRunning { + a.configRestart(i) + } + fail(w, 400, "backup failed: unsafe or oversized configuration") + return + } + } + st, _ := os.Stat(path) + if e = a.indexBackup(i, id, kind, schema, st.Size()); e != nil { + os.Remove(path) + if wasRunning && kind == "manual" { + a.configRestart(i) + } + fail(w, 500, "backup index unavailable") + return + } + if wasRunning && kind == "manual" && a.configRestart(i) != nil { + fail(w, 502, "backup saved; runtime restart requires recovery") + return + } + a.s.AddAudit(i.UID, "backup.create", i.ID, id) + writeJSON(w, map[string]any{"id": id, "size": st.Size(), "created_at": st.ModTime(), "kind": kind, "schema": schema}) + case "restore": + if r.Method != "POST" { + fail(w, 405, "method not allowed") + return + } + var in struct { + ID string `json:"backup_id"` + Password string `json:"password"` + } + if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&in) != nil || !backupIDPattern.MatchString(in.ID) { + fail(w, 400, "invalid backup ID") + return + } + key, _ := a.configKey() + restore, cleanup, e := backup.Prepare(filepath.Join(dir, in.ID+".dshbak"), in.Password, key) + clear(key) + if e != nil { + fail(w, 400, "invalid backup or original backup password") + return + } + defer cleanup() + staging, e := os.MkdirTemp("", "dsh-restore-quota-") + if e != nil { + fail(w, 500, "restore staging unavailable") + return + } + defer os.RemoveAll(staging) + if e = restore(staging); e != nil { + fail(w, 400, "invalid restore contents") + return + } + staged, e := os.OpenRoot(staging) + if e != nil { + fail(w, 500, "restore staging unavailable") + return + } + restoredBytes, e := quotaTreeBytes(staged, ".") + staged.Close() + if e != nil { + fail(w, 413, "restore size unavailable") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute) + defer cancel() + parent, e := upgradeRoot(a.root, i) + if e != nil { + fail(w, 500, "storage unavailable") + return + } + defer parent.Close() + f, e := parent.Open(".") + if e != nil { + fail(w, 500, "snapshot storage unavailable") + return + } + entries, e := f.ReadDir(-1) + f.Close() + count := 0 + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), "pre-restore-") || strings.HasPrefix(entry.Name(), "failed-restore-") { + count++ + } + } + if e != nil || count >= 3 { + fail(w, 409, "restore snapshot limit reached; administrator cleanup required") + return + } + snapshot := "pre-restore-" + store.ID() + if _, e = a.quotaSnapshot(i.UID, parent, restoredBytes); e != nil { + fail(w, 413, "quota exceeded or snapshot size unavailable") + return + } + if wasRunning { + if e = a.configStop(ctx, i.ContainerID); e != nil { + a.s.SetInstance(i.ID, "error") + fail(w, 502, "runtime stop failed") + return + } + } + snapshotBytes, quotaErr := a.quotaSnapshot(i.UID, parent, restoredBytes) + if quotaErr != nil { + if wasRunning { + a.configRestart(i) + } + fail(w, 413, "quota exceeded or snapshot size unavailable") + return + } + if e = upgradeCopy(r.Context(), parent, "home", snapshot, snapshotBytes); e != nil { + cleanupErr := parent.RemoveAll(snapshot) + if wasRunning { + a.configRestart(i) + } + if cleanupErr != nil { + a.s.AddAudit(i.UID, "backup.snapshot.cleanup_failed", i.ID, snapshot) + fail(w, 500, "restore snapshot cleanup requires administrator recovery") + return + } + fail(w, 400, "cannot snapshot home for restore") + return + } + if _, e = a.s.DB.Exec("UPDATE instances SET status='error' WHERE id=?", i.ID); e != nil { + if wasRunning { + a.configRestart(i) + } + fail(w, 500, "cannot record restore state") + return + } + restoreErr := restore(home) + if restoreErr == nil { + var root *os.Root + root, restoreErr = os.OpenRoot(home) + if restoreErr == nil { + for _, p := range []string{"profiles", ".credentials.yaml", "cordis.patch.yml", "dsh-ssh.json", "skins", "pets", "agent-presets", ".agent-presets"} { + if restoreErr = configChownTree(root, p); restoreErr != nil { + break + } + } + root.Close() + } + } + if restoreErr == nil { + restoreErr = a.docker.Do(ctx, "POST", "/containers/"+url.PathEscape(i.ContainerID)+"/start", nil, nil) + } + if restoreErr == nil { + restoreErr = a.upgradeReady(ctx, i.ContainerID, i.ID, a.runtimeHost(r)) + } + if restoreErr == nil && !wasRunning { + restoreErr = a.configStop(ctx, i.ContainerID) + } + if restoreErr != nil { + rollbackCtx, done := context.WithTimeout(context.Background(), 45*time.Second) + defer done() + if a.configStop(rollbackCtx, i.ContainerID) != nil { + fail(w, 500, "restore rollback requires runtime stop") + return + } + failed := "failed-restore-" + store.ID() + if parent.Rename("home", failed) != nil || parent.Rename(snapshot, "home") != nil || upgradeSync(parent) != nil { + a.s.SetInstance(i.ID, "error") + fail(w, 500, "restore rollback requires administrator recovery") + return + } + if wasRunning && a.configRestart(i) != nil { + fail(w, 500, "restore rollback restart requires recovery") + return + } + status := "exited" + if wasRunning { + status = "running" + } + if _, e = a.s.DB.Exec("UPDATE instances SET status=? WHERE id=?", status, i.ID); e != nil { + fail(w, 500, "restore state requires administrator recovery") + return + } + fail(w, 400, "restore rejected; previous home restored") + return + } + status := "exited" + if wasRunning { + status = "running" + } + if _, e = a.s.DB.Exec("UPDATE instances SET status=? WHERE id=?", status, i.ID); e != nil { + fail(w, 500, "restore state requires administrator recovery") + return + } + runtimeAuthCache.Delete(runtimeAuthKey{a, i.ID}) + a.s.AddAudit(i.UID, "backup.restore", i.ID, in.ID) + writeJSON(w, map[string]bool{"ok": true}) + default: + fail(w, 404, "not found") + } +} + +func configReadBody(w http.ResponseWriter, r *http.Request, controller *http.ResponseController, limit int64) bool { + if r.ContentLength > limit { + fail(w, 413, "configuration request too large") + return false + } + if err := controller.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil && !errors.Is(err, http.ErrNotSupported) { + fail(w, 500, "cannot set configuration read deadline") + return false + } + defer controller.SetReadDeadline(time.Time{}) + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, limit)) + if err != nil { + var oversized *http.MaxBytesError + switch { + case errors.As(err, &oversized): + fail(w, 413, "configuration request too large") + case errors.Is(err, os.ErrDeadlineExceeded): + fail(w, 408, "configuration upload timed out") + default: + fail(w, 400, "cannot read configuration request") + } + return false + } + r.Body = io.NopCloser(bytes.NewReader(body)) + return true +} + +func (a *API) configKey() ([]byte, error) { + path := filepath.Join(a.root, "backup.key") + f, e := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) + if e == nil { + defer f.Close() + st, err := f.Stat() + if err != nil || !st.Mode().IsRegular() { + return nil, errors.New("invalid key") + } + b, e := io.ReadAll(io.LimitReader(f, 33)) + if e != nil || len(b) != 32 { + return nil, errors.New("invalid key") + } + return b, nil + } + return nil, e +} + +func configCheckPath(root *os.Root, name string) error { + parts := strings.Split(name, "/") + for n := range parts { + st, e := root.Lstat(strings.Join(parts[:n+1], "/")) + if errors.Is(e, os.ErrNotExist) { + return nil + } + if e != nil { + return e + } + if st.Mode()&os.ModeSymlink != 0 || (n < len(parts)-1 && !st.IsDir()) { + return errors.New("unsafe configuration path") + } + } + return nil +} +func configProfileRoot(parent *os.Root, create bool) (*os.Root, error) { + root, err := parent.OpenRoot(".") + if err != nil { + return nil, err + } + for _, name := range []string{"home", "profiles", "web"} { + created := false + st, e := root.Lstat(name) + if errors.Is(e, os.ErrNotExist) && create && name != "home" { + e = root.Mkdir(name, 0750) + if e == nil { + created = true + st, e = root.Lstat(name) + } + } + if e != nil { + root.Close() + return nil, e + } + if !st.IsDir() { + root.Close() + return nil, errors.New("unsafe profile directory") + } + next, e := root.OpenRoot(name) + root.Close() + if e != nil { + return nil, e + } + actual, e := next.Stat(".") + if e != nil || !os.SameFile(st, actual) { + next.Close() + return nil, errors.New("profile directory changed") + } + if created { + if e = next.Chown(".", 1000, 1000); e != nil { + next.Close() + return nil, e + } + } + root = next + } + return root, nil +} diff --git a/internal/api/config_body_test.go b/internal/api/config_body_test.go new file mode 100644 index 0000000..3fb0e1d --- /dev/null +++ b/internal/api/config_body_test.go @@ -0,0 +1,205 @@ +package api + +import ( + "bytes" + "io" + "mime/multipart" + "net/http/httptest" + "os" + "strings" + "sync" + "testing" + "time" + + "git.misaka.ren/m1saka/dsh-web/internal/store" +) + +type configPausedBody struct { + io.Reader + started chan struct{} + release chan struct{} + once sync.Once +} + +func (b *configPausedBody) Read(p []byte) (int, error) { + b.once.Do(func() { close(b.started) }) + <-b.release + return b.Reader.Read(p) +} + +func TestConfigSlowBodyRevalidatesWithoutLocks(t *testing.T) { + for _, action := range []string{"profile", "backups", "restore", "multipart"} { + for _, change := range []string{"lease", "deleted"} { + t.Run(action+"/"+change, func(t *testing.T) { + a, i, _ := configTestAPI(t, false) + a.workspaceReaders = make(map[string]int) + body := &configPausedBody{Reader: strings.NewReader(`{}`), started: make(chan struct{}), release: make(chan struct{})} + r := httptest.NewRequest("POST", "/", body) + if action == "profile" { + r.Method = "PUT" + } + target := action + if action == "multipart" { + target = "backups" + var buf bytes.Buffer + form := multipart.NewWriter(&buf) + part, _ := form.CreateFormFile("file", "test.dshbak") + part.Write(bytes.Repeat([]byte("x"), 2<<20)) + form.Close() + body.Reader = &buf + r.Header.Set("Content-Type", form.FormDataContentType()) + t.Setenv("TMPDIR", t.TempDir()) + } + w := httptest.NewRecorder() + done := make(chan struct{}) + go func() { defer close(done); a.config(w, r, i, target) }() + defer func() { + select { + case <-body.release: + default: + close(body.release) + } + <-done + }() + select { + case <-body.started: + case <-time.After(3 * time.Second): + t.Fatal("body read did not start") + } + if !a.mu.TryLock() { + t.Fatal("slow body holds API lock") + } + if change == "lease" { + a.workspaceReaders[i.ID]++ + } + a.mu.Unlock() + if !workspaceMu.TryLock() { + t.Fatal("slow body holds workspace lock") + } + workspaceMu.Unlock() + want := 409 + if change == "deleted" { + if _, err := a.s.DB.Exec("DELETE FROM instances WHERE id=?", i.ID); err != nil { + t.Fatal(err) + } + want = 404 + } + close(body.release) + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("config did not finish") + } + if w.Code != want { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + if action == "multipart" { + entries, err := os.ReadDir(os.Getenv("TMPDIR")) + if err != nil || len(entries) != 0 { + t.Fatalf("multipart temp files remain: %v %v", entries, err) + } + } + }) + } + } +} + +type configDeadlineWriter struct { + *httptest.ResponseRecorder + deadlines []time.Time +} + +func (w *configDeadlineWriter) SetReadDeadline(deadline time.Time) error { + w.deadlines = append(w.deadlines, deadline) + return nil +} + +type configErrorBody struct{} + +func (configErrorBody) Read([]byte) (int, error) { return 0, os.ErrDeadlineExceeded } + +func TestConfigBodyErrorsReturnWithoutLocks(t *testing.T) { + for _, tc := range []struct { + name string + body io.Reader + want int + }{ + {"oversized", strings.NewReader(strings.Repeat("x", 4097)), 413}, + {"timeout", configErrorBody{}, 408}, + {"multipart", strings.NewReader("bad multipart"), 400}, + } { + t.Run(tc.name, func(t *testing.T) { + a := &API{} + r := httptest.NewRequest("POST", "/", tc.body) + r.ContentLength = -1 + if tc.name == "multipart" { + r.Header.Set("Content-Type", "multipart/form-data; boundary=test") + } + w := &configDeadlineWriter{ResponseRecorder: httptest.NewRecorder()} + a.mu.Lock() + workspaceMu.Lock() + done := make(chan struct{}) + go func() { defer close(done); a.config(w, r, store.Instance{}, "backups") }() + select { + case <-done: + workspaceMu.Unlock() + a.mu.Unlock() + case <-time.After(3 * time.Second): + workspaceMu.Unlock() + a.mu.Unlock() + t.Fatal("body error waited for locks") + } + if w.Code != tc.want { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + if len(w.deadlines) != 2 || w.deadlines[0].IsZero() || !w.deadlines[1].IsZero() { + t.Fatalf("deadlines: %v", w.deadlines) + } + }) + } +} + +func TestConfigBodySlotsRejectWithoutReading(t *testing.T) { + for n := 0; n < cap(configBodySlots); n++ { + configBodySlots <- struct{}{} + } + defer func() { + for n := 0; n < cap(configBodySlots); n++ { + <-configBodySlots + } + }() + w := httptest.NewRecorder() + (&API{}).config(w, httptest.NewRequest("POST", "/", configErrorBody{}), store.Instance{}, "backups") + if w.Code != 503 { + t.Fatalf("status %d", w.Code) + } +} + +func TestConfigSlowResponseReleasesLocks(t *testing.T) { + for _, tc := range []struct{ method, action string }{{"POST", "restore"}, {"GET", "profile"}, {"GET", "backups"}} { + t.Run(tc.method+"/"+tc.action, func(t *testing.T) { + a, i, _ := configTestAPI(t, false) + w := &pausedDownload{ResponseRecorder: httptest.NewRecorder(), started: make(chan struct{}), release: make(chan struct{})} + done := make(chan struct{}) + go func() { + defer close(done) + a.config(w, httptest.NewRequest(tc.method, "/", strings.NewReader(`{}`)), i, tc.action) + }() + defer func() { close(w.release); <-done }() + select { + case <-w.started: + case <-time.After(3 * time.Second): + t.Fatal("response write did not start") + } + if !a.mu.TryLock() { + t.Fatal("slow response holds API lock") + } + a.mu.Unlock() + if !workspaceMu.TryLock() { + t.Fatal("slow response holds workspace lock") + } + workspaceMu.Unlock() + + }) + } +} diff --git a/internal/api/config_helpers.go b/internal/api/config_helpers.go new file mode 100644 index 0000000..35eda1c --- /dev/null +++ b/internal/api/config_helpers.go @@ -0,0 +1,80 @@ +package api + +import ( + "context" + "errors" + "git.misaka.ren/m1saka/dsh-web/internal/store" + "net/url" + "os" + "path/filepath" + "time" +) + +func (a *API) indexBackup(i store.Instance, id, kind string, schema int, size int64) error { + _, err := a.s.DB.Exec("CREATE TABLE IF NOT EXISTS backups (id TEXT PRIMARY KEY, uid TEXT NOT NULL, instance_id TEXT NOT NULL, kind TEXT NOT NULL, schema INTEGER NOT NULL, size INTEGER NOT NULL, created_at INTEGER NOT NULL)") + if err != nil { + return err + } + _, err = a.s.DB.Exec("INSERT INTO backups(id,uid,instance_id,kind,schema,size,created_at) VALUES(?,?,?,?,?,?,?)", id, i.UID, i.ID, kind, schema, size, time.Now().Unix()) + return err +} + +func configChownTree(root *os.Root, name string) error { + st, err := root.Lstat(name) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + if st.Mode()&os.ModeSymlink != 0 { + return root.Lchown(name, 1000, 1000) + } + if err = root.Chown(name, 1000, 1000); err != nil { + return err + } + if !st.IsDir() { + return nil + } + f, err := root.Open(name) + if err != nil { + return err + } + entries, err := f.ReadDir(-1) + f.Close() + if err != nil { + return err + } + for _, entry := range entries { + if err = configChownTree(root, filepath.Join(name, entry.Name())); err != nil { + return err + } + } + return nil +} + +func (a *API) configStop(ctx context.Context, id string) error { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + if err := a.docker.Do(ctx, "POST", "/containers/"+url.PathEscape(id)+"/stop?t=10", nil, nil); err != nil { + return err + } + c, err := a.docker.Inspect(ctx, id) + if err != nil { + return err + } + if c.State.Running || c.State.Paused || c.State.Restarting { + return errors.New("runtime did not stop") + } + return nil +} + +func (a *API) configRestart(i store.Instance) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + err := a.docker.Do(ctx, "POST", "/containers/"+url.PathEscape(i.ContainerID)+"/start", nil, nil) + if err != nil { + a.s.SetInstance(i.ID, "error") + } + return err +} diff --git a/internal/api/config_test.go b/internal/api/config_test.go new file mode 100644 index 0000000..840f59f --- /dev/null +++ b/internal/api/config_test.go @@ -0,0 +1,404 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "git.misaka.ren/m1saka/dsh-web/internal/store" + "golang.org/x/crypto/bcrypt" + "mime/multipart" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "syscall" + "testing" + "time" +) + +func TestConfigPasswordBackupImport(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("requires chown") + } + a, i, home := configTestAPI(t, false) + hash, _ := bcrypt.GenerateFromPassword([]byte("original-password"), bcrypt.MinCost) + if _, err := a.s.DB.Exec("UPDATE users SET pass_hash=? WHERE id=?", string(hash), i.UID); err != nil { + t.Fatal(err) + } + configWrite(t, filepath.Join(home, ".credentials.yaml"), "secret: original") + for _, body := range []string{`{}`, `{"password":"wrong"}`} { + if w := configCall(a, i, "POST", "backups", body); w.Code < 400 { + t.Fatal("accepted wrong password") + } + } + w := configCall(a, i, "POST", "backups", `{"password":"original-password"}`) + if w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + var out struct { + ID string `json:"id"` + } + json.Unmarshal(w.Body.Bytes(), &out) + if _, err := os.Stat(filepath.Join(a.root, "backup.key")); !os.IsNotExist(err) { + t.Fatal("new backup persisted key") + } + encrypted, err := os.ReadFile(filepath.Join(a.root, "backups", i.UID, i.ID, out.ID+".dshbak")) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(encrypted, []byte("secret: original")) { + t.Fatal("plaintext archive") + } + hash, _ = bcrypt.GenerateFromPassword([]byte("changed-password"), bcrypt.MinCost) + a.s.DB.Exec("UPDATE users SET pass_hash=? WHERE id=?", string(hash), i.UID) + configWrite(t, filepath.Join(home, ".credentials.yaml"), "secret: changed") + w = configCall(a, i, "POST", "restore", `{"backup_id":"`+out.ID+`","password":"wrong"}`) + if w.Code != 400 { + t.Fatal(w.Code, w.Body.String()) + } + entries, _ := os.ReadDir(filepath.Dir(home)) + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), "pre-restore-") { + t.Fatal("bad password created snapshot") + } + } + var body bytes.Buffer + form := multipart.NewWriter(&body) + part, _ := form.CreateFormFile("file", "external.dshbak") + part.Write(encrypted) + form.WriteField("password", "original-password") + form.Close() + req := httptest.NewRequest("POST", "/api/instances/one/backups", &body) + req.Header.Set("Content-Type", form.FormDataContentType()) + w = httptest.NewRecorder() + a.config(w, req, i, "backups") + if w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + w = configCall(a, i, "POST", "restore", `{"backup_id":"`+out.ID+`","password":"original-password"}`) + if w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + restored, _ := os.ReadFile(filepath.Join(home, ".credentials.yaml")) + if string(restored) != "secret: original" { + t.Fatal(string(restored)) + } +} + +func configTestAPI(t *testing.T, running bool, failures ...string) (*API, store.Instance, string) { + t.Helper() + var mu sync.Mutex + httpFailure := false + backupRestart := running + failure := "" + if len(failures) != 0 { + failure = failures[0] + } + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + if httpFailure { + httpFailure = false + running = false + w.WriteHeader(503) + return + } + if r.URL.Query().Get("token") == "test-token" { + http.SetCookie(w, &http.Cookie{Name: "dsh_auth", Value: "ready"}) + w.WriteHeader(303) + return + } + w.WriteHeader(200) + })) + t.Cleanup(upstream.Close) + transport := runtimeTransport + runtimeTransport = transport.Clone() + runtimeTransport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, network, strings.TrimPrefix(upstream.URL, "http://")) + } + t.Cleanup(func() { runtimeTransport.CloseIdleConnections(); runtimeTransport = transport }) + a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + switch { + case strings.HasSuffix(r.URL.Path, "/stop"): + running = false + w.WriteHeader(204) + case strings.HasSuffix(r.URL.Path, "/start"): + if backupRestart { + backupRestart = false + running = true + w.WriteHeader(204) + return + } + running = failure != "reconcile" + httpFailure = failure == "readiness" + failure = "" + w.WriteHeader(204) + case strings.Contains(r.URL.Path, "/exec"), strings.HasSuffix(r.URL.Path, "/restart"): + t.Error("restore must use entrypoint prestart, not exec/restart") + w.WriteHeader(500) + case strings.HasSuffix(r.URL.Path, "/logs"): + fmt.Fprint(w, "http://runtime/?token=test-token") + default: + writeJSON(w, map[string]any{"State": map[string]any{"Running": running}, "NetworkSettings": map[string]any{"Networks": map[string]any{"dsh-inst-one": map[string]string{"IPAddress": "127.0.0.1"}}}}) + } + }) + hash, _ := bcrypt.GenerateFromPassword([]byte("original-password"), bcrypt.MinCost) + a.s.DB.Exec("UPDATE users SET pass_hash=?,quota=10737418240 WHERE id='owner'", string(hash)) + i := store.Instance{ID: "one", UID: "owner", Name: "one", ContainerID: "old", Status: "exited", Created: time.Now()} + if e := a.s.AddInstance(i); e != nil { + t.Fatal(e) + } + home := filepath.Join(a.root, "workspaces", "owner", "instances", "one", "home") + if e := os.MkdirAll(home, 0750); e != nil { + t.Fatal(e) + } + return a, i, home +} +func configCall(a *API, i store.Instance, method, action, body string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := httptest.NewRequest(method, "/api/instances/one/"+action, strings.NewReader(body)) + a.config(w, r, i, strings.Split(action, "?")[0]) + return w +} +func configWrite(t *testing.T, path, content string) { + t.Helper() + if e := os.WriteFile(path, []byte(content), 0600); e != nil { + t.Fatal(e) + } +} +func TestConfigProfile(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("requires chown") + } + a, i, home := configTestAPI(t, false) + for _, tc := range []struct { + method, body string + code int + }{ + {"GET", "", 200}, {"PUT", `{"content":"foo: ["}`, 400}, {"PUT", `{"content":"foo: bar\n"}`, 200}, {"GET", "", 200}, + } { + w := configCall(a, i, tc.method, "profile", tc.body) + if w.Code != tc.code { + t.Fatalf("%s: %d %s", tc.method, w.Code, w.Body.String()) + } + } + for _, path := range []string{"profiles", "profiles/web", "profiles/web/cordis.patch.yml"} { + st, e := os.Stat(filepath.Join(home, path)) + if e != nil { + t.Fatal(e) + } + stat := st.Sys().(*syscall.Stat_t) + if stat.Uid != 1000 || stat.Gid != 1000 { + t.Fatalf("ownership of %s: %+v", path, stat) + } + } + w := configCall(a, i, "GET", "profile", "") + if !strings.Contains(w.Body.String(), "foo: bar") { + t.Fatal(w.Body.String()) + } +} +func TestConfigProfileSymlinks(t *testing.T) { + for _, level := range []string{"profiles", "web", "leaf", "home"} { + t.Run(level, func(t *testing.T) { + a, i, home := configTestAPI(t, false) + secret := "credential-secret-never-return" + configWrite(t, filepath.Join(home, ".credentials.yaml"), secret) + if e := os.MkdirAll(filepath.Join(home, "profiles", "web"), 0750); e != nil { + t.Fatal(e) + } + var link, target string + switch level { + case "profiles": + link = filepath.Join(home, "profiles") + target = "private" + if e := os.MkdirAll(filepath.Join(home, "private", "web"), 0750); e != nil { + t.Fatal(e) + } + configWrite(t, filepath.Join(home, "private", "web", "cordis.patch.yml"), secret) + case "web": + link = filepath.Join(home, "profiles", "web") + target = "../private" + if e := os.Mkdir(filepath.Join(home, "private"), 0750); e != nil { + t.Fatal(e) + } + configWrite(t, filepath.Join(home, "private", "cordis.patch.yml"), secret) + case "leaf": + link = filepath.Join(home, "profiles", "web", "cordis.patch.yml") + target = "../../.credentials.yaml" + case "home": + link = home + target = home + "-other" + if e := os.Rename(home, target); e != nil { + t.Fatal(e) + } + } + if level != "home" { + if e := os.RemoveAll(link); e != nil { + t.Fatal(e) + } + } + if e := os.Symlink(target, link); e != nil { + t.Fatal(e) + } + for _, method := range []string{"GET", "PUT"} { + w := configCall(a, i, method, "profile", `{"content":"foo: bar"}`) + if w.Code != 400 || strings.Contains(w.Body.String(), secret) { + t.Fatalf("%s: %d %s", method, w.Code, w.Body.String()) + } + } + }) + } +} +func TestConfigRunningRejected(t *testing.T) { + a, i, _ := configTestAPI(t, true) + for _, tc := range []struct{ method, action string }{{"PUT", "profile"}} { + w := configCall(a, i, tc.method, tc.action, `{}`) + if w.Code != 409 { + t.Fatalf("%s: %d", tc.action, w.Code) + } + } +} +func TestConfigBackupRestore(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("requires chown") + } + a, i, home := configTestAPI(t, false) + const secret = "private-credential-token" + configWrite(t, filepath.Join(home, ".credentials.yaml"), secret) + if w := configCall(a, i, "PUT", "profile", `{"content":"foo: old"}`); w.Code != 200 { + t.Fatal(w.Body.String()) + } + w := configCall(a, i, "POST", "backups", `{"password":"original-password"}`) + var result struct { + ID string `json:"id"` + } + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &result) != nil || !backupIDPattern.MatchString(result.ID) { + t.Fatalf("%d %s", w.Code, w.Body.String()) + } + w = configCall(a, i, "GET", "backups?download="+result.ID, "") + if w.Code != 200 || !bytes.HasPrefix(w.Body.Bytes(), []byte("DSHPASS")) || bytes.Contains(w.Body.Bytes(), []byte(secret)) { + t.Fatalf("unsafe download: %d", w.Code) + } + if e := os.RemoveAll(filepath.Join(home, "profiles")); e != nil { + t.Fatal(e) + } + configWrite(t, filepath.Join(home, ".credentials.yaml"), "changed") + body := fmt.Sprintf(`{"backup_id":%q,"password":"original-password"}`, result.ID) + for n := 0; n < 3; n++ { + w = configCall(a, i, "POST", "restore", body) + if w.Code != 200 { + t.Fatalf("%d %s", w.Code, w.Body.String()) + } + } + w = configCall(a, i, "POST", "restore", body) + if w.Code != 409 { + t.Fatalf("snapshot retention: %d", w.Code) + } + got, e := os.ReadFile(filepath.Join(home, ".credentials.yaml")) + if e != nil || string(got) != secret { + t.Fatalf("restore: %q %v", got, e) + } + for _, p := range []string{"profiles", "profiles/web", "profiles/web/cordis.patch.yml"} { + st, e := os.Stat(filepath.Join(home, p)) + if e != nil { + t.Fatal(e) + } + if st.Sys().(*syscall.Stat_t).Uid != 1000 { + t.Fatalf("not owned by runtime: %s", p) + } + } + state, e := a.s.GetInstance(i.ID) + if e != nil || state.Status != "exited" { + t.Fatalf("state: %+v %v", state, e) + } + id := store.ID() + if e := os.Symlink(filepath.Join(a.root, "backup.key"), filepath.Join(a.root, "backups", i.UID, i.ID, id+".dshbak")); e != nil { + t.Fatal(e) + } + w = configCall(a, i, "GET", "backups?download="+id, "") + if w.Code == 200 { + t.Fatal("symlink download exposed key") + } +} +func TestConfigRestoreInvalidArchive(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("requires chown") + } + a, i, home := configTestAPI(t, false) + configWrite(t, filepath.Join(home, "cordis.patch.yml"), "original") + id := store.ID() + dir := filepath.Join(a.root, "backups", i.UID, i.ID) + if e := os.MkdirAll(dir, 0700); e != nil { + t.Fatal(e) + } + configWrite(t, filepath.Join(dir, id+".dshbak"), "corrupt") + w := configCall(a, i, "POST", "restore", fmt.Sprintf(`{"backup_id":%q}`, id)) + if w.Code != 400 || !strings.Contains(w.Body.String(), "invalid backup") { + t.Fatalf("%d %s", w.Code, w.Body.String()) + } + got, e := os.ReadFile(filepath.Join(home, "cordis.patch.yml")) + if e != nil || string(got) != "original" { + t.Fatalf("rollback: %q %v", got, e) + } + a.s.SetInstance(i.ID, "error") + w = configCall(a, i, "POST", "restore", fmt.Sprintf(`{"backup_id":%q}`, id)) + if w.Code != 409 { + t.Fatalf("recovery guard: %d", w.Code) + } +} + +func TestConfigRestoreOrchestration(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("requires chown") + } + for _, running := range []bool{false, true} { + for _, failure := range []string{"", "reconcile", "readiness"} { + t.Run(fmt.Sprintf("running=%v/%s", running, failure), func(t *testing.T) { + a, i, home := configTestAPI(t, running, failure) + configWrite(t, filepath.Join(home, ".credentials.yaml"), "archive") + if err := os.MkdirAll(filepath.Join(home, "pets", "nested"), 0750); err != nil { + t.Fatal(err) + } + configWrite(t, filepath.Join(home, "pets", "nested", "pet.json"), "{}") + w := configCall(a, i, "POST", "backups", `{"password":"original-password"}`) + if w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + var out struct{ ID string } + json.Unmarshal(w.Body.Bytes(), &out) + var kind string + var schema int + if err := a.s.DB.QueryRow("SELECT kind,schema FROM backups WHERE id=?", out.ID).Scan(&kind, &schema); err != nil || kind != "manual" || schema != 2 { + t.Fatalf("index: %s %d %v", kind, schema, err) + } + configWrite(t, filepath.Join(home, ".credentials.yaml"), "before-restore") + w = configCall(a, i, "POST", "restore", fmt.Sprintf(`{"backup_id":%q,"password":"original-password"}`, out.ID)) + wantCode, wantContent := 200, "archive" + if failure != "" { + wantCode, wantContent = 400, "before-restore" + } + if w.Code != wantCode { + t.Fatal(w.Code, w.Body.String()) + } + b, _ := os.ReadFile(filepath.Join(home, ".credentials.yaml")) + if string(b) != wantContent { + t.Fatalf("home: %s", b) + } + c, err := a.docker.Inspect(context.Background(), i.ContainerID) + if err != nil || c.State.Running != running { + t.Fatalf("runtime state: %+v %v", c, err) + } + st, err := os.Stat(filepath.Join(home, "pets", "nested", "pet.json")) + if err != nil || st.Sys().(*syscall.Stat_t).Uid != 1000 { + t.Fatalf("resource ownership: %v", err) + } + }) + } + } +} diff --git a/internal/api/entrypoint_test.go b/internal/api/entrypoint_test.go new file mode 100644 index 0000000..6f612d1 --- /dev/null +++ b/internal/api/entrypoint_test.go @@ -0,0 +1,97 @@ +package api + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestRuntimePrestart(t *testing.T) { + for _, status := range []string{"0", "23", "124", "137", "empty"} { + t.Run(status, func(t *testing.T) { + dir := t.TempDir() + if status != "empty" { + if err := os.MkdirAll(filepath.Join(dir, "profiles/web"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "profiles/web/package.json"), []byte("{}"), 0600); err != nil { + t.Fatal(err) + } + } + scripts := map[string]string{ + "node": "#!/bin/bash\nexit 0\n", + "timeout": `#!/bin/bash +printf "timeout %s\n" "$*" >> "$TRACE" +test "$1 $2 $3" = "--signal=TERM --kill-after=5s 120s" || exit 90 +shift 3 +exec "$@" +`, + "dsh": `#!/bin/bash +printf "dsh %s\n" "$*" >> "$TRACE" +test "$npm_config_ignore_scripts:$npm_config_ignore_pnpmfile" = true:true || exit 91 +if [ "$1" = plugin ]; then + test "$*" = "plugin --profile web install --ignore-scripts" || exit 92 + exit "$PLUGIN_STATUS" +fi +exit 0 +`, + "caddy": "#!/bin/bash\necho caddy >> \"$TRACE\"\nsleep 0.1\n", + } + for name, script := range scripts { + if err := os.WriteFile(filepath.Join(dir, name), []byte(script), 0700); err != nil { + t.Fatal(err) + } + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "bash", "../../image/entrypoint.sh", "web", "--no-open", "--port", "3080") + cmd.Env = append(os.Environ(), "PATH="+dir+":"+os.Getenv("PATH"), "DSH_HOME="+dir, "TRACE="+filepath.Join(dir, "trace"), "PLUGIN_STATUS="+status) + output, err := cmd.CombinedOutput() + data, readErr := os.ReadFile(filepath.Join(dir, "trace")) + if readErr != nil { + t.Fatal(readErr) + } + trace := string(data) + if status == "empty" { + if err != nil || strings.Contains(trace, "plugin") || !strings.Contains(trace, "dsh web") { + t.Fatalf("%v: %s: %s", err, trace, output) + } + return + } + if !strings.Contains(trace, "dsh plugin --profile web install --ignore-scripts") { + t.Fatal(trace) + } + if status == "0" { + if err != nil || !strings.Contains(string(output), "reconciliation complete") { + t.Fatalf("%v: %s", err, output) + } + if !strings.Contains(trace, "dsh web") { + t.Fatal(trace) + } + } else { + if err == nil || !strings.Contains(string(output), "failed (exit "+status+")") { + t.Fatalf("%v: %s", err, output) + } + if strings.Contains(trace, "caddy") || strings.Contains(trace, "dsh web") { + t.Fatalf("application started after failure: %s", trace) + } + } + }) + } +} + +func TestRuntimePNPMPinned(t *testing.T) { + data, err := os.ReadFile("../../image/Dockerfile") + if err != nil { + t.Fatal(err) + } + for _, required := range []string{"pnpm@10.11.0", `test "$(pnpm --version)" = "10.11.0"`, "USER node", `"/usr/bin/tini", "-g"`} { + if !strings.Contains(string(data), required) { + t.Errorf("missing %s", required) + } + } +} diff --git a/internal/api/files.go b/internal/api/files.go new file mode 100644 index 0000000..cecdd44 --- /dev/null +++ b/internal/api/files.go @@ -0,0 +1,820 @@ +package api + +import ( + "archive/zip" + "context" + "encoding/json" + "errors" + "git.misaka.ren/m1saka/dsh-web/internal/store" + "io" + "mime" + "net/http" + "os" + "path" + "sort" + "strings" + "sync" + "syscall" + "time" + + "golang.org/x/sys/unix" +) + +const maxUpload = 32 << 20 + +var uploadSlots = make(chan struct{}, 4) + +const ( + maxZipEntries = 10000 + maxZipDepth = 64 + maxZipBytes int64 = 10 << 30 + zipTimeout = 2 * time.Minute +) + +var zipSlots = make(chan struct{}, 4) +var errZipLimit = errors.New("archive limit exceeded") + +var workspaceMu sync.Mutex + +func renameNoReplace(root *os.Root, old, next string) error { + anchor, err := root.Open(".") + if err != nil { + return err + } + defer anchor.Close() + openParent := func(p string) (int, error) { + return unix.Openat2(int(anchor.Fd()), path.Dir(p), &unix.OpenHow{ + Flags: unix.O_RDONLY | unix.O_DIRECTORY | unix.O_CLOEXEC, + Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS, + }) + } + src, err := openParent(old) + if err != nil { + return err + } + defer unix.Close(src) + dst, err := openParent(next) + if err != nil { + return err + } + defer unix.Close(dst) + var st unix.Stat_t + if err = unix.Fstatat(src, path.Base(old), &st, unix.AT_SYMLINK_NOFOLLOW); err != nil { + return err + } + if st.Mode&unix.S_IFMT == unix.S_IFLNK { + return errors.New("unsafe path") + } + return unix.Renameat2(src, path.Base(old), dst, path.Base(next), unix.RENAME_NOREPLACE) +} + +func safeRel(p string) (string, error) { + p = strings.TrimSpace(strings.ReplaceAll(p, `\`, `/`)) + if p == "" { + return ".", nil + } + if strings.HasPrefix(p, "/") { + return "", errors.New("invalid path") + } + c := path.Clean(p) + if c == ".." || strings.HasPrefix(c, "../") { + return "", errors.New("invalid path") + } + return c, nil +} + +func workspacePath(uid string) string { return path.Join("workspaces", uid, "files") } + +func (a *API) openWorkspace(u store.User, instanceIDs ...string) (*os.Root, error) { + instanceID := "" + if len(instanceIDs) > 0 { + instanceID = instanceIDs[0] + } + base, err := os.OpenRoot(a.root) + if err != nil { + return nil, err + } + defer base.Close() + parts := []string{"workspaces", path.Join("workspaces", u.ID), workspacePath(u.ID)} + if instanceID != "" { + rel := path.Join("workspaces", u.ID, "instances", instanceID, "home") + if err := rootSafe(base, rel, false); err != nil { + return nil, err + } + return base.OpenRoot(rel) + } + for _, p := range parts { + if err = rootSafe(base, p, true); err != nil { + return nil, err + } + if err = base.Mkdir(p, 0750); err != nil && !errors.Is(err, os.ErrExist) { + return nil, err + } + } + if err = base.Chown(workspacePath(u.ID), 1000, 1000); err != nil { + return nil, err + } + return base.OpenRoot(workspacePath(u.ID)) +} + +func rootSafe(root *os.Root, rel string, finalMayMissing bool) error { + if rel == "." { + return nil + } + parts := strings.Split(rel, "/") + for i := range parts { + q := strings.Join(parts[:i+1], "/") + st, err := root.Lstat(q) + if err != nil { + if finalMayMissing && i == len(parts)-1 && errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + if st.Mode()&os.ModeSymlink != 0 || (i < len(parts)-1 && !st.IsDir()) { + return errors.New("unsafe path") + } + } + return nil +} + +func (a *API) workspace(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/ws/download" { + var flush func() + w, flush = deferJSONResponse(w) + defer flush() + } + u, _, err := a.user(r) + if err != nil { + fail(w, 401, "authentication required") + return + } + instanceID := strings.TrimSpace(r.URL.Query().Get("instance")) + if instanceID == "" && r.URL.Path != "/api/ws/usage" { + fail(w, http.StatusGone, "shared files removed; instance required") + return + } + if instanceID != "" { + // Pin this instance's home without holding the global lifecycle lock + // across client reads/writes. Lifecycle mutations reject active leases. + a.mu.Lock() + i, e := a.s.GetInstance(instanceID) + if e != nil || i.UID != u.ID { + a.mu.Unlock() + fail(w, 404, "instance not found") + return + } + if r.Method != http.MethodGet && (i.Status == "error" || a.upgradeRecoveryRequired(i)) { + a.mu.Unlock() + fail(w, 409, "instance requires recovery") + return + } + if a.workspaceReaders == nil { + a.workspaceReaders = make(map[string]int) + } + a.workspaceReaders[instanceID]++ + a.mu.Unlock() + defer func() { + a.mu.Lock() + a.workspaceReaders[instanceID]-- + if a.workspaceReaders[instanceID] == 0 { + delete(a.workspaceReaders, instanceID) + } + a.mu.Unlock() + }() + } + workspaceMu.Lock() + root, err := a.openWorkspace(u, instanceID) + workspaceMu.Unlock() + if err != nil { + fail(w, 500, "storage unavailable") + return + } + defer root.Close() + switch strings.TrimPrefix(r.URL.Path, "/api/ws/") { + case "usage": + a.wsUsage(w, r, root, u) + case "tree": + a.wsTree(w, r, root) + case "mkdir": + a.wsMkdir(w, r, root, u) + case "rename": + a.wsRename(w, r, root, u) + case "delete": + a.wsDelete(w, r, root, u) + case "upload": + a.wsUpload(w, r, root, u) + case "download": + a.wsDownload(w, r, root) + default: + fail(w, 404, "not found") + } +} + +func entry(root *os.Root, rel string) (os.FileInfo, error) { + if err := rootSafe(root, rel, false); err != nil { + return nil, err + } + return root.Lstat(rel) +} + +func (a *API) wsUsage(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) { + if r.Method != "GET" { + fail(w, 405, "method not allowed") + return + } + workspaceMu.Lock() + n, e := a.userUsage(u) + workspaceMu.Unlock() + if e != nil { + fail(w, 500, "usage unavailable") + return + } + writeJSON(w, map[string]int64{"used_bytes": n, "quota_bytes": u.Quota}) +} + +func (a *API) wsTree(w http.ResponseWriter, r *http.Request, root *os.Root) { + workspaceMu.Lock() + locked := true + defer func() { + if locked { + workspaceMu.Unlock() + } + }() + if r.Method != http.MethodGet { + fail(w, 405, "method not allowed") + return + } + rel, err := safeRel(r.URL.Query().Get("path")) + if err != nil { + fail(w, 400, "invalid path") + return + } + st, err := entry(root, rel) + if err != nil || !st.IsDir() { + fail(w, 404, "not found") + return + } + es, err := readRootDir(root, rel) + if err != nil { + if errors.Is(err, errDirectoryLimit) { + fail(w, 413, "directory entry limit exceeded") + return + } + fail(w, 404, "not found") + return + } + out := make([]map[string]any, 0, len(es)) + for _, de := range es { + p := de.Name() + if rel != "." { + p = rel + "/" + p + } + st, err := entry(root, p) + if err != nil || (!st.IsDir() && !st.Mode().IsRegular()) { + continue + } + sz := int64(0) + if !st.IsDir() { + sz = st.Size() + } + out = append(out, map[string]any{"name": de.Name(), "path": p, "is_dir": st.IsDir(), "size": sz, "mod_time": st.ModTime()}) + } + sort.Slice(out, func(i, j int) bool { return out[i]["name"].(string) < out[j]["name"].(string) }) + workspaceMu.Unlock() + locked = false + writeJSON(w, out) +} + +func (a *API) wsMkdir(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) { + if r.Method != http.MethodPost { + fail(w, 405, "method not allowed") + return + } + var x struct { + Path string `json:"path"` + } + if json.NewDecoder(r.Body).Decode(&x) != nil { + fail(w, 400, "invalid json") + return + } + p, e := safeRel(x.Path) + workspaceMu.Lock() + locked := true + defer func() { + if locked { + workspaceMu.Unlock() + } + }() + if e != nil || p == "." || rootSafe(root, path.Dir(p), false) != nil { + fail(w, 400, "cannot create directory") + return + } + if e = root.Mkdir(p, 0750); e != nil { + fail(w, 400, "cannot create directory") + return + } + if f, e := root.OpenFile(p, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0); e == nil { + _ = f.Chown(1000, 1000) + f.Close() + } + workspaceMu.Unlock() + locked = false + a.s.AddAudit(u.ID, "workspace.mkdir", p, "") + writeJSON(w, map[string]bool{"ok": true}) +} + +func (a *API) wsRename(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) { + if r.Method != http.MethodPost { + fail(w, 405, "method not allowed") + return + } + var x struct { + Path string `json:"path"` + NewPath string `json:"new_path"` + } + if json.NewDecoder(r.Body).Decode(&x) != nil { + fail(w, 400, "invalid json") + return + } + p, e := safeRel(x.Path) + q, e2 := safeRel(x.NewPath) + workspaceMu.Lock() + locked := true + defer func() { + if locked { + workspaceMu.Unlock() + } + }() + if e != nil || e2 != nil || p == "." || q == "." || rootSafe(root, p, false) != nil || rootSafe(root, path.Dir(q), false) != nil { + fail(w, 400, "rename failed") + return + } + st, e := root.Lstat(p) + if e != nil || st.Mode()&os.ModeSymlink != 0 { + fail(w, 400, "rename failed") + return + } + if _, e = root.Lstat(q); e == nil { + fail(w, 400, "rename failed") + return + } + if e = renameNoReplace(root, p, q); e != nil { + fail(w, 400, "rename failed") + return + } + workspaceMu.Unlock() + locked = false + a.s.AddAudit(u.ID, "workspace.rename", p, q) + writeJSON(w, map[string]bool{"ok": true}) +} + +func (a *API) wsDelete(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) { + workspaceMu.Lock() + locked := true + defer func() { + if locked { + workspaceMu.Unlock() + } + }() + if r.Method != http.MethodDelete { + fail(w, 405, "method not allowed") + return + } + p, e := safeRel(r.URL.Query().Get("path")) + if e != nil || p == "." { + fail(w, 400, "delete failed") + return + } + st, e := entry(root, p) + if e != nil || st.Mode()&os.ModeSymlink != 0 { + fail(w, 400, "delete failed") + return + } + if st.IsDir() { + es, e := readRootDir(root, p) + if e != nil || len(es) != 0 { + fail(w, 400, "delete failed") + return + } + } + if e = root.Remove(p); e != nil { + fail(w, 400, "delete failed") + return + } + workspaceMu.Unlock() + locked = false + a.s.AddAudit(u.ID, "workspace.delete", p, "") + writeJSON(w, map[string]bool{"ok": true}) +} + +func (a *API) wsUpload(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) { + if r.Method != "POST" { + fail(w, 405, "method not allowed") + return + } + select { + case uploadSlots <- struct{}{}: + defer func() { <-uploadSlots }() + default: + fail(w, 429, "too many concurrent uploads") + return + } + usedBefore, usageErr := a.userUsage(u) + if usageErr != nil || usedBefore >= u.Quota { + fail(w, 413, "quota exceeded or usage unavailable") + return + } + r.Body = http.MaxBytesReader(w, r.Body, maxUpload+(1<<20)) + controller := http.NewResponseController(w) + if err := controller.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil && !errors.Is(err, http.ErrNotSupported) { + fail(w, 400, "upload unavailable") + return + } + defer controller.SetReadDeadline(time.Time{}) + if r.ParseMultipartForm(1<<20) != nil { + fail(w, 400, "invalid upload") + return + } + defer r.MultipartForm.RemoveAll() + workspaceMu.Lock() + locked := true + defer func() { + if locked { + workspaceMu.Unlock() + } + }() + dir, e := safeRel(r.FormValue("path")) + if e != nil || rootSafe(root, dir, false) != nil { + fail(w, 400, "invalid directory") + return + } + input, h, e := r.FormFile("file") + if e != nil { + fail(w, 400, "file required") + return + } + defer input.Close() + name, e := safeRel(h.Filename) + if e != nil || name == "." || path.Base(name) != name { + fail(w, 400, "invalid filename") + return + } + if !uploadExtensionAllowed(name, os.Getenv("UPLOAD_ALLOWED_EXTENSIONS")) { + fail(w, 415, "upload file type denied") + return + } + target := path.Join(dir, name) + if _, e = root.Lstat(target); !errors.Is(e, os.ErrNotExist) { + fail(w, 409, "target exists") + return + } + used, e := a.userUsage(u) + if e != nil || h.Size > maxUpload || used+h.Size > u.Quota { + fail(w, 413, "quota or upload limit exceeded") + return + } + tmp := path.Join(dir, ".upload-"+store.ID()) + f, e := root.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if e != nil { + fail(w, 400, "upload directory unavailable") + return + } + defer root.Remove(tmp) + n, e := io.Copy(f, io.LimitReader(input, maxUpload+1)) + if e == nil && (n > maxUpload || used+n > u.Quota) { + e = errors.New("quota exceeded") + } + if e == nil { + e = f.Chown(1000, 1000) + } + if e == nil { + e = f.Sync() + } + f.Close() + if e == nil { + e = renameNoReplace(root, tmp, target) + } + if errors.Is(e, os.ErrExist) { + fail(w, 409, "target exists") + return + } + if e != nil { + fail(w, 400, "upload failed") + return + } + workspaceMu.Unlock() + locked = false + a.s.AddAudit(u.ID, "workspace.upload", target, "") + writeJSON(w, map[string]bool{"ok": true}) +} + +// Empty policy permits arbitrary workspace files; configured entries are exact +// case-insensitive extensions, not MIME claims supplied by the client. +func uploadExtensionAllowed(name, policy string) bool { + if strings.TrimSpace(policy) == "" { + return true + } + ext := strings.ToLower(path.Ext(name)) + for _, entry := range strings.Split(policy, ",") { + entry = strings.ToLower(strings.TrimSpace(entry)) + if strings.HasPrefix(entry, ".") && len(entry) > 1 && ext == entry { + return true + } + } + return false +} + +func (a *API) userUsage(u store.User) (int64, error) { + base, e := os.OpenRoot(a.root) + if e != nil { + return 0, e + } + defer base.Close() + root, e := base.OpenRoot(path.Join("workspaces", u.ID)) + if e != nil { + return 0, e + } + defer root.Close() + var total int64 + count := 0 + var walk func(string, int) error + walk = func(p string, depth int) error { + count++ + if count > 100000 || depth > 128 { + return errors.New("tree limit") + } + st, e := root.Lstat(p) + if e != nil { + return e + } + if st.Mode().IsRegular() { + total += st.Size() + return nil + } + if !st.IsDir() { + return nil + } + f, e := root.OpenFile(p, os.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW, 0) + if e != nil { + return e + } + defer f.Close() + for { + ds, readErr := f.ReadDir(128) + for _, d := range ds { + if e = walk(path.Join(p, d.Name()), depth+1); e != nil { + return e + } + } + if readErr == io.EOF { + return nil + } + if readErr != nil { + return readErr + } + if count >= 100000 { + return errors.New("tree limit") + } + } + } + e = walk(".", 0) + return total, e +} + +func (a *API) wsDownload(w http.ResponseWriter, r *http.Request, root *os.Root) { + if r.Method != http.MethodGet { + fail(w, 405, "method not allowed") + return + } + p, e := safeRel(r.URL.Query().Get("path")) + if e != nil { + fail(w, 400, "invalid path") + return + } + anchor, e := root.Open(".") + if e != nil { + fail(w, 404, "not found") + return + } + defer anchor.Close() + f, e := openDownload(anchor, p) + if e != nil { + fail(w, 404, "not found") + return + } + defer f.Close() + st, e := f.Stat() + if e == nil && st.IsDir() { + a.wsDownloadZip(w, r, f, p) + return + } + if e != nil || !st.Mode().IsRegular() { + fail(w, 400, "invalid file") + return + } + w.Header().Set("Content-Disposition", "attachment") + http.ServeContent(w, r, path.Base(p), st.ModTime(), f) +} + +func openDownload(anchor *os.File, p string) (*os.File, error) { + fd, err := unix.Openat2(int(anchor.Fd()), p, &unix.OpenHow{ + Flags: unix.O_RDONLY | unix.O_NONBLOCK | unix.O_CLOEXEC, + Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS | unix.RESOLVE_NO_XDEV, + }) + if err != nil { + return nil, err + } + f := os.NewFile(uintptr(fd), p) + st, err := f.Stat() + if err == nil && (!st.IsDir() && !st.Mode().IsRegular() || st.Mode().IsRegular() && st.Sys().(*syscall.Stat_t).Nlink != 1) { + err = errors.New("unsafe file") + } + if err != nil { + f.Close() + return nil, err + } + return f, nil +} + +type zipEntry struct { + name string + info os.FileInfo +} + +func zipManifest(ctx context.Context, anchor *os.File) ([]zipEntry, error) { + var entries []zipEntry + var total int64 + var walk func(string, int) error + walk = func(p string, depth int) error { + if err := ctx.Err(); err != nil { + return err + } + if depth > maxZipDepth || len(entries) >= maxZipEntries { + return errZipLimit + } + f, err := openDownload(anchor, p) + if err != nil { + return err + } + defer f.Close() + st, err := f.Stat() + if err != nil { + return err + } + if p != "." { + if st.Size() < 0 || st.Mode().IsRegular() && st.Size() > maxZipBytes-total { + return errZipLimit + } + if st.Mode().IsRegular() { + total += st.Size() + } + entries = append(entries, zipEntry{p, st}) + } + if !st.IsDir() { + return nil + } + for { + ds, err := f.ReadDir(128) + if err != nil && err != io.EOF { + return err + } + for _, d := range ds { + if strings.ContainsAny(d.Name(), "\\:") { + return errors.New("unsafe archive name") + } + if e := walk(path.Join(p, d.Name()), depth+1); e != nil { + return e + } + } + if err == io.EOF { + return nil + } + } + } + err := walk(".", 0) + return entries, err +} + +type zipContextWriter struct { + ctx context.Context + w io.Writer +} + +func (w zipContextWriter) Write(p []byte) (int, error) { + if err := w.ctx.Err(); err != nil { + return 0, err + } + return w.w.Write(p) +} + +func streamZip(ctx context.Context, w io.Writer, anchor *os.File, entries []zipEntry) error { + z := zip.NewWriter(zipContextWriter{ctx, w}) + buf := make([]byte, 32<<10) + for _, item := range entries { + if err := ctx.Err(); err != nil { + return err + } + f, err := openDownload(anchor, item.name) + if err != nil { + return err + } + err = func() error { + defer f.Close() + st, err := f.Stat() + if err != nil { + return err + } + if !os.SameFile(st, item.info) || st.Mode() != item.info.Mode() || st.Size() != item.info.Size() || !st.ModTime().Equal(item.info.ModTime()) { + return errors.New("file changed") + } + h, err := zip.FileInfoHeader(st) + if err != nil { + return err + } + h.Name = item.name + if st.IsDir() { + h.Name += "/" + } + dst, err := z.CreateHeader(h) + if err != nil || st.IsDir() { + return err + } + n, err := io.CopyBuffer(dst, io.LimitReader(f, item.info.Size()), buf) + if err == nil && n != item.info.Size() { + err = io.ErrUnexpectedEOF + } + if err == nil { + after, statErr := f.Stat() + if statErr != nil { + return statErr + } + if after.Size() != st.Size() || !after.ModTime().Equal(st.ModTime()) { + return errors.New("file changed") + } + } + return err + }() + if err != nil { + return err + } + } + return z.Close() +} + +func (a *API) wsDownloadZip(w http.ResponseWriter, r *http.Request, anchor *os.File, p string) { + select { + case zipSlots <- struct{}{}: + defer func() { <-zipSlots }() + default: + fail(w, 429, "too many archive downloads") + return + } + ctx, cancel := context.WithTimeout(r.Context(), zipTimeout) + defer cancel() + controller := http.NewResponseController(w) + if err := controller.SetWriteDeadline(time.Now().Add(zipTimeout)); err != nil && !errors.Is(err, http.ErrNotSupported) { + fail(w, 500, "download unavailable") + return + } + defer controller.SetWriteDeadline(time.Time{}) + entries, err := zipManifest(ctx, anchor) + if err != nil { + code := 400 + if errors.Is(err, errZipLimit) { + code = 413 + } + fail(w, code, "cannot archive directory") + return + } + name := path.Base(p) + if p == "." { + name = "workspace" + } + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": name + ".zip"})) + if err := streamZip(ctx, w, anchor, entries); err != nil { + // A failed stream must not become a successful, silently incomplete archive. + panic(http.ErrAbortHandler) + } +} + +const maxDirectoryEntries = 10000 + +var errDirectoryLimit = errors.New("directory entry limit exceeded") + +func readRootDir(root *os.Root, p string) ([]os.DirEntry, error) { + f, e := root.OpenFile(p, os.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW, 0) + if e != nil { + return nil, e + } + defer f.Close() + entries, err := f.ReadDir(maxDirectoryEntries + 1) + if len(entries) > maxDirectoryEntries { + return nil, errDirectoryLimit + } + if err == io.EOF { + err = nil + } + return entries, err +} diff --git a/internal/api/files_test.go b/internal/api/files_test.go new file mode 100644 index 0000000..49569c0 --- /dev/null +++ b/internal/api/files_test.go @@ -0,0 +1,542 @@ +package api + +import ( + "archive/zip" + "bytes" + "context" + "errors" + "fmt" + "git.misaka.ren/m1saka/dsh-web/internal/store" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + "time" +) + +func TestUploadExtensionPolicy(t *testing.T) { + for _, tc := range []struct { + name, policy string + want bool + }{ + {"file.exe", "", true}, {"file.TXT", ".txt, .md", true}, + {"file.txt.exe", ".txt", false}, {"file", ".txt", false}, + {"file.exe", "*,txt", false}, {"file.json", ".txt,.json", true}, + } { + if got := uploadExtensionAllowed(tc.name, tc.policy); got != tc.want { + t.Errorf("%q %q: %v", tc.name, tc.policy, got) + } + } +} + +func downloadTestRoot(t *testing.T) (string, *os.Root) { + t.Helper() + dir := t.TempDir() + root, err := os.OpenRoot(dir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { root.Close() }) + return dir, root +} + +func TestDirectoryDownloadZip(t *testing.T) { + dir, root := downloadTestRoot(t) + if err := os.MkdirAll(filepath.Join(dir, "docs/empty"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "docs/你好.txt"), []byte("hello zip"), 0600); err != nil { + t.Fatal(err) + } + for _, p := range []string{"docs", ".", "", "docs/empty"} { + t.Run(p, func(t *testing.T) { + w := httptest.NewRecorder() + (&API{}).wsDownload(w, httptest.NewRequest("GET", "/api/ws/download?path="+url.QueryEscape(p), nil), root) + if w.Code != 200 || w.Header().Get("Content-Type") != "application/zip" || !strings.Contains(w.Header().Get("Content-Disposition"), ".zip") { + t.Fatal(w.Code, w.Header(), w.Body.String()) + } + z, err := zip.NewReader(bytes.NewReader(w.Body.Bytes()), int64(w.Body.Len())) + if err != nil { + t.Fatal(err) + } + want := map[string]string{"empty/": "", "你好.txt": "hello zip"} + if p == "." || p == "" { + want = map[string]string{"docs/": "", "docs/empty/": "", "docs/你好.txt": "hello zip"} + } + if p == "docs/empty" { + want = map[string]string{} + } + if len(z.File) != len(want) { + t.Fatalf("entries %d want %d", len(z.File), len(want)) + } + for _, f := range z.File { + expect, ok := want[f.Name] + if !ok { + t.Fatal("unexpected entry", f.Name) + } + r, err := f.Open() + if err != nil { + t.Fatal(err) + } + b, err := io.ReadAll(r) + r.Close() + if err != nil || string(b) != expect { + t.Fatal(f.Name, string(b), err) + } + } + }) + } + r := httptest.NewRequest("GET", "/api/ws/download?path=docs/"+url.QueryEscape("你好.txt"), nil) + r.Header.Set("Range", "bytes=1-3") + w := httptest.NewRecorder() + (&API{}).wsDownload(w, r, root) + if w.Code != 206 || w.Body.String() != "ell" { + t.Fatal(w.Code, w.Body.String()) + } +} + +func TestDirectoryDownloadRejectsUnsafeEntries(t *testing.T) { + for _, kind := range []string{"symlink", "internal-link", "hardlink", "fifo", "backslash", "drive"} { + t.Run(kind, func(t *testing.T) { + dir, root := downloadTestRoot(t) + if err := os.Mkdir(filepath.Join(dir, "docs"), 0700); err != nil { + t.Fatal(err) + } + outside := filepath.Join(t.TempDir(), "secret") + if err := os.WriteFile(outside, []byte("secret"), 0600); err != nil { + t.Fatal(err) + } + target := filepath.Join(dir, "docs/unsafe") + var err error + switch kind { + case "symlink": + err = os.Symlink(outside, target) + case "internal-link": + err = os.Symlink(".", target) + case "hardlink": + err = os.Link(outside, target) + case "fifo": + err = syscall.Mkfifo(target, 0600) + case "backslash": + err = os.WriteFile(filepath.Join(dir, `docs/..\secret`), nil, 0600) + case "drive": + err = os.WriteFile(filepath.Join(dir, "docs/C:secret"), nil, 0600) + } + if err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + (&API{}).wsDownload(w, httptest.NewRequest("GET", "/api/ws/download?path=docs", nil), root) + if w.Code != 400 || strings.Contains(w.Body.String(), "secret") { + t.Fatal(w.Code, w.Body.String()) + } + }) + } + dir, root := downloadTestRoot(t) + if err := os.Mkdir(filepath.Join(dir, "real"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "real/file"), []byte("secret"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink("real", filepath.Join(dir, "link")); err != nil { + t.Fatal(err) + } + for _, p := range []string{"../secret", "/etc/passwd", `..\secret`, "link", "link/file"} { + w := httptest.NewRecorder() + (&API{}).wsDownload(w, httptest.NewRequest("GET", "/api/ws/download?path="+url.QueryEscape(p), nil), root) + if w.Code < 400 { + t.Fatal("unsafe path accepted", p) + } + } +} + +func TestDirectoryDownloadLimits(t *testing.T) { + for _, limit := range []string{"size", "depth", "count"} { + t.Run(limit, func(t *testing.T) { + dir, root := downloadTestRoot(t) + switch limit { + case "size": + f, err := os.Create(filepath.Join(dir, "large")) + if err != nil { + t.Fatal(err) + } + err = f.Truncate(maxZipBytes + 1) + f.Close() + if err != nil { + t.Fatal(err) + } + case "depth": + if err := os.MkdirAll(filepath.Join(dir, strings.Repeat("d/", maxZipDepth+1)), 0700); err != nil { + t.Fatal(err) + } + case "count": + for i := 0; i <= maxZipEntries; i++ { + if err := os.WriteFile(filepath.Join(dir, fmt.Sprint(i)), nil, 0600); err != nil { + t.Fatal(err) + } + } + } + w := httptest.NewRecorder() + (&API{}).wsDownload(w, httptest.NewRequest("GET", "/api/ws/download?path=.", nil), root) + if w.Code != 413 { + t.Fatal(w.Code, w.Body.String()) + } + }) + } +} + +func TestZipManifestRechecksAndCancellation(t *testing.T) { + for _, change := range []string{"symlink", "grow", "cancel"} { + t.Run(change, func(t *testing.T) { + dir, root := downloadTestRoot(t) + p := filepath.Join(dir, "file") + if err := os.WriteFile(p, []byte("data"), 0600); err != nil { + t.Fatal(err) + } + f, err := root.Open(".") + if err != nil { + t.Fatal(err) + } + defer f.Close() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + es, err := zipManifest(ctx, f) + if err != nil { + t.Fatal(err) + } + switch change { + case "symlink": + if err := os.Remove(p); err != nil { + t.Fatal(err) + } + if err := os.Symlink("/etc/passwd", p); err != nil { + t.Fatal(err) + } + case "grow": + if err := os.WriteFile(p, []byte("more data"), 0600); err != nil { + t.Fatal(err) + } + case "cancel": + cancel() + } + if err := streamZip(ctx, io.Discard, f, es); err == nil { + t.Fatal("unsafe stream accepted") + } + if change == "cancel" { + if _, err := zipManifest(ctx, f); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + } + }) + } +} + +type failingZipWriter struct{} + +func (failingZipWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe } + +func TestZipWriteFailureAndSizeBound(t *testing.T) { + dir, root := downloadTestRoot(t) + p := filepath.Join(dir, "file") + if err := os.WriteFile(p, bytes.Repeat([]byte("x"), 65536), 0600); err != nil { + t.Fatal(err) + } + f, err := root.Open(".") + if err != nil { + t.Fatal(err) + } + defer f.Close() + es, err := zipManifest(context.Background(), f) + if err != nil { + t.Fatal(err) + } + if err := streamZip(context.Background(), failingZipWriter{}, f, es); !errors.Is(err, io.ErrClosedPipe) { + t.Fatal(err) + } + w := &growingZipWriter{path: p} + if err := streamZip(context.Background(), w, f, es); err == nil { + t.Fatal("concurrent growth accepted") + } + if w.Len() > 65536+1024 { + t.Fatal("stream exceeded manifest bound", w.Len()) + } +} + +type growingZipWriter struct { + bytes.Buffer + path string +} + +func (w *growingZipWriter) Write(p []byte) (int, error) { + if w.path != "" { + f, err := os.OpenFile(w.path, os.O_WRONLY|os.O_APPEND, 0) + if err != nil { + return 0, err + } + _, err = f.Write([]byte("growth")) + f.Close() + if err != nil { + return 0, err + } + w.path = "" + } + return w.Buffer.Write(p) +} + +type blockedZipWriter struct { + *httptest.ResponseRecorder + started chan struct{} + release chan struct{} + deadlines []time.Time +} + +func (w *blockedZipWriter) Write(p []byte) (int, error) { + if w.started != nil { + close(w.started) + w.started = nil + <-w.release + } + return w.ResponseRecorder.Write(p) +} +func (w *blockedZipWriter) SetWriteDeadline(d time.Time) error { + w.deadlines = append(w.deadlines, d) + return nil +} + +func TestZipTransferNoLocksAndConcurrencyLimit(t *testing.T) { + _, root := downloadTestRoot(t) + a := &API{} + started := make(chan struct{}) + w := &blockedZipWriter{ResponseRecorder: httptest.NewRecorder(), started: started, release: make(chan struct{})} + done := make(chan struct{}) + go func() { + defer close(done) + a.wsDownload(w, httptest.NewRequest("GET", "/api/ws/download?path=.", nil), root) + }() + defer func() { close(w.release); <-done }() + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("download not started") + } + if !workspaceMu.TryLock() { + t.Fatal("workspace lock held across write") + } + workspaceMu.Unlock() + if !a.mu.TryLock() { + t.Fatal("lifecycle lock held across write") + } + a.mu.Unlock() + if len(w.deadlines) != 1 || w.deadlines[0].IsZero() { + t.Fatal("missing deadline") + } + for i := 1; i < cap(zipSlots); i++ { + zipSlots <- struct{}{} + } + defer func() { + for i := 1; i < cap(zipSlots); i++ { + <-zipSlots + } + }() + busy := httptest.NewRecorder() + a.wsDownload(busy, httptest.NewRequest("GET", "/api/ws/download?path=.", nil), root) + if busy.Code != 429 { + t.Fatal(busy.Code) + } +} + +func TestRenameNoReplace(t *testing.T) { + dir := t.TempDir() + root, err := os.OpenRoot(dir) + if err != nil { + t.Fatal(err) + } + defer root.Close() + for _, source := range []string{"source", ".upload-test"} { + if err := os.WriteFile(filepath.Join(dir, source), []byte("new"), 0600); err != nil { + t.Fatal(err) + } + if _, err := root.Lstat("target"); !errors.Is(err, os.ErrNotExist) { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "target"), []byte("runtime"), 0600); err != nil { + t.Fatal(err) + } + if err := renameNoReplace(root, source, "target"); !errors.Is(err, os.ErrExist) { + t.Fatalf("overwrite: %v", err) + } + b, err := root.ReadFile("target") + if err != nil || string(b) != "runtime" { + t.Fatalf("target changed: %q %v", b, err) + } + if err := root.Remove("target"); err != nil { + t.Fatal(err) + } + if err := renameNoReplace(root, source, "target"); err != nil { + t.Fatal(err) + } + if err := root.Remove("target"); err != nil { + t.Fatal(err) + } + } + if err := os.Mkdir(filepath.Join(dir, "real"), 0700); err != nil { + t.Fatal(err) + } + if err := os.Symlink("real", filepath.Join(dir, "link")); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "source"), []byte("new"), 0600); err != nil { + t.Fatal(err) + } + for _, target := range []string{"link/target", "../target"} { + if err := renameNoReplace(root, "source", target); err == nil { + t.Fatalf("unsafe parent accepted: %s", target) + } + } + if err := renameNoReplace(root, "link", "target"); err == nil { + t.Fatal("symlink source accepted") + } +} + +type stalledUpload struct { + started chan struct{} + release chan struct{} +} + +func (b *stalledUpload) Read([]byte) (int, error) { + close(b.started) + <-b.release + return 0, io.EOF +} +func (b *stalledUpload) Close() error { return nil } + +type deadlineRecorder struct { + *httptest.ResponseRecorder + deadlines []time.Time +} + +func (w *deadlineRecorder) SetReadDeadline(d time.Time) error { + w.deadlines = append(w.deadlines, d) + return nil +} + +func TestLegacyWorkspaceRemoved(t *testing.T) { + a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("unexpected docker call") }) + for _, method := range []string{"GET", "POST", "DELETE"} { + for _, endpoint := range []string{"tree", "download", "upload", "mkdir", "rename", "delete"} { + w := httptest.NewRecorder() + a.workspace(w, runtimeRequest(method, "/api/ws/"+endpoint, "", sid)) + if w.Code != http.StatusGone { + t.Fatalf("%s %s: %d", method, endpoint, w.Code) + } + } + } +} + +func TestWorkspaceSafetyAndUpload(t *testing.T) { + s, err := store.Open(filepath.Join(t.TempDir(), "test.sqlite")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + a := &API{s: s, root: t.TempDir()} + u := store.User{ID: "user", Quota: 8} + root, e := a.openWorkspace(u) + if e != nil { + t.Fatal(e) + } + defer root.Close() + for _, p := range []string{"", ".", "a/..", "../other", "/tmp"} { + w := httptest.NewRecorder() + a.wsDelete(w, httptest.NewRequest("DELETE", "/api/ws/delete?path="+p, nil), root, u) + if w.Code < 400 { + t.Fatalf("deleted %q", p) + } + } + outside := t.TempDir() + os.WriteFile(filepath.Join(outside, "secret"), []byte("secret"), 0600) + if e = os.Symlink(outside, filepath.Join(a.root, "workspaces/user/files/link")); e != nil { + t.Fatal(e) + } + w := httptest.NewRecorder() + a.wsDownload(w, httptest.NewRequest("GET", "/api/ws/download?path=link/secret", nil), root) + if w.Code < 400 { + t.Fatal("escaped root") + } + upload := func(name, content string) int { + var b bytes.Buffer + m := multipart.NewWriter(&b) + m.WriteField("path", ".") + f, _ := m.CreateFormFile("file", name) + f.Write([]byte(content)) + m.Close() + r := httptest.NewRequest("POST", "/api/ws/upload", &b) + r.Header.Set("Content-Type", m.FormDataContentType()) + w := httptest.NewRecorder() + a.wsUpload(w, r, root, u) + return w.Code + } + if code := upload("file.txt", "hello"); code != 200 { + t.Fatal(code) + } + if code := upload("big.txt", "12345"); code != 413 { + t.Fatal("quota", code) + } + if code := upload("file.txt", "x"); code != 409 { + t.Fatal("overwrite", code) + } + w = httptest.NewRecorder() + a.wsDownload(w, httptest.NewRequest("GET", "/api/ws/download?path=file.txt", nil), root) + if w.Code != 200 || w.Body.String() != "hello" { + t.Fatal(w.Code, w.Body.String()) + } + for _, op := range []struct{ action, body string }{ + {"mkdir", `{"path":"folder"}`}, + {"rename", `{"path":"folder","new_path":"renamed"}`}, + {"delete", ""}, + } { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/api/ws/"+op.action, strings.NewReader(op.body)) + switch op.action { + case "mkdir": + a.wsMkdir(w, r, root, u) + case "rename": + a.wsRename(w, r, root, u) + case "delete": + a.wsDelete(w, httptest.NewRequest("DELETE", "/api/ws/delete?path=renamed", nil), root, u) + } + if w.Code != 200 { + t.Fatal(op.action, w.Code, w.Body.String()) + } + } + rows, err := s.DB.Query("SELECT uid,action,target,detail FROM audit ORDER BY id") + if err != nil { + t.Fatal(err) + } + defer rows.Close() + want := [][3]string{{"workspace.upload", "file.txt", ""}, {"workspace.mkdir", "folder", ""}, {"workspace.rename", "folder", "renamed"}, {"workspace.delete", "renamed", ""}} + i := 0 + for rows.Next() { + var uid, action, target, detail string + if err := rows.Scan(&uid, &action, &target, &detail); err != nil { + t.Fatal(err) + } + if i >= len(want) || uid != u.ID || [3]string{action, target, detail} != want[i] { + t.Fatal("unexpected audit", uid, action, target, detail) + } + i++ + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + if i != len(want) { + t.Fatal("missing audit entries", i) + } +} diff --git a/internal/api/images.go b/internal/api/images.go new file mode 100644 index 0000000..67fe895 --- /dev/null +++ b/internal/api/images.go @@ -0,0 +1,512 @@ +package api + +import ( + "archive/tar" + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + imageassets "git.misaka.ren/m1saka/dsh-web/image" + "git.misaka.ren/m1saka/dsh-web/internal/store" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "sync" + "time" +) + +// Caller holds buildQueue; interrupted jobs cannot be reported as successful. +func (a *API) ensureBuilds() error { + if buildQueue.initialized[a.s.DB] { + return nil + } + _, err := a.s.DB.Exec(`CREATE TABLE IF NOT EXISTS image_builds(id TEXT PRIMARY KEY,version TEXT NOT NULL,status TEXT NOT NULL,logs TEXT NOT NULL DEFAULT '',error TEXT NOT NULL DEFAULT '',image_id TEXT NOT NULL DEFAULT '',created_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS image_build_requests(user_id TEXT NOT NULL,build_id TEXT PRIMARY KEY,created_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS images(image_id TEXT PRIMARY KEY,version TEXT NOT NULL,recipe_rev TEXT,platform TEXT,tag TEXT NOT NULL DEFAULT '',status TEXT NOT NULL,built_at TEXT NOT NULL,usage_count INTEGER NOT NULL DEFAULT 0);`) + if err != nil { + return err + } + for _, q := range []string{ + "ALTER TABLE image_builds ADD COLUMN requested_by TEXT NOT NULL DEFAULT ''", + "ALTER TABLE image_builds ADD COLUMN recipe_rev TEXT", + "ALTER TABLE image_builds ADD COLUMN platform TEXT", + "ALTER TABLE image_builds ADD COLUMN finished_at TEXT", + "ALTER TABLE images ADD COLUMN recipe_rev TEXT", + "ALTER TABLE images ADD COLUMN platform TEXT", + "ALTER TABLE images ADD COLUMN usage_count INTEGER NOT NULL DEFAULT 0", + } { + if _, e := a.s.DB.Exec(q); e != nil && !strings.Contains(strings.ToLower(e.Error()), "duplicate column") { + return e + } + } + _, err = a.s.DB.Exec("DROP INDEX IF EXISTS image_builds_version; CREATE UNIQUE INDEX IF NOT EXISTS image_builds_identity ON image_builds(version,recipe_rev,platform) WHERE recipe_rev IS NOT NULL AND platform IS NOT NULL AND status IN ('queued','running','validating','success')") + if err != nil { + return err + } + if !buildQueue.initialized[a.s.DB] { + if _, err = a.s.DB.Exec("UPDATE image_builds SET status='failed',error='image template identity is unknown' WHERE status='queued' AND (recipe_rev IS NULL OR recipe_rev<>? OR platform IS NULL OR platform<>?)", imageRecipeRev, imagePlatform); err != nil { + return err + } + if _, err = a.s.DB.Exec("UPDATE image_builds SET status='failed',error='build interrupted by process restart' WHERE status IN ('running','validating')"); err != nil { + return err + } + buildQueue.initialized[a.s.DB] = true + } + return nil +} +func (a *API) builds(w http.ResponseWriter, r *http.Request) { + u, _, authErr := a.user(r) + if authErr != nil { + fail(w, 401, "authentication required") + return + } + if r.Method != http.MethodGet && r.Method != http.MethodPost { + fail(w, 405, "method not allowed") + return + } + var in struct{ Version string } + if r.Method == http.MethodPost { + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)) + dec.DisallowUnknownFields() + if dec.Decode(&in) != nil || dec.Decode(new(any)) != io.EOF || !validBuildVersion(in.Version) { + fail(w, 400, "a fixed version is required") + return + } + } + buildQueue.Lock() + defer buildQueue.Unlock() + if err := a.ensureBuilds(); err != nil { + fail(w, 500, "database error") + return + } + if r.Method == http.MethodGet { + rows, err := a.s.DB.Query("SELECT id,version,status,logs,error,image_id,created_at,requested_by,recipe_rev,platform,finished_at FROM image_builds ORDER BY created_at DESC,id DESC") + if err != nil { + fail(w, 500, "database error") + return + } + result := []buildRecord{} + for rows.Next() { + var b buildRecord + var finished, recipe, platform sql.NullString + if err = rows.Scan(&b.ID, &b.Version, &b.Status, &b.Logs, &b.Error, &b.ImageID, &b.Created, &b.RequestedBy, &recipe, &platform, &finished); err != nil { + break + } + b.RecipeRev, b.Platform, b.Finished = recipe.String, platform.String, finished.String + result = append(result, b) + } + scanErr := rows.Err() + rows.Close() + if err != nil || scanErr != nil { + fail(w, 500, "database error") + return + } + if err := a.reconcileBuilds(r.Context(), result); err != nil { + fail(w, 502, "docker image reconciliation failed") + return + } + a.startBuildWorker() + writeJSON(w, result) + return + } + b, err := a.enqueueBuildLocked(r.Context(), u.ID, in.Version) + if err != nil { + status := 502 + if errors.Is(err, errBuildRateLimited) { + status = 429 + w.Header().Set("Retry-After", "3600") + } + fail(w, status, sanitizeBuildLog(err.Error())) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + writeJSON(w, b) +} + +var errBuildRateLimited = errors.New("build limit: three new tasks per user per hour") + +func (a *API) enqueueBuild(ctx context.Context, uid, version string) (buildRecord, error) { + buildQueue.Lock() + defer buildQueue.Unlock() + if !validBuildVersion(version) { + return buildRecord{}, errors.New("a fixed version is required") + } + if err := a.ensureBuilds(); err != nil { + return buildRecord{}, err + } + return a.enqueueBuildLocked(ctx, uid, version) +} + +func (a *API) enqueueBuildLocked(ctx context.Context, uid, version string) (buildRecord, error) { + var b buildRecord + var finished, recipe, platform sql.NullString + err := a.s.DB.QueryRow("SELECT id,version,status,logs,error,image_id,created_at,requested_by,recipe_rev,platform,finished_at FROM image_builds WHERE version=? AND recipe_rev=? AND platform=? AND status IN ('queued','running','validating','success')", version, imageRecipeRev, imagePlatform).Scan(&b.ID, &b.Version, &b.Status, &b.Logs, &b.Error, &b.ImageID, &b.Created, &b.RequestedBy, &recipe, &platform, &finished) + b.RecipeRev, b.Platform, b.Finished = recipe.String, platform.String, finished.String + if err == nil && b.Status == "success" { + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + id, lookupErr := a.localBuildImage(ctx, b.Version, b.ImageID) + cancel() + if lookupErr != nil { + return b, lookupErr + } + if id != b.ImageID || id == "" { + if _, err = a.s.DB.Exec("UPDATE image_builds SET status='missing',error='image no longer available' WHERE id=?", b.ID); err == nil { + err = sql.ErrNoRows + } + } + } + if errors.Is(err, sql.ErrNoRows) { + var count int + if err := a.s.DB.QueryRow("SELECT count(*) FROM image_build_requests WHERE user_id=? AND created_at>?", uid, time.Now().Add(-time.Hour).UTC().Format(time.RFC3339Nano)).Scan(&count); err != nil { + return b, err + } + if count >= 3 { + return b, errBuildRateLimited + } + b = buildRecord{ID: store.ID(), Version: version, Status: "queued", Created: time.Now().UTC().Format(time.RFC3339Nano), RequestedBy: uid, RecipeRev: imageRecipeRev, Platform: imagePlatform} + err = a.insertBuildRequest(b, uid) + } + if err != nil { + return b, err + } + a.startBuildWorker() + return b, nil +} +func (a *API) startBuildWorker() { + if buildQueue.active[a.s.DB] { + return + } + buildQueue.active[a.s.DB] = true + go func() { + buildWorker.Lock() + defer buildWorker.Unlock() + for { + buildQueue.Lock() + var b buildRecord + err := a.s.DB.QueryRow("SELECT id,version FROM image_builds WHERE status='queued' ORDER BY created_at,id LIMIT 1").Scan(&b.ID, &b.Version) + if err == nil { + _, err = a.s.DB.Exec("UPDATE image_builds SET status='running' WHERE id=?", b.ID) + } + if err != nil { + delete(buildQueue.active, a.s.DB) + buildQueue.Unlock() + return + } + buildQueue.Unlock() + a.runImageBuild(b) + } + }() +} +func imageBuildContext() (io.Reader, error) { + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + for _, name := range []string{"Dockerfile", "Caddyfile", "entrypoint.sh", "install-mobile.mjs", "install-free-search.mjs", "install-workspace.mjs", "init-profile.mjs"} { + dataBytes, err := imageassets.Files.ReadFile(name) + if err != nil { + return nil, err + } + data := string(dataBytes) + if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0755, Size: int64(len(data))}); err != nil { + return nil, err + } + if _, err := tw.Write([]byte(data)); err != nil { + return nil, err + } + } + if err := tw.Close(); err != nil { + return nil, err + } + return &buf, nil +} +func (a *API) runImageBuild(b buildRecord) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + logs := "" + rawLogs := "" + var logErr error + appendLog := func(s string) { + if len(rawLogs) < buildLogLimit { + remaining := buildLogLimit - len(rawLogs) + if len(s) > remaining { + s = s[:remaining] + } + rawLogs += s + } + logs = sanitizeBuildLog(rawLogs) + if logErr == nil { + _, logErr = a.s.DB.Exec("UPDATE image_builds SET logs=? WHERE id=?", logs, b.ID) + } + } + tarball, err := imageBuildContext() + var id string + if err == nil { + id, err = a.localBuildImage(ctx, b.Version, "") + } + if err == nil && id == "" { + _, err = fetchNPMVersion(ctx, npmBuildClient, b.Version) + if err == nil { + err = a.checkBuildDisk(ctx) + } + } + if err == nil && id == "" { + id, err = a.docker.Build(ctx, tarball, b.Version, "dsh-web:"+b.Version+"-"+imageRecipeRev, appendLog) + } + if err == nil { + var matches bool + matches, err = a.imageMatches(ctx, id, b.Version) + if err == nil && !matches { + err = errors.New("image template or platform mismatch") + } + } + if err == nil { + _, err = a.s.DB.Exec("UPDATE image_builds SET status='validating',image_id=? WHERE id=?", id, b.ID) + } + if err == nil { + err = a.verifyBuiltImage(ctx, id, b.Version) + } + if err == nil { + err = logErr + } + status, detail := "success", "" + if err != nil { + status = "failed" + detail = sanitizeBuildLog(err.Error()) + appendLog(string(rune(10)) + detail) + } + finished := time.Now().UTC().Format(time.RFC3339Nano) + _, _ = a.s.DB.Exec("UPDATE image_builds SET status=?,logs=?,error=?,image_id=?,finished_at=? WHERE id=?", status, logs, detail, id, finished, b.ID) + if status == "success" && id != "" { + _, _ = a.s.DB.Exec("INSERT INTO images(image_id,version,recipe_rev,platform,tag,status,built_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(image_id) DO UPDATE SET version=excluded.version,recipe_rev=excluded.recipe_rev,platform=excluded.platform,tag=excluded.tag,status=excluded.status,built_at=excluded.built_at", id, b.Version, imageRecipeRev, imagePlatform, "dsh-web:"+b.Version+"-"+imageRecipeRev, "available", finished) + } +} +func (a *API) verifyBuiltImage(parent context.Context, image, version string) (result error) { + ctx, cancel := context.WithTimeout(parent, 90*time.Second) + defer cancel() + var created struct { + ID string `json:"Id"` + } + config := map[string]any{"Image": image, "User": "1000:1000", "Labels": map[string]string{"dsh-mgr.role": "build-check"}, "HostConfig": map[string]any{"NetworkMode": "none", "ReadonlyRootfs": true, "CapDrop": []string{"ALL"}, "SecurityOpt": []string{"no-new-privileges:true"}, "Memory": int64(1 << 30), "NanoCpus": int64(1e9), "PidsLimit": 256, "Tmpfs": map[string]string{"/tmp": "rw,exec,nosuid,nodev,size=256m,mode=1777", "/workspace": "rw,nosuid,nodev,size=256m,uid=1000,gid=1000"}}} + if err := a.docker.Do(ctx, http.MethodPost, "/containers/create", config, &created); err != nil { + return err + } + if created.ID == "" { + return errors.New("verification container ID missing") + } + path := "/containers/" + url.PathEscape(created.ID) + defer func() { + cleanup, c := context.WithTimeout(context.Background(), 10*time.Second) + defer c() + err := a.docker.Do(cleanup, http.MethodDelete, path+"?force=1&v=1", nil, nil) + result = errors.Join(result, err) + }() + if err := a.docker.Do(ctx, http.MethodPost, path+"/start", nil, nil); err != nil { + return err + } + script := `const end=Date.now()+60000;async function probe(){try{const r=await fetch('http://127.0.0.1:8080/',{redirect:'manual',signal:AbortSignal.timeout(2000)});if(r.status>=200&&r.status<500)process.exit(0);}catch{}if(Date.now()>end)process.exit(1);setTimeout(probe,500);}probe();` + { + expected, _ := json.Marshal(version) + script = `const cp=require('node:child_process');const actual=cp.execFileSync('dsh',['--version'],{encoding:'utf8',timeout:20000}).trim();if(actual!==` + string(expected) + `)process.exit(2);` + script + } + script = `const fs=require('node:fs');for(const [k,v] of Object.entries({DSH_HOME:'/workspace',HOME:'/workspace',XDG_DATA_HOME:'/workspace/.local/share',XDG_CONFIG_HOME:'/workspace/.config'})){if(process.env[k]!==v)process.exit(3);}if(!fs.lstatSync('/data/dsh-home').isSymbolicLink()||fs.realpathSync('/data/dsh-home')!=='/workspace')process.exit(4);fs.writeFileSync('/workspace/.home-check','isolated');if(fs.readFileSync('/data/dsh-home/.home-check','utf8')!=='isolated')process.exit(5);fs.unlinkSync('/workspace/.home-check');` + script + var exec struct { + ID string `json:"Id"` + } + if err := a.docker.Do(ctx, http.MethodPost, path+"/exec", map[string]any{"Cmd": []string{"node", "-e", script}, "User": "1000:1000"}, &exec); err != nil { + return err + } + if exec.ID == "" { + return errors.New("verification exec ID missing") + } + ep := "/exec/" + url.PathEscape(exec.ID) + if err := a.docker.Do(ctx, http.MethodPost, ep+"/start", map[string]bool{"Detach": true, "Tty": false}, nil); err != nil { + return err + } + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + for { + var state struct { + Running bool + ExitCode int + } + if err := a.docker.Do(ctx, http.MethodGet, ep+"/json", nil, &state); err != nil { + return err + } + if !state.Running { + if state.ExitCode != 0 { + return fmt.Errorf("image startup verification failed (exit %d)", state.ExitCode) + } + container, err := a.docker.Inspect(ctx, created.ID) + if err != nil { + return err + } + if !container.State.Running { + return errors.New("verification container exited") + } + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +const buildLogLimit = 64 << 10 + +var fixedVersion = regexp.MustCompile(`^(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)(-[0-9A-Za-z-]+([.][0-9A-Za-z-]+)*)?$`) +var buildSecrets = regexp.MustCompile(`(?i)(authorization["']?[[:space:]]*[:=][[:space:]]*["']?(?:bearer[[:space:]]+|basic[[:space:]]+)?|(?:token|password|passwd|secret|api[_-]?key|cookie)["']?[[:space:]]*[:=][[:space:]]*["']?)[^[:space:]"'&,;]+`) +var buildURLCredentials = regexp.MustCompile(`(https?://)[^/@[:space:]]+:[^/@[:space:]]+@`) +var buildQueue = struct { + sync.Mutex + initialized map[*sql.DB]bool + active map[*sql.DB]bool +}{initialized: map[*sql.DB]bool{}, active: map[*sql.DB]bool{}} +var buildWorker sync.Mutex + +type imageRecord struct { + ID string `json:"id"` + ImageID string `json:"image_id"` + Version string `json:"version"` + Tag string `json:"tag,omitempty"` + Status string `json:"status"` + Created string `json:"created_at"` + RecipeRev string `json:"recipe_rev,omitempty"` + Platform string `json:"platform,omitempty"` + UsageCount int `json:"usage_count"` +} +type buildRecord struct { + ImageID string `json:"image_id,omitempty"` + ID string `json:"id"` + Version string `json:"version"` + Status string `json:"status"` + Logs string `json:"logs"` + Error string `json:"error,omitempty"` + Created string `json:"created_at"` + RequestedBy string `json:"requested_by,omitempty"` + RecipeRev string `json:"recipe_rev,omitempty"` + Platform string `json:"platform,omitempty"` + Finished string `json:"finished_at,omitempty"` +} + +func (a *API) images(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + a.deleteImage(w, r) + return + } + if _, _, err := a.user(r); err != nil { + fail(w, 401, "authentication required") + return + } + if r.Method != http.MethodGet { + fail(w, 405, "method not allowed") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + result, err := a.imageIndex(ctx) + if err != nil { + fail(w, 502, "image index reconciliation failed") + return + } + writeJSON(w, result) +} + +func (a *API) verifiedImages(ctx context.Context) (map[string]string, error) { + buildQueue.Lock() + defer buildQueue.Unlock() + if err := a.ensureBuilds(); err != nil { + return nil, err + } + rows, err := a.s.DB.QueryContext(ctx, "SELECT image_id,version FROM image_builds WHERE status='success' AND image_id<>'' AND recipe_rev=? AND platform=?", imageRecipeRev, imagePlatform) + if err != nil { + return nil, err + } + defer rows.Close() + images := make(map[string]string) + for rows.Next() { + var id, version string + if err := rows.Scan(&id, &version); err != nil { + return nil, err + } + images[id] = version + } + return images, rows.Err() +} +func (a *API) latest(w http.ResponseWriter, r *http.Request) { + if _, _, err := a.user(r); err != nil { + fail(w, 401, "authentication required") + return + } + if r.Method != http.MethodGet { + fail(w, 405, "method not allowed") + return + } + v, err := fetchLatest(r.Context(), npmBuildClient) + if err != nil { + fail(w, 502, "npm version lookup failed") + return + } + writeJSON(w, map[string]string{"version": v, "checked_at": time.Now().UTC().Format(time.RFC3339)}) +} +func fetchLatest(ctx context.Context, client *http.Client) (string, error) { + return fetchNPMVersion(ctx, client, "latest") +} +func fetchNPMVersion(ctx context.Context, client *http.Client, version string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://registry.npmjs.org/@deepseek-ai%2Fdsh/"+url.PathEscape(version), nil) + if err != nil { + return "", err + } + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return "", fmt.Errorf("npm status %d", resp.StatusCode) + } + var v struct{ Version string } + if err = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&v); err != nil { + return "", err + } + if !validBuildVersion(v.Version) || (version != "latest" && v.Version != version) { + return "", errors.New("invalid npm version") + } + return v.Version, nil +} +func validBuildVersion(v string) bool { return len(v) <= 100 && fixedVersion.MatchString(v) } +func sanitizeBuildLog(s string) string { + s = buildURLCredentials.ReplaceAllString(s, "$1[REDACTED]@") + s = buildSecrets.ReplaceAllString(s, "$1[REDACTED]") + s = strings.ToValidUTF8(s, "") + if len(s) > buildLogLimit { + s = strings.ToValidUTF8(s[:buildLogLimit], "") + } + return s +} + +func (a *API) localBuildImage(ctx context.Context, version, preferred string) (string, error) { + filters, _ := json.Marshal(map[string][]string{"label": {"dsh-mgr.role=runtime", "dsh-mgr.version=" + version}}) + var images []struct { + ID string + Labels map[string]string + } + if err := a.docker.Do(ctx, http.MethodGet, "/images/json?filters="+url.QueryEscape(string(filters)), nil, &images); err != nil { + return "", err + } + for _, im := range images { + if im.ID != "" && im.Labels["dsh-mgr.role"] == "runtime" && im.Labels["dsh-mgr.version"] == version && im.Labels["dsh-mgr.recipe"] == imageRecipeRev && (preferred == "" || im.ID == preferred) { + ok, err := a.imageMatches(ctx, im.ID, version) + if err != nil { + return "", err + } + if ok { + return im.ID, nil + } + } + } + return "", nil +} diff --git a/internal/api/images_delete.go b/internal/api/images_delete.go new file mode 100644 index 0000000..ea803ac --- /dev/null +++ b/internal/api/images_delete.go @@ -0,0 +1,140 @@ +package api + +import ( + "context" + "database/sql" + "errors" + "net/http" + "net/url" + "time" + + "git.misaka.ren/m1saka/dsh-web/internal/docker" +) + +func (a *API) deleteImage(w http.ResponseWriter, r *http.Request) { + u, _, err := a.user(r) + if err != nil { + fail(w, 401, "authentication required") + return + } + if !u.Admin { + fail(w, 403, "administrator required") + return + } + id := r.URL.Query().Get("image_id") + if !runtimeImageID.MatchString(id) { + fail(w, 400, "invalid image_id") + return + } + // Upgrades also acquire a.mu before buildQueue. Keep both through deletion. + a.mu.Lock() + defer a.mu.Unlock() + buildQueue.Lock() + defer buildQueue.Unlock() + if buildQueue.active[a.s.DB] { + fail(w, 409, "image build worker is active") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + tx, err := a.s.DB.BeginTx(ctx, nil) + if err != nil { + fail(w, 500, "database error") + return + } + defer tx.Rollback() + // Take SQLite's writer lock before checking intents: reservation does not use a.mu. + if _, err = tx.ExecContext(ctx, "UPDATE images SET status=status WHERE image_id=?", id); err != nil { + fail(w, 500, "image index unavailable") + return + } + var version string + err = tx.QueryRowContext(ctx, "SELECT version FROM images WHERE image_id=?", id).Scan(&version) + if errors.Is(err, sql.ErrNoRows) { + fail(w, 404, "managed image not found") + return + } + if err != nil { + fail(w, 500, "image index unavailable") + return + } + for _, check := range []struct{ table, query string }{ + {"image_builds", "SELECT count(*) FROM image_builds WHERE status IN ('queued','running','validating')"}, + {"creation_intents", "SELECT count(*) FROM creation_intents WHERE status IN ('waiting','creating')"}, + {"upgrade_journal", "SELECT count(*) FROM upgrade_journal WHERE phase NOT IN ('complete','rolled_back')"}, + } { + var exists, count int + if err = tx.QueryRowContext(ctx, "SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?", check.table).Scan(&exists); err == nil && exists > 0 { + err = tx.QueryRowContext(ctx, check.query).Scan(&count) + } + if err != nil { + fail(w, 500, "active task lookup failed") + return + } + if count > 0 { + fail(w, 409, "active task prevents image deletion") + return + } + } + var references int + if err = tx.QueryRowContext(ctx, "SELECT count(*) FROM instances WHERE image_id=?", id).Scan(&references); err != nil { + fail(w, 500, "instance reference lookup failed") + return + } + if references > 0 { + fail(w, 409, "image is referenced by an instance") + return + } + var image struct { + ID string `json:"Id"` + Config struct{ Labels map[string]string } + } + path := "/images/" + url.PathEscape(id) + if err = a.docker.Do(ctx, http.MethodGet, path+"/json", nil, &image); err != nil { + fail(w, 502, "image inspection failed") + return + } + if image.ID != id || image.Config.Labels["dsh-mgr.role"] != "runtime" || version == "" || image.Config.Labels["dsh-mgr.version"] != version { + fail(w, 409, "managed runtime image required") + return + } + var containers []struct{ ImageID string } + if err = a.docker.Do(ctx, http.MethodGet, "/containers/json?all=1", nil, &containers); err != nil || containers == nil { + fail(w, 502, "container reference lookup failed") + return + } + for _, container := range containers { + if container.ImageID == "" { + fail(w, 502, "container image identity unavailable") + return + } + if container.ImageID == id { + fail(w, 409, "image is referenced by a container") + return + } + } + if _, err = tx.ExecContext(ctx, "UPDATE images SET status='missing',usage_count=0 WHERE image_id=?", id); err == nil { + _, err = tx.ExecContext(ctx, "UPDATE image_builds SET status='missing',error='image deleted by administrator' WHERE image_id=? AND status='success'", id) + } + if err == nil { + _, err = tx.ExecContext(ctx, "INSERT INTO audit(uid,action,target,detail,at) VALUES(?,'image.delete',?,'',?)", u.ID, id, time.Now().Unix()) + } + if err != nil { + fail(w, 500, "image deletion recording failed") + return + } + if err = a.docker.Do(ctx, http.MethodDelete, path+"?force=false", nil, nil); err != nil { + status := http.StatusBadGateway + var apiErr *docker.APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusConflict { + status = http.StatusConflict + } + fail(w, status, "docker image deletion failed") + return + } + if err = tx.Commit(); err != nil { + fail(w, 500, "image deleted but recording failed") + return + } + writeJSON(w, map[string]string{"image_id": id, "status": "missing"}) +} diff --git a/internal/api/images_delete_test.go b/internal/api/images_delete_test.go new file mode 100644 index 0000000..a929efd --- /dev/null +++ b/internal/api/images_delete_test.go @@ -0,0 +1,173 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +func TestImageDelete(t *testing.T) { + id := "sha256:" + strings.Repeat("a", 64) + for _, mode := range []string{"success", "anonymous", "non-admin", "invalid", "unmanaged", "role", "instance", "container-running", "container-exited", "containers-failure", "containers-null", "containers-unknown", "inspect-failure", "delete-failure", "delete-conflict", "queued", "running", "validating", "worker", "waiting", "creating", "journal", "complete", "rolled_back", "db-failure", "task-query-failure", "audit-failure"} { + t.Run(mode, func(t *testing.T) { + var deletes atomic.Int32 + a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodDelete: + deletes.Add(1) + if r.URL.Path != "/v1.47/images/"+id || r.URL.Query().Get("force") != "false" { + t.Errorf("unsafe delete: %s", r.URL) + } + if mode == "delete-failure" { + w.WriteHeader(500) + return + } + if mode == "delete-conflict" { + w.WriteHeader(409) + return + } + writeJSON(w, []any{}) + case r.URL.Path == "/v1.47/images/"+id+"/json": + if mode == "inspect-failure" { + w.WriteHeader(500) + return + } + if mode == "role" { + writeJSON(w, map[string]any{"Id": id}) + return + } + writeImageInspect(w, id, "1.2.3") + case r.URL.Path == "/v1.47/containers/json": + if r.URL.RawQuery != "all=1" { + t.Errorf("must inspect all containers: %s", r.URL) + } + if mode == "containers-failure" { + w.WriteHeader(500) + return + } + if mode == "containers-null" { + writeJSON(w, nil) + return + } + if mode == "containers-unknown" { + writeJSON(w, []any{map[string]string{"Image": "tag"}}) + return + } + if strings.HasPrefix(mode, "container-") { + writeJSON(w, []any{map[string]string{"ImageID": id, "State": strings.TrimPrefix(mode, "container-")}}) + return + } + writeJSON(w, []any{}) + default: + t.Errorf("unexpected Docker request: %s %s", r.Method, r.URL) + w.WriteHeader(500) + } + }) + exec := func(q string, args ...any) { + t.Helper() + if _, err := a.s.DB.Exec(q, args...); err != nil { + t.Fatal(err) + } + } + seedVerifiedImage(t, a, id, "1.2.3") + exec("INSERT INTO images(image_id,version,status,built_at) VALUES(?,'1.2.3','available','now')", id) + if mode != "non-admin" { + exec("UPDATE users SET admin=1 WHERE id='u'") + } + want := 409 + switch mode { + case "success", "complete", "rolled_back": + want = 200 + case "anonymous": + sid = "" + want = 401 + case "non-admin": + want = 403 + case "invalid": + want = 400 + case "unmanaged": + exec("DELETE FROM images") + want = 404 + case "inspect-failure", "containers-failure", "containers-null", "containers-unknown", "delete-failure": + want = 502 + case "instance": + exec("INSERT INTO instances(id,uid,name,image_id,created) VALUES('i','other','i',?,0)", id) + case "queued", "running", "validating": + exec("INSERT INTO image_builds(id,version,status,created_at) VALUES('active','2.0.0',?,'now')", mode) + case "worker": + buildQueue.Lock() + buildQueue.active[a.s.DB] = true + buildQueue.Unlock() + defer func() { buildQueue.Lock(); delete(buildQueue.active, a.s.DB); buildQueue.Unlock() }() + case "waiting", "creating": + if err := a.s.EnsureIntents(); err != nil { + t.Fatal(err) + } + exec("INSERT INTO creation_intents(id,uid,name,version,status,host,created) VALUES('intent','other','i','2.0.0',?,'host',0)", mode) + case "db-failure": + exec("ALTER TABLE instances RENAME COLUMN image_id TO broken") + want = 500 + case "task-query-failure": + exec("CREATE TABLE upgrade_journal(broken TEXT)") + want = 500 + case "audit-failure": + exec("CREATE TRIGGER deny_audit BEFORE INSERT ON audit BEGIN SELECT RAISE(FAIL,'unavailable'); END") + want = 500 + } + if mode == "journal" || mode == "complete" || mode == "rolled_back" { + exec("CREATE TABLE upgrade_journal(phase TEXT)") + exec("INSERT INTO upgrade_journal VALUES(?)", mode) + } + queryID := id + if mode == "invalid" { + queryID = "runtime:latest" + } + r := httptest.NewRequest(http.MethodDelete, "/api/images?image_id="+queryID, nil) + if sid != "" { + r.AddCookie(&http.Cookie{Name: "dsh_session", Value: sid}) + addTestAccess(r, sid) + } + w := httptest.NewRecorder() + a.images(w, r) + if w.Code != want { + t.Fatalf("status=%d want=%d body=%s", w.Code, want, w.Body.String()) + } + wantDeletes := int32(0) + if want == 200 || mode == "delete-failure" || mode == "delete-conflict" { + wantDeletes = 1 + } + if deletes.Load() != wantDeletes { + t.Fatalf("deletes=%d want=%d", deletes.Load(), wantDeletes) + } + if mode != "unmanaged" { + var status string + if err := a.s.DB.QueryRow("SELECT status FROM images WHERE image_id=?", id).Scan(&status); err != nil { + t.Fatal(err) + } + wantStatus := "available" + if want == 200 { + wantStatus = "missing" + } + if status != wantStatus { + t.Fatalf("image status=%s want=%s", status, wantStatus) + } + } + var audits int + if err := a.s.DB.QueryRow("SELECT count(*) FROM audit WHERE action='image.delete' AND uid='u' AND target=?", id).Scan(&audits); err != nil { + t.Fatal(err) + } + if (audits == 1) != (want == 200) { + t.Fatalf("audits=%d", audits) + } + var status string + if err := a.s.DB.QueryRow("SELECT status FROM image_builds WHERE image_id=?", id).Scan(&status); err != nil { + t.Fatal(err) + } + if (status == "missing") != (want == 200) { + t.Fatalf("build status=%s", status) + } + }) + } +} diff --git a/internal/api/images_policy.go b/internal/api/images_policy.go new file mode 100644 index 0000000..33754ce --- /dev/null +++ b/internal/api/images_policy.go @@ -0,0 +1,252 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "git.misaka.ren/m1saka/dsh-web/internal/docker" + "net/http" + "net/url" + "os" + "path/filepath" + "runtime" + "syscall" + "time" +) + +var npmBuildClient = &http.Client{Timeout: 15 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + +const imageRecipeRev = "7" + +var imagePlatform = runtime.GOOS + "/" + runtime.GOARCH + +const minimumBuildDiskBytes = uint64(2 << 30) + +var buildDiskAvailable = func(path string) (uint64, error) { + var stat syscall.Statfs_t + if err := syscall.Statfs(path, &stat); err != nil { + return 0, err + } + return uint64(stat.Bavail) * uint64(stat.Bsize), nil +} + +type inspectedImage struct { + ID string `json:"Id"` + Os, Architecture, Variant, Created string + RepoTags []string + Config struct{ Labels map[string]string } +} + +func (im inspectedImage) platform() string { + if im.Os == "" || im.Architecture == "" { + return "" + } + p := im.Os + "/" + im.Architecture + if im.Variant != "" { + p += "/" + im.Variant + } + return p +} + +func (a *API) inspectLocalImage(ctx context.Context, id string) (*inspectedImage, error) { + if id == "" { + return nil, nil + } + var im inspectedImage + if err := a.docker.Do(ctx, http.MethodGet, "/images/"+url.PathEscape(id)+"/json", nil, &im); err != nil { + var apiErr *docker.APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound { + return nil, nil + } + return nil, err + } + return &im, nil +} + +func (a *API) imageMatches(ctx context.Context, id, version string) (bool, error) { + im, err := a.inspectLocalImage(ctx, id) + if err != nil || im == nil { + return false, err + } + return im.ID == id && im.platform() == imagePlatform && im.Config.Labels["dsh-mgr.role"] == "runtime" && im.Config.Labels["dsh-mgr.version"] == version && im.Config.Labels["dsh-mgr.recipe"] == imageRecipeRev, nil +} + +func (a *API) imageIndex(ctx context.Context) ([]imageRecord, error) { + buildQueue.Lock() + defer buildQueue.Unlock() + if err := a.ensureBuilds(); err != nil { + return nil, err + } + if _, err := a.s.DB.ExecContext(ctx, `INSERT OR IGNORE INTO images(image_id,version,recipe_rev,platform,tag,status,built_at) + SELECT image_id,version,recipe_rev,platform,'','missing',COALESCE(finished_at,created_at) FROM image_builds WHERE status IN ('success','missing') AND image_id<>'' ORDER BY created_at DESC,id DESC`); err != nil { + return nil, err + } + rows, err := a.s.DB.QueryContext(ctx, `SELECT image_id,version,COALESCE(recipe_rev,''),COALESCE(platform,''),tag,status,built_at, + (SELECT count(*) FROM instances WHERE instances.image_id=images.image_id) FROM images ORDER BY built_at DESC,image_id`) + if err != nil { + return nil, err + } + result := []imageRecord{} + for rows.Next() { + var im imageRecord + if err = rows.Scan(&im.ImageID, &im.Version, &im.RecipeRev, &im.Platform, &im.Tag, &im.Status, &im.Created, &im.UsageCount); err != nil { + break + } + im.ID = im.ImageID + result = append(result, im) + } + scanErr := rows.Err() + rows.Close() + if err != nil { + return nil, err + } + if scanErr != nil { + return nil, scanErr + } + filters, _ := json.Marshal(map[string][]string{"label": {"dsh-mgr.role=runtime"}}) + var local []struct { + ID string + Labels map[string]string + } + if err := a.docker.Do(ctx, http.MethodGet, "/images/json?filters="+url.QueryEscape(string(filters)), nil, &local); err != nil { + return nil, err + } + known := make(map[string]bool, len(result)) + for _, im := range result { + known[im.ImageID] = true + } + for _, im := range local { + if im.ID != "" && !known[im.ID] && im.Labels["dsh-mgr.role"] == "runtime" { + known[im.ID] = true + result = append(result, imageRecord{ID: im.ID, ImageID: im.ID}) + } + } + for i := range result { + im := &result[i] + im.Status = "missing" + actual, err := a.inspectLocalImage(ctx, im.ImageID) + if err != nil { + return nil, err + } + if actual == nil { + continue + } + im.Status = "unverified" + im.Platform = actual.platform() + im.Version = actual.Config.Labels["dsh-mgr.version"] + im.RecipeRev = actual.Config.Labels["dsh-mgr.recipe"] + im.Tag = "" + if len(actual.RepoTags) > 0 { + im.Tag = actual.RepoTags[0] + } + if actual.Created != "" { + im.Created = actual.Created + } + if err := a.s.DB.QueryRowContext(ctx, "SELECT count(*) FROM instances WHERE image_id=?", im.ImageID).Scan(&im.UsageCount); err != nil { + return nil, err + } + var verified int + if err := a.s.DB.QueryRowContext(ctx, "SELECT count(*) FROM image_builds WHERE image_id=? AND version=? AND recipe_rev=? AND platform=? AND status='success'", im.ImageID, im.Version, imageRecipeRev, imagePlatform).Scan(&verified); err != nil { + return nil, err + } + if verified > 0 && actual.ID == im.ImageID && actual.Config.Labels["dsh-mgr.role"] == "runtime" && im.RecipeRev == imageRecipeRev && im.Platform == imagePlatform { + im.Status = "available" + } + } + tx, err := a.s.DB.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer tx.Rollback() + for _, im := range result { + if _, err = tx.ExecContext(ctx, `INSERT INTO images(image_id,version,recipe_rev,platform,tag,status,built_at,usage_count) VALUES(?,?,?,?,?,?,?,?) + ON CONFLICT(image_id) DO UPDATE SET version=excluded.version,recipe_rev=excluded.recipe_rev,platform=excluded.platform,tag=excluded.tag,status=excluded.status,built_at=excluded.built_at,usage_count=excluded.usage_count`, im.ImageID, im.Version, im.RecipeRev, im.Platform, im.Tag, im.Status, im.Created, im.UsageCount); err != nil { + return nil, err + } + if im.Status == "missing" { + if _, err = tx.ExecContext(ctx, "UPDATE image_builds SET status='missing',error='image no longer available or template identity is unknown' WHERE image_id=? AND status='success'", im.ImageID); err != nil { + return nil, err + } + } + } + return result, tx.Commit() +} + +func (a *API) StartImageBuilds() error { + buildQueue.Lock() + defer buildQueue.Unlock() + if err := a.ensureBuilds(); err != nil { + return err + } + a.startBuildWorker() + return nil +} + +func (a *API) checkBuildDisk(ctx context.Context) error { + var info struct{ DockerRootDir string } + if err := a.docker.Do(ctx, http.MethodGet, "/info", nil, &info); err != nil { + return fmt.Errorf("build disk check: %w", err) + } + // A matching pathname alone cannot establish daemon filesystem identity. + confirmed := os.Getenv("DSH_DOCKER_ROOT_VISIBLE") + if !filepath.IsAbs(info.DockerRootDir) || confirmed != info.DockerRootDir { + return errors.New("Docker disk capacity unknown: mount the daemon DockerRootDir at the identical path and set DSH_DOCKER_ROOT_VISIBLE to that path; new builds are blocked") + } + available, err := buildDiskAvailable(confirmed) + if err != nil { + return fmt.Errorf("Docker disk capacity unavailable: %w", err) + } + return requireBuildDisk(available) +} + +func requireBuildDisk(available uint64) error { + if available < minimumBuildDiskBytes { + return fmt.Errorf("insufficient Docker disk space: %d available bytes, require %d", available, minimumBuildDiskBytes) + } + return nil +} + +func (a *API) insertBuildRequest(b buildRecord, user string) error { + tx, err := a.s.DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err = tx.Exec("INSERT INTO image_builds(id,version,status,created_at,requested_by,recipe_rev,platform) VALUES(?,?,?,?,?,?,?)", b.ID, b.Version, b.Status, b.Created, b.RequestedBy, b.RecipeRev, b.Platform); err != nil { + return err + } + if _, err = tx.Exec("INSERT INTO image_build_requests(user_id,build_id,created_at) VALUES(?,?,?)", user, b.ID, b.Created); err != nil { + return err + } + return tx.Commit() +} + +func (a *API) reconcileBuilds(parent context.Context, records []buildRecord) error { + ctx, cancel := context.WithTimeout(parent, 15*time.Second) + defer cancel() + for i := range records { + b := &records[i] + if b.Status != "success" { + continue + } + if b.RecipeRev != imageRecipeRev || b.Platform != imagePlatform { + b.Status, b.Error = "missing", "image template identity is unknown" + if _, err := a.s.DB.Exec("UPDATE image_builds SET status=?,error=? WHERE id=? AND status='success'", b.Status, b.Error, b.ID); err != nil { + return err + } + continue + } + id, err := a.localBuildImage(ctx, b.Version, b.ImageID) + if err != nil { + return err + } + if id == "" || id != b.ImageID { + b.Status, b.Error = "missing", "image no longer available" + if _, err := a.s.DB.Exec("UPDATE image_builds SET status=?,error=? WHERE id=? AND status='success'", b.Status, b.Error, b.ID); err != nil { + return err + } + } + } + return nil +} diff --git a/internal/api/images_policy_test.go b/internal/api/images_policy_test.go new file mode 100644 index 0000000..0a43cb6 --- /dev/null +++ b/internal/api/images_policy_test.go @@ -0,0 +1,381 @@ +package api + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestImageLegacyMigrationNeverCertifies(t *testing.T) { + a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1.47/images/json" { + io.WriteString(w, "[]") + return + } + writeImageInspect(w, "legacy-image", "1.2.3") + }) + _, err := a.s.DB.Exec(`CREATE TABLE image_builds(id TEXT PRIMARY KEY,version TEXT NOT NULL,status TEXT NOT NULL,logs TEXT NOT NULL DEFAULT '',error TEXT NOT NULL DEFAULT '',image_id TEXT NOT NULL DEFAULT '',created_at TEXT NOT NULL); + CREATE UNIQUE INDEX image_builds_version ON image_builds(version) WHERE status IN ('queued','running','validating','success'); + INSERT INTO image_builds(id,version,status,image_id,created_at) VALUES('legacy','1.2.3','success','legacy-image','now'),('queued','1.2.4','queued','','now'); + CREATE TABLE images(image_id TEXT PRIMARY KEY,version TEXT NOT NULL,tag TEXT NOT NULL DEFAULT '',status TEXT NOT NULL,built_at TEXT NOT NULL); + INSERT INTO images VALUES('legacy-image','1.2.3','','available','now');`) + if err != nil { + t.Fatal(err) + } + verified, err := a.verifiedImages(context.Background()) + if err != nil || len(verified) != 0 { + t.Fatalf("%v %v", verified, err) + } + var recipe, platform sql.NullString + if err = a.s.DB.QueryRow("SELECT recipe_rev,platform FROM image_builds WHERE id='legacy'").Scan(&recipe, &platform); err != nil || recipe.Valid || platform.Valid { + t.Fatalf("legacy metadata inferred: %v %v %v", recipe, platform, err) + } + w := imageRequest(a.images, sid, "GET", "") + var images []imageRecord + if err = json.Unmarshal(w.Body.Bytes(), &images); err != nil || len(images) != 1 || images[0].Status != "unverified" || images[0].RecipeRev != imageRecipeRev || images[0].Platform != imagePlatform { + t.Fatal(w.Body.String(), err) + } + if err = a.s.DB.QueryRow("SELECT recipe_rev,platform FROM image_builds WHERE id='legacy'").Scan(&recipe, &platform); err != nil || recipe.Valid || platform.Valid { + t.Fatalf("GET certified legacy metadata: %v %v %v", recipe, platform, err) + } + var status string + if err = a.s.DB.QueryRow("SELECT status FROM image_builds WHERE id='queued'").Scan(&status); err != nil || status != "failed" { + t.Fatal(status, err) + } + seedVerifiedImage(t, a, "new-image", "1.2.3") + verified, err = a.verifiedImages(context.Background()) + if err != nil || len(verified) != 1 || verified["new-image"] != "1.2.3" { + t.Fatal(verified, err) + } +} + +func TestImageInspectIdentity(t *testing.T) { + for _, mode := range []string{"valid", "recipe", "wrong-key", "platform", "variant", "unknown-platform", "role", "version", "id", "missing", "unavailable"} { + t.Run(mode, func(t *testing.T) { + a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Fatal("unexpected mutation") + } + if mode == "missing" { + w.WriteHeader(404) + return + } + if mode == "unavailable" { + w.WriteHeader(500) + return + } + parts := strings.Split(imagePlatform, "/") + labels := map[string]string{"dsh-mgr.role": "runtime", "dsh-mgr.version": "1.2.3", "dsh-mgr.recipe": imageRecipeRev} + im := map[string]any{"Id": "image", "Os": parts[0], "Architecture": parts[1], "Config": map[string]any{"Labels": labels}} + switch mode { + case "recipe": + labels["dsh-mgr.recipe"] = "old" + case "wrong-key": + delete(labels, "dsh-mgr.recipe") + labels["dsh-mgr.recipe_rev"] = imageRecipeRev + case "platform": + im["Architecture"] = "other" + case "variant": + im["Variant"] = "v7" + case "unknown-platform": + delete(im, "Architecture") + case "role": + labels["dsh-mgr.role"] = "other" + case "version": + labels["dsh-mgr.version"] = "2.0.0" + case "id": + im["Id"] = "other" + } + writeJSON(w, im) + }) + ok, err := a.imageMatches(context.Background(), "image", "1.2.3") + if ok != (mode == "valid") || (err != nil) != (mode == "unavailable") { + t.Fatal(ok, err) + } + }) + } +} + +func TestImageIndexLocalInventory(t *testing.T) { + for _, mode := range []string{"external", "old-recipe", "other-platform", "inspect-failure"} { + t.Run(mode, func(t *testing.T) { + var deleted atomic.Bool + a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("unexpected Docker mutation: %s", r.Method) + } + if r.URL.Path == "/v1.47/images/json" { + if deleted.Load() { + io.WriteString(w, "[]") + } else { + io.WriteString(w, `[{"Id":"image","Labels":{"dsh-mgr.role":"runtime","dsh-mgr.recipe":"2","dsh-mgr.version":"9.9.9"}}]`) + } + return + } + if mode == "inspect-failure" { + w.WriteHeader(500) + return + } + if deleted.Load() { + w.WriteHeader(404) + return + } + parts := strings.Split(imagePlatform, "/") + im := inspectedImage{ID: "image", Os: parts[0], Architecture: parts[1], Created: "2026-01-01T00:00:00Z", RepoTags: []string{"dsh-web:1.2.3"}} + im.Config.Labels = map[string]string{"dsh-mgr.role": "runtime", "dsh-mgr.version": "1.2.3", "dsh-mgr.recipe": imageRecipeRev} + if mode == "old-recipe" { + im.Config.Labels["dsh-mgr.recipe"] = "1" + } + if mode == "other-platform" { + im.Architecture, im.Variant = "arm", "v7" + } + writeJSON(w, im) + }) + if mode != "external" { + seedVerifiedImage(t, a, "image", "1.2.3") + } + if mode == "old-recipe" { + if _, err := a.s.DB.Exec("UPDATE image_builds SET recipe_rev='1'"); err != nil { + t.Fatal(err) + } + } + w := imageRequest(a.images, sid, "GET", "") + if mode == "inspect-failure" { + var status string + if err := a.s.DB.QueryRow("SELECT status FROM image_builds WHERE image_id='image'").Scan(&status); err != nil || status != "success" || w.Code != 502 { + t.Fatal(status, w.Code, err) + } + return + } + var images []imageRecord + if err := json.Unmarshal(w.Body.Bytes(), &images); err != nil || w.Code != 200 || len(images) != 1 || images[0].Status != "unverified" || images[0].Version != "1.2.3" || images[0].Created != "2026-01-01T00:00:00Z" || images[0].Tag != "dsh-web:1.2.3" { + t.Fatal(w.Body.String(), err) + } + if mode == "other-platform" && images[0].Platform != strings.Split(imagePlatform, "/")[0]+"/arm/v7" { + t.Fatal(images[0].Platform) + } + if mode == "old-recipe" && images[0].RecipeRev != "1" { + t.Fatal(images[0].RecipeRev) + } + if w := imageRequest(a.instances, sid, "POST", `{"name":"rejected","image_id":"image"}`); w.Code != 400 { + t.Fatal(w.Code, w.Body.String()) + } + deleted.Store(true) + w = imageRequest(a.images, sid, "GET", "") + if err := json.Unmarshal(w.Body.Bytes(), &images); err != nil || w.Code != 200 || len(images) != 1 || images[0].Status != "missing" { + t.Fatal(w.Body.String(), err) + } + }) + } +} + +func TestImageIndexUsageMissingAndDockerFailure(t *testing.T) { + var mode atomic.Int32 + a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Error("unexpected mutation") + } + if r.URL.Path == "/v1.47/images/json" && mode.Load() != 1 { + io.WriteString(w, "[]") + return + } + switch mode.Load() { + case 1: + w.WriteHeader(500) + case 2: + w.WriteHeader(404) + default: + writeImageInspect(w, "image", "1.2.3") + } + }) + seedVerifiedImage(t, a, "image", "1.2.3") + if _, err := a.s.DB.Exec(`INSERT INTO instances(id,uid,name,image_id,status,created) VALUES('one','u','one','image','running',0),('two','other-user','two','image','stopped',0),('three','u','three','other','running',0)`); err != nil { + t.Fatal(err) + } + check := func(status string, count int) { + t.Helper() + w := imageRequest(a.images, sid, "GET", "") + var images []imageRecord + if err := json.Unmarshal(w.Body.Bytes(), &images); err != nil || w.Code != 200 || len(images) != 1 || images[0].Status != status || images[0].UsageCount != count { + t.Fatal(w.Body.String(), err) + } + var storedStatus string + var storedCount int + if err := a.s.DB.QueryRow("SELECT status,usage_count FROM images WHERE image_id='image'").Scan(&storedStatus, &storedCount); err != nil || storedStatus != status || storedCount != count { + t.Fatal(storedStatus, storedCount, err) + } + } + check("available", 2) + mode.Store(1) + if w := imageRequest(a.images, sid, "GET", ""); w.Code != 502 { + t.Fatal(w.Code, w.Body.String()) + } + var status string + if err := a.s.DB.QueryRow("SELECT status FROM image_builds WHERE image_id='image'").Scan(&status); err != nil || status != "success" { + t.Fatal(status, err) + } + mode.Store(2) + check("missing", 2) + if _, err := a.s.DB.Exec("DELETE FROM instances WHERE id='two'"); err != nil { + t.Fatal(err) + } + check("missing", 1) + verified, err := a.verifiedImages(context.Background()) + if err != nil || len(verified) != 0 { + t.Fatal(verified, err) + } +} + +func TestBuildRateLimitDoesNotPenalizeSharedTask(t *testing.T) { + a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1.47/images/verified/json" { + writeImageInspect(w, "verified", "1.2.3") + return + } + io.WriteString(w, `[{"Id":"verified","Labels":{"dsh-mgr.role":"runtime","dsh-mgr.version":"1.2.3","dsh-mgr.recipe":"`+imageRecipeRev+`"}}]`) + }) + seedVerifiedImage(t, a, "verified", "1.2.3") + for _, id := range []string{"a", "b", "c"} { + if _, err := a.s.DB.Exec("INSERT INTO image_build_requests VALUES('u',?,?)", id, time.Now().UTC().Format(time.RFC3339Nano)); err != nil { + t.Fatal(err) + } + } + if w := imageRequest(a.builds, sid, "POST", `{"version":"1.2.3"}`); w.Code != 202 { + t.Fatal(w.Code, w.Body.String()) + } + if w := imageRequest(a.builds, sid, "POST", `{"version":"1.2.4"}`); w.Code != 429 || w.Header().Get("Retry-After") == "" { + t.Fatal(w.Code, w.Body.String()) + } + var n int + a.s.DB.QueryRow("SELECT count(*) FROM image_build_requests").Scan(&n) + if n != 3 { + t.Fatal(n) + } +} + +func TestBuildDiskPolicy(t *testing.T) { + a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { t.Error("unexpected build") }) + if err := a.checkBuildDisk(context.Background()); err != nil { + t.Fatal(err) + } + probeErr := errors.New("capacity probe failed") + for _, tc := range []struct { + name string + available uint64 + err error + want string + }{ + {"insufficient", minimumBuildDiskBytes - 1, nil, "insufficient"}, + {"exact", minimumBuildDiskBytes, nil, ""}, + {"probe-error", 0, probeErr, "unavailable"}, + } { + t.Run(tc.name, func(t *testing.T) { + buildDiskAvailable = func(path string) (uint64, error) { + if path != os.Getenv("DSH_DOCKER_ROOT_VISIBLE") { + t.Fatalf("unexpected probe path %q", path) + } + return tc.available, tc.err + } + err := a.checkBuildDisk(context.Background()) + if tc.want == "" && err != nil || tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)) { + t.Fatal(err) + } + if tc.err != nil && !errors.Is(err, tc.err) { + t.Fatalf("probe error not preserved: %v", err) + } + }) + } + buildDiskAvailable = func(string) (uint64, error) { + t.Fatal("unconfirmed Docker root must not be probed") + return 0, nil + } + t.Setenv("DSH_DOCKER_ROOT_VISIBLE", "") + if err := a.checkBuildDisk(context.Background()); err == nil || !strings.Contains(err.Error(), "unknown") { + t.Fatal(err) + } + if requireBuildDisk(minimumBuildDiskBytes-1) == nil || requireBuildDisk(minimumBuildDiskBytes) != nil { + t.Fatal("disk threshold") + } +} + +func TestUnknownDockerDiskBlocksBuild(t *testing.T) { + a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1.47/images/json" { + t.Errorf("unexpected Docker operation %s", r.URL.Path) + } + io.WriteString(w, "[]") + }) + t.Setenv("DSH_DOCKER_ROOT_VISIBLE", "") + w := imageRequest(a.builds, sid, "POST", `{"version":"1.2.3"}`) + if w.Code != 202 { + t.Fatal(w.Body.String()) + } + deadline := time.Now().Add(3 * time.Second) + for { + var status, detail string + if err := a.s.DB.QueryRow("SELECT status,error FROM image_builds").Scan(&status, &detail); err != nil { + t.Fatal(err) + } + if status == "failed" { + if !strings.Contains(detail, "capacity unknown") { + t.Fatal(detail) + } + break + } + if time.Now().After(deadline) { + t.Fatal(status, detail) + } + time.Sleep(time.Millisecond) + } +} + +func TestExactNPMMetadata(t *testing.T) { + for _, test := range []struct { + body string + status int + ok bool + }{ + {`{"version":"1.2.3"}`, 200, true}, + {`{"version":"1.2.4"}`, 200, false}, + {`{"version":"latest"}`, 200, false}, + {`{}`, 404, false}, + {`invalid`, 200, false}, + } { + client := &http.Client{Transport: imageRoundTrip(func(r *http.Request) (*http.Response, error) { + if r.URL.Host != "registry.npmjs.org" || !strings.HasSuffix(r.URL.Path, "/1.2.3") { + t.Error(r.URL) + } + return &http.Response{StatusCode: test.status, Body: io.NopCloser(strings.NewReader(test.body)), Header: make(http.Header)}, nil + })} + _, err := fetchNPMVersion(context.Background(), client, "1.2.3") + if (err == nil) != test.ok { + t.Fatalf("%s: %v", test.body, err) + } + } +} + +func TestBuildListReconcilesMissingAndLatestTimestamp(t *testing.T) { + a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "[]") }) + seedVerifiedImage(t, a, "deleted", "1.2.3") + w := imageRequest(a.builds, sid, "GET", "") + var records []buildRecord + if err := json.Unmarshal(w.Body.Bytes(), &records); err != nil || len(records) != 1 || records[0].Status != "missing" { + t.Fatal(w.Body.String()) + } + w = imageRequest(a.latest, sid, "GET", "") + var latest map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &latest); err != nil { + t.Fatal(err) + } + if _, err := time.Parse(time.RFC3339, latest["checked_at"]); err != nil || latest["version"] != "1.2.3" { + t.Fatal(latest, err) + } +} diff --git a/internal/api/images_test.go b/internal/api/images_test.go new file mode 100644 index 0000000..003bcc2 --- /dev/null +++ b/internal/api/images_test.go @@ -0,0 +1,498 @@ +package api + +import ( + "archive/tar" + "context" + "encoding/json" + "fmt" + "git.misaka.ren/m1saka/dsh-web/internal/docker" + "git.misaka.ren/m1saka/dsh-web/internal/store" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +func imageTestAPI(t *testing.T, h http.HandlerFunc) (*API, string) { + t.Helper() + oldDiskAvailable := buildDiskAvailable + buildDiskAvailable = func(string) (uint64, error) { return minimumBuildDiskBytes, nil } + t.Cleanup(func() { buildDiskAvailable = oldDiskAvailable }) + oldClient := npmBuildClient + npmBuildClient = &http.Client{Transport: imageRoundTrip(func(r *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"version":"1.2.3"}`))}, nil + })} + t.Cleanup(func() { npmBuildClient = oldClient }) + root := t.TempDir() + t.Setenv("DSH_DOCKER_ROOT_VISIBLE", root) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1.47/info" { + writeJSON(w, map[string]string{"DockerRootDir": root}) + return + } + h(w, r) + })) + t.Cleanup(srv.Close) + s, err := store.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + s.DB.SetMaxOpenConns(1) + a := New(s, t.TempDir()) + a.docker = docker.New(srv.URL) + t.Cleanup(func() { + deadline := time.Now().Add(5 * time.Second) + for { + buildQueue.Lock() + active := buildQueue.active[s.DB] + buildQueue.Unlock() + if !active { + break + } + if time.Now().After(deadline) { + t.Error("worker did not finish") + break + } + time.Sleep(time.Millisecond) + } + s.Close() + }) + if err = s.CreateUser(store.User{ID: "u", Username: "user"}, "unused"); err != nil { + t.Fatal(err) + } + sid, _, err := s.Session("u") + if err != nil { + t.Fatal(err) + } + registerTestAccess(t, a, sid) + return a, sid +} +func imageRequest(h http.HandlerFunc, sid, method, body string) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, "/", strings.NewReader(body)) + if sid != "" { + r.AddCookie(&http.Cookie{Name: "dsh_session", Value: sid}) + addTestAccess(r, sid) + } + w := httptest.NewRecorder() + h(w, r) + return w +} +func TestImageListContract(t *testing.T) { + a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1.47/images/unverified/json" { + writeImageInspect(w, "unverified", "1.2.3") + return + } + if r.URL.Path == "/v1.47/images/sha256:abc/json" { + writeImageInspect(w, "sha256:abc", "1.2.3") + return + } + if !strings.Contains(r.URL.Query().Get("filters"), "dsh-mgr.role=runtime") { + t.Error("missing label filter") + } + io.WriteString(w, `[{"Id":"sha256:abc","Labels":{"dsh-mgr.role":"runtime","dsh-mgr.version":"1.2.3","dsh-mgr.recipe":"`+imageRecipeRev+`"},"RepoTags":["runtime:1.2.3"]},{"Id":"other","Labels":{}},{"Id":"unverified","Labels":{"dsh-mgr.role":"runtime","dsh-mgr.version":"1.2.3","dsh-mgr.recipe":"`+imageRecipeRev+`"}},{"Id":"sha256:abc","Labels":{"dsh-mgr.role":"runtime","dsh-mgr.version":"9.9.9"}}]`) + }) + seedVerifiedImage(t, a, "sha256:abc", "1.2.3") + if w := imageRequest(a.images, "", "GET", ""); w.Code != 401 { + t.Fatal(w.Code) + } + w := imageRequest(a.images, sid, "GET", "") + var images []imageRecord + if err := json.Unmarshal(w.Body.Bytes(), &images); err != nil { + t.Fatal(err) + } + if len(images) != 2 || images[0].ID != "sha256:abc" || images[0].ImageID != images[0].ID || images[0].Status != "available" || images[1].Status != "unverified" { + t.Fatal(w.Body.String()) + } + if w = imageRequest(a.images, sid, "POST", ""); w.Code != 405 { + t.Fatal(w.Code) + } +} + +func seedVerifiedImage(t *testing.T, a *API, id, version string) { + t.Helper() + buildQueue.Lock() + defer buildQueue.Unlock() + if err := a.ensureBuilds(); err != nil { + t.Fatal(err) + } + if _, err := a.s.DB.Exec("INSERT INTO image_builds(id,version,status,image_id,created_at,recipe_rev,platform) VALUES(?,?,'success',?,'now',?,?)", store.ID(), version, id, imageRecipeRev, imagePlatform); err != nil { + t.Fatal(err) + } +} +func TestImageContextAndSanitizing(t *testing.T) { + r, err := imageBuildContext() + if err != nil { + t.Fatal(err) + } + tr := tar.NewReader(r) + n := 0 + for { + h, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + data, _ := io.ReadAll(tr) + original, err := os.ReadFile(filepath.Join("..", "..", "image", h.Name)) + if err != nil || string(data) != string(original) { + t.Fatalf("recipe drift %s: %v", h.Name, err) + } + if h.Name == "Dockerfile" && (!strings.Contains(string(data), "ARG RECIPE_REV="+imageRecipeRev+"\n") || !strings.Contains(string(data), "dsh-mgr.recipe=$RECIPE_REV")) { + t.Fatal("recipe revision or label differs from image policy") + } + n++ + } + if n != 7 { + t.Fatal(n) + } + s := sanitizeBuildLog("token=topsecret Authorization: Bearer hidden password=private https://user:pass@example.com/") + for _, secret := range []string{"topsecret", "hidden", "private", "user:pass"} { + if strings.Contains(s, secret) { + t.Fatal(s) + } + } + if len(sanitizeBuildLog(strings.Repeat("x", 2*buildLogLimit))) > buildLogLimit { + t.Fatal("unbounded") + } + for _, v := range []string{"latest", "*", "^1.2.3", "1.2", "1.2.3;id", "https://example.com", ""} { + if validBuildVersion(v) { + t.Fatal(v) + } + } + if !validBuildVersion("1.2.3-beta.1") { + t.Fatal("fixed prerelease rejected") + } +} + +type imageRoundTrip func(*http.Request) (*http.Response, error) + +func (f imageRoundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } +func TestLatestOfficialRegistry(t *testing.T) { + client := &http.Client{Transport: imageRoundTrip(func(r *http.Request) (*http.Response, error) { + if r.URL.Host != "registry.npmjs.org" || r.URL.Scheme != "https" { + t.Fatal(r.URL) + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"version":"1.2.3"}`)), Header: make(http.Header)}, nil + })} + v, err := fetchLatest(context.Background(), client) + if err != nil || v != "1.2.3" { + t.Fatalf("%s %v", v, err) + } +} +func TestBuildDedupAndVerification(t *testing.T) { + var calls, deleted atomic.Int32 + a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1.47/images/sha256:abc/json", "/v1.47/images/sha256:reuse/json": + writeImageInspect(w, strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/v1.47/images/"), "/json"), "1.2.3") + case "/v1.47/images/json": + if calls.Load() == 0 { + io.WriteString(w, "[]") + } else { + io.WriteString(w, `[{"Id":"sha256:abc","Labels":{"dsh-mgr.role":"runtime","dsh-mgr.version":"1.2.3","dsh-mgr.recipe":"`+imageRecipeRev+`"}}]`) + } + case "/v1.47/build": + calls.Add(1) + if r.URL.Query().Get("t") != "dsh-web:1.2.3-"+imageRecipeRev { + t.Error("unexpected tag", r.URL.Query()) + } + var args map[string]string + json.Unmarshal([]byte(r.URL.Query().Get("buildargs")), &args) + if args["DSH_VERSION"] != "1.2.3" { + t.Error(args) + } + io.Copy(io.Discard, r.Body) + io.WriteString(w, `{"stream":"token=secretvalue"}`+string(rune(10))+`{"aux":{"ID":"sha256:abc"}}`) + case "/v1.47/containers/create": + var cfg struct { + Image string + HostConfig struct { + NetworkMode string + ReadonlyRootfs bool + Tmpfs map[string]string + } + } + json.NewDecoder(r.Body).Decode(&cfg) + if cfg.Image != "sha256:abc" || cfg.HostConfig.NetworkMode != "none" || !cfg.HostConfig.ReadonlyRootfs || !strings.Contains(cfg.HostConfig.Tmpfs["/tmp"], "exec") { + t.Errorf("unsafe config %+v", cfg) + } + io.WriteString(w, `{"Id":"check"}`) + case "/v1.47/containers/check/start", "/v1.47/exec/probe/start": + w.WriteHeader(204) + case "/v1.47/containers/check/exec": + var cfg struct{ Cmd []string } + if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil || len(cfg.Cmd) != 3 || !strings.Contains(cfg.Cmd[2], "execFileSync('dsh',['--version']") || !strings.Contains(cfg.Cmd[2], `actual!=="1.2.3"`) { + t.Errorf("missing version verification: %+v %v", cfg, err) + } + io.WriteString(w, `{"Id":"probe"}`) + case "/v1.47/exec/probe/json": + io.WriteString(w, `{"Running":false,"ExitCode":0}`) + case "/v1.47/containers/check/json": + io.WriteString(w, `{"State":{"Running":true}}`) + case "/v1.47/containers/check": + deleted.Add(1) + w.WriteHeader(204) + default: + t.Errorf("unexpected %s", r.URL) + w.WriteHeader(500) + } + }) + responses := make(chan *httptest.ResponseRecorder, 8) + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + responses <- imageRequest(a.builds, sid, "POST", `{"version":"1.2.3"}`) + }() + } + wg.Wait() + close(responses) + first := "" + for w := range responses { + if w.Code != 202 { + t.Fatal(w.Body.String()) + } + var b buildRecord + json.Unmarshal(w.Body.Bytes(), &b) + if first == "" { + first = b.ID + } + if first != b.ID { + t.Fatal("duplicate job") + } + } + deadline := time.Now().Add(5 * time.Second) + for { + w := imageRequest(a.builds, sid, "GET", "") + var jobs []buildRecord + json.Unmarshal(w.Body.Bytes(), &jobs) + if len(jobs) == 1 && jobs[0].Status == "success" { + if strings.Contains(jobs[0].Logs, "secretvalue") { + t.Fatal(jobs) + } + break + } + if time.Now().After(deadline) { + t.Fatal(w.Body.String()) + } + time.Sleep(time.Millisecond) + } + if calls.Load() != 1 || deleted.Load() != 1 { + t.Fatalf("builds %d cleanup %d", calls.Load(), deleted.Load()) + } + for _, body := range []string{`{"version":"latest"}`, `{"version":"1.2.3","url":"bad"}`, `{"version":"1.2.3"} {}`} { + if w := imageRequest(a.builds, sid, "POST", body); w.Code != 400 { + t.Fatal(w.Code) + } + } +} +func TestVerificationFailureCleansUp(t *testing.T) { + var deleted atomic.Bool + a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/create"): + io.WriteString(w, `{"Id":"bad"}`) + case r.Method == "DELETE": + deleted.Store(true) + w.WriteHeader(204) + default: + w.WriteHeader(500) + io.WriteString(w, `{"message":"cannot start"}`) + } + }) + if err := a.verifyBuiltImage(context.Background(), "bad", "1.2.3"); err == nil { + t.Fatal("accepted failed start") + } + if !deleted.Load() { + t.Fatal("container leaked") + } +} + +func TestBuildReuseAndMissingImage(t *testing.T) { + var exists atomic.Bool + exists.Store(true) + var builds, checks atomic.Int32 + a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1.47/images/sha256:abc/json", "/v1.47/images/sha256:reuse/json": + writeImageInspect(w, strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/v1.47/images/"), "/json"), "1.2.3") + case "/v1.47/images/json": + if exists.Load() { + io.WriteString(w, `[{"Id":"sha256:reuse","Labels":{"dsh-mgr.role":"runtime","dsh-mgr.version":"1.2.3","dsh-mgr.recipe":"`+imageRecipeRev+`"}}]`) + } else { + io.WriteString(w, "[]") + } + case "/v1.47/build": + builds.Add(1) + exists.Store(true) + io.Copy(io.Discard, r.Body) + io.WriteString(w, `{"aux":{"ID":"sha256:reuse"}}`) + case "/v1.47/containers/create": + checks.Add(1) + io.WriteString(w, `{"Id":"c"}`) + case "/v1.47/containers/c/start", "/v1.47/exec/e/start", "/v1.47/containers/c": + w.WriteHeader(204) + case "/v1.47/containers/c/exec": + io.WriteString(w, `{"Id":"e"}`) + case "/v1.47/exec/e/json": + io.WriteString(w, `{"Running":false,"ExitCode":0}`) + case "/v1.47/containers/c/json": + io.WriteString(w, `{"State":{"Running":true}}`) + default: + t.Errorf("unexpected %s", r.URL) + w.WriteHeader(500) + } + }) + wait := func(id string) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for { + var status, image string + err := a.s.DB.QueryRow("SELECT status,image_id FROM image_builds WHERE id=?", id).Scan(&status, &image) + if err == nil && status == "success" { + if image != "sha256:reuse" { + t.Fatal(image) + } + return + } + if time.Now().After(deadline) { + t.Fatalf("status %s: %v", status, err) + } + time.Sleep(time.Millisecond) + } + } + post := func() buildRecord { + t.Helper() + w := imageRequest(a.builds, sid, "POST", `{"version":"1.2.3"}`) + if w.Code != 202 { + t.Fatal(w.Body.String()) + } + var b buildRecord + if err := json.Unmarshal(w.Body.Bytes(), &b); err != nil { + t.Fatal(err) + } + return b + } + first := post() + wait(first.ID) + if builds.Load() != 0 || checks.Load() != 1 { + t.Fatal("local image not reused and verified") + } + exists.Store(false) + second := post() + if second.ID == first.ID { + t.Fatal("missing image deduplicated") + } + wait(second.ID) + if builds.Load() != 1 || checks.Load() != 2 { + t.Fatal("missing image not rebuilt") + } +} +func TestBuildRestartRecovery(t *testing.T) { + a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(500) }) + buildQueue.Lock() + defer buildQueue.Unlock() + if err := a.ensureBuilds(); err != nil { + t.Fatal(err) + } + if _, err := a.s.DB.Exec("INSERT INTO image_builds(id,version,status,created_at) VALUES('old','1.2.3','running','now')"); err != nil { + t.Fatal(err) + } + delete(buildQueue.initialized, a.s.DB) + if err := a.ensureBuilds(); err != nil { + t.Fatal(err) + } + var status, detail string + if err := a.s.DB.QueryRow("SELECT status,error FROM image_builds WHERE id='old'").Scan(&status, &detail); err != nil { + t.Fatal(err) + } + if status != "failed" || !strings.Contains(detail, "restart") { + t.Fatalf("%s %s", status, detail) + } +} + +func TestFailedVerificationNeverAvailable(t *testing.T) { + var deleted atomic.Bool + imageID := "sha256:" + strings.Repeat("c", 64) + a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1.47/images/sha256:abc/json", "/v1.47/images/sha256:reuse/json": + writeImageInspect(w, strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/v1.47/images/"), "/json"), "1.2.3") + case "/v1.47/images/json": + writeJSON(w, []any{map[string]any{"Id": imageID, "Labels": map[string]string{"dsh-mgr.role": "runtime", "dsh-mgr.version": "1.2.3", "dsh-mgr.recipe": imageRecipeRev}}}) + case "/v1.47/containers/create": + io.WriteString(w, `{"Id":"check"}`) + case "/v1.47/containers/check/start", "/v1.47/exec/probe/start": + w.WriteHeader(204) + case "/v1.47/containers/check/exec": + io.WriteString(w, `{"Id":"probe"}`) + case "/v1.47/exec/probe/json": + io.WriteString(w, `{"Running":false,"ExitCode":1}`) + case "/v1.47/containers/check": + deleted.Store(true) + w.WriteHeader(204) + case "/v1.47/images/" + imageID + "/json": + writeImageInspect(w, imageID, "1.2.3") + default: + t.Errorf("unexpected Docker call %s", r.URL) + w.WriteHeader(500) + } + }) + w := imageRequest(a.builds, sid, "POST", `{"version":"1.2.3"}`) + if w.Code != 202 { + t.Fatal(w.Body.String()) + } + deadline := time.Now().Add(5 * time.Second) + for { + var status, id, detail string + err := a.s.DB.QueryRow("SELECT status,image_id,error FROM image_builds").Scan(&status, &id, &detail) + if err == nil && status == "failed" { + if id != imageID || !strings.Contains(detail, "verification failed") || !deleted.Load() { + t.Fatalf("%s %s cleanup=%v", id, detail, deleted.Load()) + } + break + } + if time.Now().After(deadline) { + t.Fatalf("status=%s error=%v", status, err) + } + time.Sleep(time.Millisecond) + } + w = imageRequest(a.images, sid, "GET", "") + var images []imageRecord + if err := json.Unmarshal(w.Body.Bytes(), &images); err != nil || w.Code != 200 || len(images) != 1 || images[0].Status != "unverified" { + t.Fatal("failed image advertised", w.Body.String()) + } + w = imageRequest(a.instances, sid, "POST", fmt.Sprintf(`{"name":"rejected","image_id":%q}`, imageID)) + if w.Code != 400 { + t.Fatal("unverified image accepted", w.Code, w.Body.String()) + } +} + +func TestLocalBuildImagePrefersPersistedID(t *testing.T) { + a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1.47/images/verified/json" { + writeImageInspect(w, "verified", "1.2.3") + return + } + io.WriteString(w, `[{"Id":"newer","Labels":{"dsh-mgr.role":"runtime","dsh-mgr.version":"1.2.3","dsh-mgr.recipe":"`+imageRecipeRev+`"}},{"Id":"verified","Labels":{"dsh-mgr.role":"runtime","dsh-mgr.version":"1.2.3","dsh-mgr.recipe":"`+imageRecipeRev+`"}}]`) + }) + id, err := a.localBuildImage(context.Background(), "1.2.3", "verified") + if err != nil || id != "verified" { + t.Fatalf("id=%s err=%v", id, err) + } +} + +func writeImageInspect(w http.ResponseWriter, id, version string) { + parts := strings.Split(imagePlatform, "/") + writeJSON(w, map[string]any{"Id": id, "Os": parts[0], "Architecture": parts[1], "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.role": "runtime", "dsh-mgr.version": version, "dsh-mgr.recipe": imageRecipeRev}}}) +} diff --git a/internal/api/intents.go b/internal/api/intents.go new file mode 100644 index 0000000..1f8ff54 --- /dev/null +++ b/internal/api/intents.go @@ -0,0 +1,212 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "time" + + "git.misaka.ren/m1saka/dsh-web/internal/store" +) + +func (a *API) intents(w http.ResponseWriter, r *http.Request) { + u, _, err := a.user(r) + if err != nil { + fail(w, 401, "authentication required") + return + } + if err = a.s.EnsureIntents(); err != nil { + fail(w, 500, "database error") + return + } + switch r.Method { + case http.MethodGet: + list, err := a.s.Intents(u.ID) + if err != nil { + fail(w, 500, "database error") + return + } + writeJSON(w, list) + case http.MethodPost: + var in struct { + Name string `json:"name"` + Version string `json:"version"` + } + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)) + dec.DisallowUnknownFields() + if dec.Decode(&in) != nil || dec.Decode(new(any)) != io.EOF || strings.TrimSpace(in.Name) == "" || len(in.Name) > 200 || !validBuildVersion(in.Version) { + fail(w, 400, "name and fixed version required") + return + } + i := store.Intent{ID: store.ID(), UID: u.ID, Name: strings.TrimSpace(in.Name), Version: in.Version, Host: a.runtimeHost(r), Status: "waiting"} + if err = a.s.ReserveIntent(i); err != nil { + if errors.Is(err, store.ErrInstanceLimit) { + fail(w, 409, err.Error()) + } else { + fail(w, 500, "database error") + } + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + writeJSON(w, i) + default: + fail(w, 405, "method not allowed") + } +} +func (a *API) intent(w http.ResponseWriter, r *http.Request) { + u, _, err := a.user(r) + if err != nil { + fail(w, 401, "authentication required") + return + } + if r.Method != http.MethodDelete { + fail(w, 405, "method not allowed") + return + } + if err = a.s.EnsureIntents(); err != nil { + fail(w, 500, "database error") + return + } + id := strings.TrimPrefix(r.URL.Path, "/api/intents/") + if err = a.s.CancelIntent(id, u.ID); err != nil { + if errors.Is(err, store.ErrIntentState) { + fail(w, 409, "intent not found or no longer waiting") + } else { + fail(w, 500, "database error") + } + return + } + writeJSON(w, map[string]string{"status": "cancelled"}) +} + +// Call once at startup, after the build queue has recovered interrupted jobs. +func (a *API) StartIntents(ctx context.Context) error { + if err := a.s.EnsureIntents(); err != nil { + return err + } + if err := a.recoverIntents(ctx); err != nil { + return err + } + go func() { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + default: + } + a.processIntents(ctx) + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } + }() + return nil +} + +func (a *API) finishIntent(id, status, detail string) { + _, _ = a.s.DB.Exec(`UPDATE creation_intents SET status=?,error=? WHERE id=? AND status IN ('waiting','creating')`, status, detail, id) +} +func (a *API) recoverIntents(ctx context.Context) error { + list, err := a.s.Intents("") + if err != nil { + return err + } + for _, in := range list { + if in.Status != "creating" { + continue + } + i, err := a.s.GetInstance(in.InstanceID) + if err == nil && i.Status == "running" { + a.finishIntent(in.ID, "success", "") + continue + } + cleanup, cancel := context.WithTimeout(ctx, 30*time.Second) + // Docker may have created the container before its ID reached SQLite. + err = a.removeRuntimeContainer(cleanup, runtimeNetwork(in.InstanceID)) + if err == nil { + err = a.removeRuntimeNetwork(cleanup, in.InstanceID) + } + cancel() + if err != nil { + return err + } + a.s.DeleteInstance(in.InstanceID) + a.finishIntent(in.ID, "failed", "instance creation interrupted by process restart") + } + return nil +} + +func (a *API) processIntents(ctx context.Context) { + list, err := a.s.Intents("") + if err != nil { + return + } + for _, in := range list { + if ctx.Err() != nil { + return + } + if in.Status != "waiting" { + continue + } + if in.BuildID == "" { + b, err := a.enqueueBuild(ctx, in.UID, in.Version) + if err != nil { + a.finishIntent(in.ID, "failed", "build submission failed") + continue + } + if _, err = a.s.DB.Exec(`UPDATE creation_intents SET build_id=? WHERE id=? AND status='waiting'`, b.ID, in.ID); err != nil { + continue + } + in.BuildID = b.ID + } + var status, imageID, version string + err := a.s.DB.QueryRow(`SELECT status,image_id,version FROM image_builds WHERE id=?`, in.BuildID).Scan(&status, &imageID, &version) + if err != nil { + continue + } + if version != in.Version || status == "failed" || status == "cancelled" || status == "missing" { + a.finishIntent(in.ID, "failed", "build failed; requested version was not created") + continue + } + if status != "success" { + continue + } + work, cancel := context.WithTimeout(ctx, 90*time.Second) + verified, err := a.verifiedImages(work) + if err != nil { + cancel() + continue + } + if !runtimeImageID.MatchString(imageID) || verified[imageID] != in.Version { + cancel() + a.finishIntent(in.ID, "failed", "verified image for requested version unavailable") + continue + } + u, err := a.s.UserByID(in.UID) + if err != nil { + cancel() + a.finishIntent(in.ID, "failed", "owner unavailable") + continue + } + i := store.Instance{ID: store.ID(), UID: in.UID, Name: in.Name, Version: in.Version, ImageID: imageID, Status: "creating", Created: time.Now()} + if err = a.s.ClaimIntent(in.ID, i); err != nil { + cancel() + continue + } + _, err = a.createRuntime(work, u, i, in.Host) + cancel() + if err != nil { + a.finishIntent(in.ID, "failed", err.Error()) + } else { + a.finishIntent(in.ID, "success", "") + } + } +} diff --git a/internal/api/intents_test.go b/internal/api/intents_test.go new file mode 100644 index 0000000..60e55e7 --- /dev/null +++ b/internal/api/intents_test.go @@ -0,0 +1,158 @@ +package api + +import ( + "context" + "git.misaka.ren/m1saka/dsh-web/internal/store" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) + +func TestIntentsReserveCancel(t *testing.T) { + a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { t.Error("reservation contacted Docker") }) + if err := a.initAuth(); err != nil { + t.Fatal(err) + } + for n := 0; n < 3; n++ { + w := httptest.NewRecorder() + a.intents(w, runtimeRequest("POST", "/api/intents", `{"name":"one","version":"1.2.3"}`, sid)) + want := 202 + if n == 2 { + want = 409 + } + if w.Code != want { + t.Fatalf("%d: %d %s", n, w.Code, w.Body.String()) + } + } + list, err := a.s.Intents("owner") + if err != nil || len(list) != 2 { + t.Fatalf("%v %v", list, err) + } + w := httptest.NewRecorder() + a.intent(w, runtimeRequest("DELETE", "/api/intents/"+list[0].ID, "", sid)) + if w.Code != 200 { + t.Fatal(w.Code) + } + if err := a.s.ReserveIntent(store.Intent{ID: store.ID(), UID: "owner"}); err != nil { + t.Fatal(err) + } +} + +func TestIntentBuildSuccessUsesRealRuntime(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("runtime filesystem ownership requires root") + } + image := "sha256:" + strings.Repeat("a", 64) + creates := 0 + a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/images/"): + writeJSON(w, map[string]any{"Id": image, "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.role": "runtime", "dsh-mgr.version": "1.2.3"}}}) + case strings.HasSuffix(r.URL.Path, "/containers/create"): + creates++ + writeJSON(w, map[string]string{"Id": "runtime-created"}) + default: + w.WriteHeader(204) + } + }) + seedVerifiedImage(t, a, image, "1.2.3") + if err := a.s.EnsureIntents(); err != nil { + t.Fatal(err) + } + var build string + if err := a.s.DB.QueryRow("SELECT id FROM image_builds WHERE version='1.2.3'").Scan(&build); err != nil { + t.Fatal(err) + } + in := store.Intent{ID: store.ID(), UID: "owner", Name: "one", Version: "1.2.3", Host: "panel.test"} + if err := a.s.ReserveIntent(in); err != nil { + t.Fatal(err) + } + if _, err := a.s.DB.Exec("UPDATE creation_intents SET build_id=? WHERE id=?", build, in.ID); err != nil { + t.Fatal(err) + } + a.processIntents(context.Background()) + a.processIntents(context.Background()) + list, err := a.s.Intents("owner") + if err != nil || len(list) != 1 || list[0].Status != "success" || creates != 1 { + t.Fatalf("%+v err=%v creates=%d", list, err, creates) + } + instances, err := a.s.Instances("owner") + if err != nil || len(instances) != 1 || instances[0].ImageID != image || instances[0].Version != "1.2.3" { + t.Fatalf("%+v %v", instances, err) + } +} + +func TestIntentFailedBuildReleasesAndCancelPreservesBuild(t *testing.T) { + a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { t.Error("failed intent contacted Docker") }) + seedVerifiedImage(t, a, "sha256:"+strings.Repeat("b", 64), "1.2.3") + if err := a.s.EnsureIntents(); err != nil { + t.Fatal(err) + } + var build string + a.s.DB.QueryRow("SELECT id FROM image_builds LIMIT 1").Scan(&build) + for _, id := range []string{"cancel", "fail"} { + if err := a.s.ReserveIntent(store.Intent{ID: id, UID: "owner", Version: "1.2.3"}); err != nil { + t.Fatal(err) + } + a.s.DB.Exec("UPDATE creation_intents SET build_id=? WHERE id=?", build, id) + } + if err := a.s.CancelIntent("cancel", "owner"); err != nil { + t.Fatal(err) + } + var status string + a.s.DB.QueryRow("SELECT status FROM image_builds WHERE id=?", build).Scan(&status) + if status != "success" { + t.Fatal(status) + } + a.s.DB.Exec("UPDATE image_builds SET status='failed' WHERE id=?", build) + a.processIntents(context.Background()) + for n := 0; n < 2; n++ { + if err := a.s.ReserveIntent(store.Intent{ID: store.ID(), UID: "owner"}); err != nil { + t.Fatal(err) + } + } +} + +func TestIntentRestartCleansInterruptedCreationAndRetainsWaiting(t *testing.T) { + deletes := 0 + a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "DELETE" { + deletes++ + } + w.WriteHeader(204) + }) + if err := a.s.EnsureIntents(); err != nil { + t.Fatal(err) + } + for _, id := range []string{"waiting", "creating"} { + if err := a.s.ReserveIntent(store.Intent{ID: id, UID: "owner", Version: "1.2.3"}); err != nil { + t.Fatal(err) + } + } + if err := a.s.ClaimIntent("creating", store.Instance{ID: "reserved", UID: "owner", Version: "1.2.3", Status: "creating"}); err != nil { + t.Fatal(err) + } + if err := a.recoverIntents(context.Background()); err != nil { + t.Fatal(err) + } + if deletes != 2 { + t.Fatalf("cleanup calls=%d", deletes) + } + list, err := a.s.Intents("owner") + if err != nil { + t.Fatal(err) + } + for _, in := range list { + if in.ID == "waiting" && in.Status != "waiting" { + t.Fatal(in) + } + if in.ID == "creating" && in.Status != "failed" { + t.Fatal(in) + } + } + if err := a.s.ReserveIntent(store.Intent{ID: store.ID(), UID: "owner"}); err != nil { + t.Fatal(err) + } +} diff --git a/internal/api/orphans.go b/internal/api/orphans.go new file mode 100644 index 0000000..3ec664a --- /dev/null +++ b/internal/api/orphans.go @@ -0,0 +1,143 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +var orphanID = regexp.MustCompile(`^[a-f0-9]{64}$`) +var orphanLabelID = regexp.MustCompile(`^[a-f0-9]{32}$`) + +func (a *API) checkOrphan(ctx context.Context, id string) error { + var c struct { + ID, Name string + Config struct{ Labels map[string]string } + State struct { + Status string + Running, Paused, Restarting bool + } + Mounts []struct{ Type, Source, Destination string } + } + if !orphanID.MatchString(id) { + return errors.New("invalid container ID") + } + if err := a.docker.Do(ctx, "GET", "/containers/"+id+"/json", nil, &c); err != nil { + return errors.New("container unavailable") + } + inst, owner := c.Config.Labels["dsh-mgr.instance"], c.Config.Labels["dsh-mgr.owner"] + if c.ID != id || c.Config.Labels["dsh-mgr.role"] != "dsh" || !orphanLabelID.MatchString(inst) || !orphanLabelID.MatchString(owner) || strings.TrimPrefix(c.Name, "/") != runtimeNetwork(inst) { + return errors.New("container identity not eligible") + } + if c.State.Running || c.State.Paused || c.State.Restarting || (c.State.Status != "exited" && c.State.Status != "created") { + return errors.New("only stopped containers can be reclaimed") + } + var n int + if err := a.s.DB.QueryRowContext(ctx, "SELECT count(*) FROM instances WHERE id=? OR container_id=?", inst, id).Scan(&n); err != nil || n != 0 { + return errors.New("instance reference or database unavailable") + } + if err := a.s.DB.QueryRowContext(ctx, "SELECT count(*) FROM users WHERE id=?", owner).Scan(&n); err != nil || n != 1 { + return errors.New("unknown owner") + } + if err := a.s.DB.QueryRowContext(ctx, "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='upgrade_journal'").Scan(&n); err != nil { + return err + } + if n != 0 { + if err := a.s.DB.QueryRowContext(ctx, "SELECT count(*) FROM upgrade_journal WHERE instance_id=? OR old_container=? OR new_container=?", inst, id, id).Scan(&n); err != nil || n != 0 { + return errors.New("upgrade reference or database unavailable") + } + } + host := os.Getenv("HOST_DATA_ROOT") + if host == "" { + host = a.root + } + if !filepath.IsAbs(host) { + return errors.New("host root unavailable") + } + expected := map[string]string{"/workspace": filepath.Join(host, "workspaces", owner, "instances", inst, "home")} + // Accept complete historical layouts, never arbitrary bind sources. + for _, m := range c.Mounts { + if m.Destination == "/data/dsh-home" { + expected = map[string]string{"/workspace": filepath.Join(host, "workspaces", owner, "files"), "/data/dsh-home": filepath.Join(host, "workspaces", owner, "instances", inst, "home")} + } + } + for _, m := range c.Mounts { + if m.Destination == "/shared" { + expected["/shared"] = filepath.Join(host, "workspaces", owner, "files") + } + } + for _, m := range c.Mounts { + if m.Type == "tmpfs" && m.Destination == "/tmp" { + continue + } + want, ok := expected[m.Destination] + if !ok || m.Type != "bind" || filepath.Clean(m.Source) != want { + return errors.New("mount identity not eligible") + } + delete(expected, m.Destination) + } + if len(expected) != 0 { + return errors.New("required mounts missing") + } + return nil +} + +func (a *API) orphans(w http.ResponseWriter, r *http.Request) { + u, _, err := a.user(r) + if err != nil { + fail(w, 401, "authentication required") + return + } + if !u.Admin { + fail(w, 403, "administrator required") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + a.mu.Lock() + defer a.mu.Unlock() + switch r.Method { + case "GET": + filters, _ := json.Marshal(map[string][]string{"label": {"dsh-mgr.role=dsh"}}) + var list []struct { + ID string `json:"Id"` + } + if a.docker.Do(ctx, "GET", "/containers/json?all=1&filters="+url.QueryEscape(string(filters)), nil, &list) != nil { + fail(w, 502, "container inventory unavailable") + return + } + eligible := []string{} + for _, c := range list { + if a.checkOrphan(ctx, c.ID) == nil { + eligible = append(eligible, c.ID) + } + } + writeJSON(w, eligible) + case "DELETE": + id := r.URL.Query().Get("id") + if r.URL.Query().Get("confirm") != id || id == "" { + fail(w, 400, "exact container ID confirmation required") + return + } + if err := a.checkOrphan(ctx, id); err != nil { + fail(w, 409, err.Error()) + return + } + // Never force removal or remove volumes. Docker rejects a concurrent start. + if a.docker.Do(ctx, "DELETE", "/containers/"+id+"?force=false&v=false", nil, nil) != nil { + fail(w, 409, "container changed or removal rejected") + return + } + a.s.AddAudit(u.ID, "reconcile.reclaim", id, "stopped orphan removed; bind data retained") + writeJSON(w, map[string]bool{"ok": true}) + default: + fail(w, 405, "method not allowed") + } +} diff --git a/internal/api/orphans_test.go b/internal/api/orphans_test.go new file mode 100644 index 0000000..7179b6f --- /dev/null +++ b/internal/api/orphans_test.go @@ -0,0 +1,65 @@ +package api + +import ( + "context" + "git.misaka.ren/m1saka/dsh-web/internal/store" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestOrphanReclaimGuards(t *testing.T) { + for _, kind := range []string{"safe", "running", "foreign-mount", "reference", "upgrade", "confirmation", "nonadmin"} { + t.Run(kind, func(t *testing.T) { + t.Setenv("HOST_DATA_ROOT", "/host/data") + id, inst, owner := strings.Repeat("a", 64), strings.Repeat("b", 32), strings.Repeat("c", 32) + deleted := false + a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "DELETE" { + if r.URL.Query().Get("force") != "false" || r.URL.Query().Get("v") != "false" { + t.Fatal("unsafe deletion") + } + deleted = true + w.WriteHeader(204) + return + } + source := "/host/data/workspaces/" + owner + "/instances/" + inst + "/home" + if kind == "foreign-mount" { + source = "/unrelated" + } + writeJSON(w, map[string]any{"Id": id, "Name": "/dsh-inst-" + inst, "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.role": "dsh", "dsh-mgr.instance": inst, "dsh-mgr.owner": owner}}, "State": map[string]any{"Status": "exited", "Running": kind == "running"}, "Mounts": []map[string]string{{"Type": "bind", "Source": source, "Destination": "/workspace"}}}) + }) + if err := a.s.CreateUser(store.User{ID: owner, Username: "orphan-owner"}, "unused"); err != nil { + t.Fatal(err) + } + if kind != "nonadmin" { + if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='owner'"); err != nil { + t.Fatal(err) + } + } + if kind == "reference" { + if err := a.s.AddInstance(store.Instance{ID: inst, UID: owner, ContainerID: id}); err != nil { + t.Fatal(err) + } + } + if kind == "upgrade" { + if _, err := a.s.DB.Exec("CREATE TABLE upgrade_journal(instance_id TEXT,old_container TEXT,new_container TEXT); INSERT INTO upgrade_journal VALUES(?,?,'')", inst, id); err != nil { + t.Fatal(err) + } + } + if kind == "safe" && a.checkOrphan(context.Background(), id) != nil { + t.Fatal("safe orphan rejected") + } + confirm := id + if kind == "confirmation" { + confirm = "wrong" + } + w := httptest.NewRecorder() + a.orphans(w, runtimeRequest("DELETE", "/api/orphans?id="+id+"&confirm="+confirm, "", sid)) + if (w.Code == 200) != (kind == "safe") || deleted != (kind == "safe") { + t.Fatalf("status=%d deleted=%v body=%s", w.Code, deleted, w.Body.String()) + } + }) + } +} diff --git a/internal/api/password.go b/internal/api/password.go new file mode 100644 index 0000000..5413ef9 --- /dev/null +++ b/internal/api/password.go @@ -0,0 +1,94 @@ +package api + +import ( + "encoding/json" + "io" + "net/http" + "time" + + "golang.org/x/crypto/bcrypt" +) + +func (a *API) changePassword(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if r.Method != http.MethodPost { + fail(w, 405, "method not allowed") + return + } + if _, _, err := a.user(r); err != nil { + fail(w, 401, "authentication required") + return + } + var in struct { + CurrentPassword string `json:"current_password"` + Password string `json:"password"` + } + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)) + decoder.DisallowUnknownFields() + if decoder.Decode(&in) != nil || decoder.Decode(new(any)) != io.EOF || in.CurrentPassword == "" || len(in.Password) < 12 || len(in.Password) > 72 { + fail(w, 400, "current password and new password 12–72 bytes required") + return + } + // Recheck credentials after waiting for concurrent logins or password resets. + a.authLoginMu.Lock() + defer a.authLoginMu.Unlock() + u, _, err := a.user(r) + if err != nil { + fail(w, 401, "authentication required") + return + } + locked, err := a.accountLocked(u.Username) + if err != nil { + fail(w, 503, "authentication unavailable") + return + } + if locked { + fail(w, 429, "account temporarily locked; retry later") + return + } + currentHash, err := a.s.Password(u.Username) + if err != nil { + fail(w, 503, "authentication unavailable") + return + } + if len(in.CurrentPassword) > 72 || bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(in.CurrentPassword)) != nil { + if err = a.failedLogin(u.Username); err != nil { + fail(w, 503, "authentication unavailable") + return + } + fail(w, 400, "invalid current password") + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost) + if err != nil { + fail(w, 500, "password error") + return + } + tx, err := a.s.DB.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, "password change failed") + return + } + defer tx.Rollback() + _, err = tx.ExecContext(r.Context(), "UPDATE users SET pass_hash=? WHERE id=?", string(hash), u.ID) + if err == nil { + _, err = tx.ExecContext(r.Context(), "DELETE FROM auth_refresh WHERE sid IN (SELECT id FROM sessions WHERE uid=?)", u.ID) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), "DELETE FROM sessions WHERE uid=?", u.ID) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), "DELETE FROM auth_failures WHERE username=?", u.Username) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), "INSERT INTO audit(uid,action,target,detail,at) VALUES(?,?,?,?,?)", u.ID, "user.change_password", u.ID, "", time.Now().Unix()) + } + if err == nil { + err = tx.Commit() + } + if err != nil { + fail(w, 500, "password change failed") + return + } + writeJSON(w, map[string]bool{"ok": true, "reauthenticate": true}) +} diff --git a/internal/api/password_test.go b/internal/api/password_test.go new file mode 100644 index 0000000..d79c9d0 --- /dev/null +++ b/internal/api/password_test.go @@ -0,0 +1,365 @@ +package api + +import ( + "encoding/json" + "fmt" + "golang.org/x/crypto/bcrypt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +const changePath = "/api/me/password" + +func TestChangePasswordCurrentLength(t *testing.T) { + a, req := authFixture(t) + password := strings.Repeat("a", 72) + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + if _, err = a.s.DB.Exec("UPDATE users SET pass_hash=? WHERE id='alice'", string(hash)); err != nil { + t.Fatal(err) + } + cs := req("POST", "/api/login", resetLogin("alice", password), "", nil).Result().Cookies() + if w := req("POST", changePath, changeBody(password+"x", resetSecret), cookieValue(cs, "dsh_csrf"), cs); w.Code != 400 { + t.Fatal("accepted prefix", w.Code) + } + if w := req("POST", changePath, changeBody(password, resetSecret), cookieValue(cs, "dsh_csrf"), cs); w.Code != 200 { + t.Fatal("72-byte current rejected", w.Code) + } +} + +func TestChangePasswordConcurrentChanges(t *testing.T) { + a, req := authFixture(t) + cs := req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil).Result().Cookies() + start := make(chan struct{}) + results := make(chan int, 2) + for i := 0; i < 2; i++ { + go func() { + <-start + results <- req("POST", changePath, changeBody("password-123", resetSecret), cookieValue(cs, "dsh_csrf"), cs).Code + }() + } + close(start) + successes := 0 + for i := 0; i < 2; i++ { + code := <-results + if code == 200 { + successes++ + } else if code != 401 && code != 403 { + t.Fatal(code) + } + } + if successes != 1 { + t.Fatal("multiple changes", successes) + } + var n int + if err := a.s.DB.QueryRow("SELECT count(*) FROM audit WHERE action='user.change_password'").Scan(&n); err != nil || n != 1 { + t.Fatal(n, err) + } +} +func TestChangePasswordRechecksWaitingCredentials(t *testing.T) { + for _, revoke := range []string{"session", "password"} { + t.Run(revoke, func(t *testing.T) { + a, req := authFixture(t) + if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='alice'"); err != nil { + t.Fatal(err) + } + admin := req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil).Result().Cookies() + reader := &resetSignalReader{reader: strings.NewReader(changeBody("password-123", resetSecret)), started: make(chan struct{})} + r := httptest.NewRequest("POST", changePath, reader) + r.Header.Set("Origin", a.origin) + r.Header.Set("X-CSRF-Token", cookieValue(admin, "dsh_csrf")) + for _, c := range admin { + r.AddCookie(c) + } + newHash, err := bcrypt.GenerateFromPassword([]byte(resetSecret), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + if _, err = a.s.DB.Exec("UPDATE users SET pass_hash=? WHERE id='bob'", string(newHash)); err != nil { + t.Fatal(err) + } + a.authLoginMu.Lock() + locked := true + defer func() { + if locked { + a.authLoginMu.Unlock() + } + }() + done := make(chan *httptest.ResponseRecorder, 1) + go func() { w := httptest.NewRecorder(); a.Handler().ServeHTTP(w, r); done <- w }() + select { + case <-reader.started: + case <-time.After(5 * time.Second): + t.Fatal("request did not reach decoder") + } + select { + case w := <-done: + t.Fatal("reset bypassed login lock", w.Code) + case <-time.After(150 * time.Millisecond): + } + query := "UPDATE users SET pass_hash=(SELECT pass_hash FROM users WHERE id='bob') WHERE id='alice'" + want := 400 + if revoke == "session" { + query = "DELETE FROM sessions WHERE uid='alice'" + want = 401 + } + if _, err := a.s.DB.Exec(query); err != nil { + t.Fatal(err) + } + a.authLoginMu.Unlock() + locked = false + select { + case w := <-done: + if w.Code != want { + t.Fatal(w.Code, w.Body.String()) + } + case <-time.After(5 * time.Second): + t.Fatal("reset did not finish") + } + expected := resetSecret + if revoke == "session" { + expected = "password-123" + } + hash, err := a.s.Password("alice") + if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(expected)) != nil { + t.Fatal("unauthorized reset", err) + } + var n int + if err := a.s.DB.QueryRow("SELECT count(*) FROM audit WHERE action='user.change_password'").Scan(&n); err != nil || n != 0 { + t.Fatal(n, err) + } + }) + } +} +func changeBody(current, password string) string { + b, _ := json.Marshal(map[string]string{"current_password": current, "password": password}) + return string(b) +} +func TestChangePasswordValidation(t *testing.T) { + a, req := authFixture(t) + cs := req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil).Result().Cookies() + csrf := cookieValue(cs, "dsh_csrf") + valid := changeBody("password-123", resetSecret) + for _, tc := range []struct { + name, method, body, csrf string + cookies []*http.Cookie + code int + }{ + {"anonymous", "POST", valid, "", nil, 401}, + {"csrf", "POST", valid, "bad", cs, 403}, + {"method", "GET", "", csrf, cs, 405}, + {"short", "POST", changeBody("password-123", strings.Repeat("a", 11)), csrf, cs, 400}, + {"long", "POST", changeBody("password-123", strings.Repeat("a", 73)), csrf, cs, 400}, + {"unicode long", "POST", changeBody("password-123", strings.Repeat("密", 25)), csrf, cs, 400}, + {"missing current", "POST", changeBody("", resetSecret), csrf, cs, 400}, + {"malformed", "POST", "{", csrf, cs, 400}, + {"trailing", "POST", valid + "{}", csrf, cs, 400}, + {"oversized", "POST", changeBody("password-123", strings.Repeat("a", 4096)), csrf, cs, 400}, + {"target id", "POST", strings.TrimSuffix(valid, "}") + `,"id":"bob"}`, csrf, cs, 400}, + {"wrong current", "POST", changeBody("wrong", resetSecret), csrf, cs, 400}, + {"missing access", "POST", valid, csrf, []*http.Cookie{{Name: "dsh_session", Value: cookieValue(cs, "dsh_session")}}, 401}, + } { + t.Run(tc.name, func(t *testing.T) { + w := req(tc.method, changePath, tc.body, tc.csrf, tc.cookies) + if w.Code != tc.code { + t.Fatal(w.Code, w.Body.String()) + } + }) + } + for _, origin := range []string{"", "https://evil.test"} { + r := httptest.NewRequest("POST", changePath, strings.NewReader(valid)) + r.Header.Set("Origin", origin) + r.Header.Set("X-CSRF-Token", csrf) + for _, c := range cs { + r.AddCookie(c) + } + w := httptest.NewRecorder() + a.Handler().ServeHTTP(w, r) + if w.Code != 403 { + t.Fatal(w.Code) + } + } + if w := req("GET", "/api/me", "", "", cs); w.Code != 200 { + t.Fatal("validation revoked session", w.Code) + } + for _, user := range []string{"alice", "bob"} { + if w := req("POST", "/api/login", resetLogin(user, "password-123"), "", nil); w.Code != 200 { + t.Fatal("validation changed password", user, w.Code) + } + } +} + +func TestChangePasswordLifecycle(t *testing.T) { + for _, password := range []string{strings.Repeat("a", 12), strings.Repeat("密", 24)} { + t.Run(fmt.Sprint(len(password)), func(t *testing.T) { + a, req := authFixture(t) + login := func(user string) []*http.Cookie { + w := req("POST", "/api/login", resetLogin(user, "password-123"), "", nil) + if w.Code != 200 { + t.Fatal(w.Code) + } + return w.Result().Cookies() + } + cs, second, other := login("alice"), login("alice"), login("bob") + if _, err := a.s.DB.Exec("INSERT INTO auth_failures VALUES('alice',2,0)"); err != nil { + t.Fatal(err) + } + w := req("POST", changePath, changeBody("password-123", password), cookieValue(cs, "dsh_csrf"), cs) + var result map[string]bool + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &result) != nil || !result["ok"] || !result["reauthenticate"] || w.Header().Get("Cache-Control") != "no-store" { + t.Fatal(w.Code, w.Body.String()) + } + for _, old := range [][]*http.Cookie{cs, second} { + if w := req("GET", "/api/me", "", "", old); w.Code != 401 { + t.Fatal("old access", w.Code) + } + if w := req("POST", "/api/refresh", "", cookieValue(old, "dsh_csrf"), old); w.Code != 403 { + t.Fatal("old refresh", w.Code) + } + } + for query, want := range map[string]int{"SELECT count(*) FROM sessions WHERE uid='alice'": 0, "SELECT count(*) FROM auth_refresh": 1, "SELECT count(*) FROM auth_failures WHERE username='alice'": 0, "SELECT count(*) FROM audit WHERE action='user.change_password' AND uid='alice' AND target='alice' AND detail=''": 1} { + var n int + if err := a.s.DB.QueryRow(query).Scan(&n); err != nil || n != want { + t.Fatal(query, n, err) + } + } + if w := req("GET", "/api/me", "", "", other); w.Code != 200 { + t.Fatal("other session", w.Code) + } + if w := req("POST", "/api/refresh", "", cookieValue(other, "dsh_csrf"), other); w.Code != 200 { + t.Fatal("other refresh", w.Code) + } + if w := req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil); w.Code != 401 { + t.Fatal("old password", w.Code) + } + if w := req("POST", "/api/login", resetLogin("alice", password), "", nil); w.Code != 200 { + t.Fatal("new password", w.Code) + } + if w := req("POST", "/api/login", resetLogin("bob", "password-123"), "", nil); w.Code != 200 { + t.Fatal("other password", w.Code) + } + }) + } +} + +func TestChangePasswordConcurrentFailures(t *testing.T) { + a, req := authFixture(t) + cs := req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil).Result().Cookies() + results := make(chan int, 12) + start := make(chan struct{}) + for i := 0; i < 12; i++ { + go func() { + <-start + results <- req("POST", changePath, changeBody("wrong", resetSecret), cookieValue(cs, "dsh_csrf"), cs).Code + }() + } + close(start) + counts := map[int]int{} + for i := 0; i < 12; i++ { + counts[<-results]++ + } + if counts[400] != 5 || counts[429] != 7 { + t.Fatal(counts) + } + var n int + var until int64 + if err := a.s.DB.QueryRow("SELECT failures,locked_until FROM auth_failures WHERE username='alice'").Scan(&n, &until); err != nil || n != 5 || until <= time.Now().Unix() { + t.Fatal(n, until, err) + } + if w := req("POST", changePath, changeBody("password-123", resetSecret), cookieValue(cs, "dsh_csrf"), cs); w.Code != 429 { + t.Fatal("logged-in bypass", w.Code) + } + if w := req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil); w.Code != 429 { + t.Fatal("login bypass", w.Code) + } + if w := req("GET", "/api/me", "", "", cs); w.Code != 200 { + t.Fatal("wrong password logged out", w.Code) + } + if _, err := a.s.DB.Exec("UPDATE auth_failures SET locked_until=? WHERE username='alice'", time.Now().Add(-time.Second).Unix()); err != nil { + t.Fatal(err) + } + if w := req("POST", changePath, changeBody("password-123", resetSecret), cookieValue(cs, "dsh_csrf"), cs); w.Code != 200 { + t.Fatal("expired lock", w.Code) + } +} + +func TestChangePasswordRollback(t *testing.T) { + for _, step := range []struct{ table, event string }{{"users", "UPDATE"}, {"auth_refresh", "DELETE"}, {"sessions", "DELETE"}, {"auth_failures", "DELETE"}, {"audit", "INSERT"}} { + t.Run(step.table, func(t *testing.T) { + a, req := authFixture(t) + if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='alice'"); err != nil { + t.Fatal(err) + } + _ = req("POST", "/api/login", resetLogin("alice", "password-123"), "", nil).Result().Cookies() + target := req("POST", "/api/login", resetLogin("bob", "password-123"), "", nil).Result().Cookies() + before, err := a.s.Password("bob") + if err != nil { + t.Fatal(err) + } + if _, err := a.s.DB.Exec("INSERT INTO auth_failures VALUES('bob',2,0)"); err != nil { + t.Fatal(err) + } + if _, err := a.s.DB.Exec(fmt.Sprintf("CREATE TRIGGER reject_reset BEFORE %s ON %s BEGIN SELECT RAISE(ABORT,'forced failure'); END", step.event, step.table)); err != nil { + t.Fatal(err) + } + w := req("POST", changePath, changeBody("password-123", resetSecret), cookieValue(target, "dsh_csrf"), target) + if w.Code != 500 || strings.Contains(w.Body.String(), resetSecret) { + t.Fatal(w.Code, w.Body.String()) + } + after, err := a.s.Password("bob") + if err != nil || before != after { + t.Fatal("hash changed", err) + } + for query, want := range map[string]int{"SELECT count(*) FROM sessions WHERE uid='bob'": 1, "SELECT count(*) FROM auth_refresh": 2, "SELECT count(*) FROM auth_failures WHERE username='bob' AND failures=2 AND locked_until=0": 1, "SELECT count(*) FROM audit WHERE action='user.change_password'": 0} { + var n int + if err := a.s.DB.QueryRow(query).Scan(&n); err != nil || n != want { + t.Fatal(query, n, err) + } + } + if w := req("GET", "/api/me", "", "", target); w.Code != 200 { + t.Fatal("session changed", w.Code) + } + if w := req("POST", "/api/refresh", "", cookieValue(target, "dsh_csrf"), target); w.Code != 200 { + t.Fatal("refresh changed", w.Code) + } + }) + } +} + +func TestChangePasswordConcurrentLogin(t *testing.T) { + a, req := authFixture(t) + if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='alice'"); err != nil { + t.Fatal(err) + } + admin := req("POST", "/api/login", resetLogin("bob", "password-123"), "", nil).Result().Cookies() + start := make(chan struct{}) + results := make(chan *httptest.ResponseRecorder, 8) + for i := 0; i < 8; i++ { + go func() { <-start; results <- req("POST", "/api/login", resetLogin("bob", "password-123"), "", nil) }() + } + close(start) + w := req("POST", changePath, changeBody("password-123", resetSecret), cookieValue(admin, "dsh_csrf"), admin) + if w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + for i := 0; i < 8; i++ { + w := <-results + if w.Code != 200 && w.Code != 401 && w.Code != 429 { + t.Fatal(w.Code, w.Body.String()) + } + if w.Code == 200 { + if check := req("GET", "/api/me", "", "", w.Result().Cookies()); check.Code != 401 { + t.Fatal("old-password login retained session", check.Code) + } + } + } + var n int + if err := a.s.DB.QueryRow("SELECT count(*) FROM sessions WHERE uid='bob'").Scan(&n); err != nil || n != 0 { + t.Fatal("old login sessions", n, err) + } +} diff --git a/internal/api/paths.go b/internal/api/paths.go new file mode 100644 index 0000000..50ee2cf --- /dev/null +++ b/internal/api/paths.go @@ -0,0 +1,18 @@ +package api + +import ( + "errors" + "path/filepath" + "strings" +) + +func toHostPath(containerRoot, hostRoot, containerPath string) (string, error) { + if !filepath.IsAbs(containerRoot) || !filepath.IsAbs(hostRoot) || !filepath.IsAbs(containerPath) { + return "", errors.New("absolute storage paths required") + } + rel, err := filepath.Rel(containerRoot, containerPath) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", errors.New("storage path escapes root") + } + return filepath.Join(hostRoot, rel), nil +} diff --git a/internal/api/paths_test.go b/internal/api/paths_test.go new file mode 100644 index 0000000..89ee2fd --- /dev/null +++ b/internal/api/paths_test.go @@ -0,0 +1,15 @@ +package api + +import "testing" + +func TestToHostPath(t *testing.T) { + for _, tc := range []struct { + input, want string + bad bool + }{{"/data/workspaces/u/files", "/host/workspaces/u/files", false}, {"/data", "/host", false}, {"/data-other/x", "", true}, {"/data/../secret", "", true}, {"relative", "", true}} { + got, err := toHostPath("/data", "/host", tc.input) + if (err != nil) != tc.bad || got != tc.want { + t.Fatalf("%s: %q %v", tc.input, got, err) + } + } +} diff --git a/internal/api/profile_preview.go b/internal/api/profile_preview.go new file mode 100644 index 0000000..c433cac --- /dev/null +++ b/internal/api/profile_preview.go @@ -0,0 +1,226 @@ +package api + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "os" + "regexp" + "strings" + "syscall" + "time" + + "git.misaka.ren/m1saka/dsh-web/internal/store" + "gopkg.in/yaml.v3" +) + +const previewLimit = 1 << 20 + +var bundleReference = regexp.MustCompile("^(@[a-z0-9._-]+/)?[a-z0-9._-]+$") + +// The caller must authenticate and check instance ownership before dispatching. +func (a *API) profilePreview(w http.ResponseWriter, r *http.Request, i store.Instance) { + w.Header().Set("Cache-Control", "no-store") + if r.Method != http.MethodGet { + fail(w, 405, "method not allowed") + return + } + bundles, err := a.profileBundles(i) + if err != nil { + fail(w, 400, "bundle manifest unavailable") + return + } + result := map[string]any{"bundles": bundles, "config": "", "redacted": true} + if i.ContainerID == "" || i.Status != "running" { + result["preview_error"] = "启动实例后可预览已保存的配置。" + writeJSON(w, result) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + content, err := a.dumpProfile(ctx, i.ContainerID) + if err != nil { + result["preview_error"] = "配置预览不可用;命令输出和错误详情已隐藏。" + } else { + result["config"] = content + } + writeJSON(w, result) +} + +func (a *API) profileBundles(i store.Instance) (any, error) { + parent, err := upgradeRoot(a.root, i) + if err != nil { + return nil, err + } + defer parent.Close() + if err = configCheckPath(parent, "home/profiles/web/package.json"); err != nil { + return nil, err + } + root, err := configProfileRoot(parent, false) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + defer root.Close() + f, err := root.OpenFile("package.json", os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + defer f.Close() + st, err := f.Stat() + if err != nil || !st.Mode().IsRegular() || st.Size() > previewLimit { + return nil, errors.New("invalid manifest") + } + data, err := io.ReadAll(io.LimitReader(f, previewLimit+1)) + if err != nil || len(data) > previewLimit { + return nil, errors.New("invalid manifest") + } + var pkg map[string]json.RawMessage + if json.Unmarshal(data, &pkg) != nil { + return nil, errors.New("invalid manifest") + } + var profile any + raw := pkg["dsh.profile"] + if raw == nil { + var dsh map[string]json.RawMessage + if pkg["dsh"] != nil && json.Unmarshal(pkg["dsh"], &dsh) != nil { + return nil, errors.New("invalid manifest") + } + raw = dsh["profile"] + } + if raw == nil { + return nil, nil + } + if json.Unmarshal(raw, &profile) != nil { + return nil, errors.New("invalid manifest") + } + return redactPreview(profile, false), nil +} + +func (a *API) dumpProfile(ctx context.Context, container string) (string, error) { + var exec struct{ ID string } + err := a.docker.Do(ctx, "POST", "/containers/"+url.PathEscape(container)+"/exec", map[string]any{ + "Cmd": []string{"dsh", "--profile", "web", "--dump-config"}, "AttachStdout": true, "AttachStderr": true, "Tty": false, + }, &exec) + if err != nil { + return "", err + } + if exec.ID == "" { + return "", errors.New("missing exec ID") + } + path := "/exec/" + url.PathEscape(exec.ID) + body, _ := json.Marshal(map[string]bool{"Detach": false, "Tty": false}) + resp, err := a.docker.Stream(ctx, "POST", path+"/start", bytes.NewReader(body), "application/json") + if err != nil { + return "", err + } + defer resp.Body.Close() + data, err := previewStdout(resp.Body) + if err != nil { + return "", err + } + var state struct { + Running bool + ExitCode int + } + if err = a.docker.Do(ctx, "GET", path+"/json", nil, &state); err != nil { + return "", err + } + if state.Running || state.ExitCode != 0 { + return "", errors.New("preview command failed") + } + var document any + decoder := yaml.NewDecoder(bytes.NewReader(data)) + if err = decoder.Decode(&document); err != nil { + return "", errors.New("invalid preview") + } + var extra any + if decoder.Decode(&extra) != io.EOF { + return "", errors.New("multiple preview documents") + } + switch document.(type) { + case map[string]any, []any: + // DSH exports its composed plugin tree as a YAML sequence. + default: + return "", errors.New("invalid preview structure") + } + safe, err := yaml.Marshal(redactPreview(document, true)) + return string(safe), err +} + +func previewStdout(r io.Reader) ([]byte, error) { + var output bytes.Buffer + total := 0 + for { + var header [8]byte + _, err := io.ReadFull(r, header[:]) + if err == io.EOF { + return output.Bytes(), nil + } + if err != nil { + return nil, err + } + if (header[0] != 1 && header[0] != 2) || header[1] != 0 || header[2] != 0 || header[3] != 0 { + return nil, errors.New("invalid exec stream") + } + size := int64(binary.BigEndian.Uint32(header[4:])) + total += 8 + if size > int64(previewLimit-total) { + return nil, errors.New("preview too large") + } + total += int(size) + var dst io.Writer = io.Discard + if header[0] == 1 { + dst = &output + } + if _, err = io.CopyN(dst, r, size); err != nil { + return nil, err + } + } +} + +func redactPreview(value any, strict bool) any { + switch v := value.(type) { + case map[string]any: + out := make(map[string]any, len(v)) + for key, item := range v { + normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "", ".", "").Replace(key)) + sensitive := false + for _, word := range []string{"key", "secret", "token", "password", "passwd", "credential", "authorization", "cookie", "env", "header"} { + sensitive = sensitive || strings.Contains(normalized, word) + } + if sensitive { + out[key] = "[REDACTED]" + } else { + out[key] = redactPreview(item, strict) + } + } + return out + case []any: + out := make([]any, len(v)) + for j, item := range v { + out[j] = redactPreview(item, strict) + } + return out + case string: + // Plugins can resolve credentials into arbitrary string fields, not just apiKey. + if strict || !bundleReference.MatchString(v) { + return "[REDACTED]" + } + return v + case map[any]any: + return "[REDACTED]" + default: + return v + } +} diff --git a/internal/api/profile_preview_test.go b/internal/api/profile_preview_test.go new file mode 100644 index 0000000..ab569b3 --- /dev/null +++ b/internal/api/profile_preview_test.go @@ -0,0 +1,188 @@ +package api + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "git.misaka.ren/m1saka/dsh-web/internal/docker" + "git.misaka.ren/m1saka/dsh-web/internal/store" +) + +func previewFrame(stream byte, text string) []byte { + header := make([]byte, 8) + header[0] = stream + binary.BigEndian.PutUint32(header[4:], uint32(len(text))) + return append(header, []byte(text)...) +} + +func TestProfileDumpFixedCommandAndRedaction(t *testing.T) { + daemon := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1.47/containers/container/exec": + var request struct { + Cmd []string + AttachStdout, AttachStderr, Tty bool + } + if json.NewDecoder(r.Body).Decode(&request) != nil { + t.Error("invalid exec request") + } + if r.Method != "POST" || !reflect.DeepEqual(request.Cmd, []string{"dsh", "--profile", "web", "--dump-config"}) || !request.AttachStdout || !request.AttachStderr || request.Tty { + t.Errorf("unsafe command: %+v", request) + } + writeJSON(w, map[string]string{"Id": "exec"}) + case "/v1.47/exec/exec/start": + w.Write(previewFrame(2, "stderr-secret")) + w.Write(previewFrame(1, "provider:\n apiKey: direct-secret\n API_KEY: underscore-secret\n password: numeric-secret\n nested:\n - token: nested-secret\n arbitrary: resolved-credential\n endpoint: https://name:password@example.test/?token=secret\n enabled: true\n")) + case "/v1.47/exec/exec/json": + writeJSON(w, map[string]any{"Running": false, "ExitCode": 0}) + default: + t.Errorf("unexpected Docker call %s", r.URL.Path) + w.WriteHeader(404) + } + })) + defer daemon.Close() + a := &API{docker: docker.New(daemon.URL)} + out, err := a.dumpProfile(context.Background(), "container") + if err != nil { + t.Fatal(err) + } + for _, secret := range []string{"secret", "resolved-credential", "https://"} { + if strings.Contains(out, secret) { + t.Fatalf("secret leaked: %s", out) + } + } + if !strings.Contains(out, "[REDACTED]") || !strings.Contains(out, "enabled: true") { + t.Fatalf("missing structure: %s", out) + } +} + +func TestProfileDumpPluginSequence(t *testing.T) { + daemon := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/exec"): + writeJSON(w, map[string]string{"Id": "exec"}) + case strings.HasSuffix(r.URL.Path, "/start"): + w.Write(previewFrame(1, "# bundle\n- id: plugin\n disabled: !!js '!ctx.get(\"profileContext\")'\n config:\n apiKey: private-secret\n enabled: true\n")) + default: + writeJSON(w, map[string]any{"Running": false, "ExitCode": 0}) + } + })) + defer daemon.Close() + a := &API{docker: docker.New(daemon.URL)} + out, err := a.dumpProfile(context.Background(), "container") + if err != nil { + t.Fatal(err) + } + if strings.Contains(out, "private-secret") || strings.Contains(out, "ctx.get") || !strings.Contains(out, "enabled: true") || !strings.Contains(out, "[REDACTED]") { + t.Fatalf("invalid sequence redaction: %s", out) + } +} + +func TestProfileDumpRejectsUnsafeOutput(t *testing.T) { + for _, tc := range []struct { + name, output string + exit int + }{ + {"invalid", "apiKey: [secret", 0}, {"plain", "secret", 0}, + {"documents", "a: b\n---\napiKey: secret", 0}, {"failure", "a: b", 1}, + {"empty", "", 0}, + } { + t.Run(tc.name, func(t *testing.T) { + daemon := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/exec"): + writeJSON(w, map[string]string{"Id": "exec"}) + case strings.HasSuffix(r.URL.Path, "/start"): + w.Write(previewFrame(1, tc.output)) + default: + writeJSON(w, map[string]any{"Running": false, "ExitCode": tc.exit}) + } + })) + defer daemon.Close() + a := &API{docker: docker.New(daemon.URL)} + if out, err := a.dumpProfile(context.Background(), "container"); err == nil || out != "" { + t.Fatalf("unsafe output returned: %q, %v", out, err) + } + }) + } +} + +func TestPreviewStreamBounds(t *testing.T) { + for _, data := range [][]byte{ + []byte("raw secret"), {1, 0}, previewFrame(3, "secret"), + previewFrame(1, strings.Repeat("x", previewLimit)), + previewFrame(1, "secret")[:10], + } { + if _, err := previewStdout(bytes.NewReader(data)); err == nil { + t.Fatal("accepted invalid stream") + } + } + data := append(previewFrame(2, "secret"), previewFrame(1, "safe")...) + got, err := previewStdout(bytes.NewReader(data)) + if err != nil || string(got) != "safe" { + t.Fatalf("stdout: %q %v", got, err) + } +} + +func previewFixture(t *testing.T) (*API, store.Instance, string) { + t.Helper() + root := t.TempDir() + i := store.Instance{ID: "instance", UID: "owner", ContainerID: "container", Status: "stopped"} + path := filepath.Join(root, "workspaces", i.UID, "instances", i.ID, "home", "profiles", "web") + if err := os.MkdirAll(path, 0700); err != nil { + t.Fatal(err) + } + return &API{root: root}, i, path +} + +func TestProfilePreviewManifestAndErrors(t *testing.T) { + a, i, path := previewFixture(t) + if err := os.WriteFile(filepath.Join(path, "package.json"), []byte(`{"dsh":{"profile":{"bundles":["@deepseek/bundle"],"apiKey":"private-value"}},"scripts":{"secret":"ignored-value"}}`), 0600); err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + a.profilePreview(w, httptest.NewRequest("GET", "/preview?cmd=cat&path=.credentials.yaml", nil), i) + if w.Code != 200 || !strings.Contains(w.Body.String(), "@deepseek/bundle") || strings.Contains(w.Body.String(), "private-value") || strings.Contains(w.Body.String(), "ignored-value") { + t.Fatalf("manifest response: %d %s", w.Code, w.Body.String()) + } + if w.Header().Get("Cache-Control") != "no-store" { + t.Fatal("preview must not be cached") + } + daemon := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "daemon-private-value", 500) })) + defer daemon.Close() + a.docker = docker.New(daemon.URL) + i.Status = "running" + w = httptest.NewRecorder() + a.profilePreview(w, httptest.NewRequest("GET", "/preview", nil), i) + if strings.Contains(w.Body.String(), "daemon-private-value") || !strings.Contains(w.Body.String(), "preview_error") { + t.Fatalf("unsafe error: %s", w.Body.String()) + } + w = httptest.NewRecorder() + a.profilePreview(w, httptest.NewRequest("POST", "/preview", nil), i) + if w.Code != 405 { + t.Fatalf("method: %d", w.Code) + } +} + +func TestProfileBundlesRejectsCredentialSymlink(t *testing.T) { + a, i, path := previewFixture(t) + credentials := filepath.Join(path, "..", "..", ".credentials.yaml") + if err := os.WriteFile(credentials, []byte("private-value"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(credentials, filepath.Join(path, "package.json")); err != nil { + t.Fatal(err) + } + if _, err := a.profileBundles(i); err == nil { + t.Fatal("accepted credential symlink") + } +} diff --git a/internal/api/quota.go b/internal/api/quota.go new file mode 100644 index 0000000..0d783d1 --- /dev/null +++ b/internal/api/quota.go @@ -0,0 +1,63 @@ +package api + +import ( + "context" + "git.misaka.ren/m1saka/dsh-web/internal/store" + "log" + "time" +) + +// RunQuotaMonitor reports soft quota crossings; it never claims to constrain +// runtime writes and never deletes user data to recover capacity. +func (a *API) RunQuotaMonitor(ctx context.Context) { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + exceeded := map[string]bool{} + for { + a.checkQuotas(ctx, exceeded) + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + +func (a *API) checkQuotas(ctx context.Context, exceeded map[string]bool) { + rows, err := a.s.DB.QueryContext(ctx, "SELECT id,quota FROM users") + if err != nil { + return + } + var users []store.User + for rows.Next() { + var u store.User + if rows.Scan(&u.ID, &u.Quota) != nil { + rows.Close() + return + } + users = append(users, u) + } + err = rows.Err() + rows.Close() + if err != nil { + return + } + for _, u := range users { + if ctx.Err() != nil { + return + } + used, err := a.userUsage(u) + if err != nil { + log.Printf("quota usage unavailable for user %s", u.ID) + continue + } + over := used > u.Quota + if over && !exceeded[u.ID] { + a.s.AddAudit(u.ID, "quota.exceeded", u.ID, "soft quota exceeded; runtime writes remain unrestricted") + } + if !over && exceeded[u.ID] { + a.s.AddAudit(u.ID, "quota.recovered", u.ID, "") + } + exceeded[u.ID] = over + } +} diff --git a/internal/api/quota_test.go b/internal/api/quota_test.go new file mode 100644 index 0000000..235b02d --- /dev/null +++ b/internal/api/quota_test.go @@ -0,0 +1,31 @@ +package api + +import ( + "context" + "net/http" + "os" + "path/filepath" + "testing" +) + +func TestQuotaMonitorReportsTransitions(t *testing.T) { + a, _ := runtimeTestAPI(t, func(http.ResponseWriter, *http.Request) {}) + dir := filepath.Join(a.root, "workspaces", "owner", "files") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "test"), []byte("over"), 0600); err != nil { + t.Fatal(err) + } + state := map[string]bool{} + a.checkQuotas(context.Background(), state) + a.checkQuotas(context.Background(), state) + if _, err := a.s.DB.Exec("UPDATE users SET quota=100 WHERE id='owner'"); err != nil { + t.Fatal(err) + } + a.checkQuotas(context.Background(), state) + var count int + if err := a.s.DB.QueryRow("SELECT count(*) FROM audit WHERE action IN ('quota.exceeded','quota.recovered')").Scan(&count); err != nil || count != 2 { + t.Fatalf("transitions=%d error=%v", count, err) + } +} diff --git a/internal/api/quota_write.go b/internal/api/quota_write.go new file mode 100644 index 0000000..0b5ab4a --- /dev/null +++ b/internal/api/quota_write.go @@ -0,0 +1,63 @@ +package api + +import ( + "errors" + "git.misaka.ren/m1saka/dsh-web/internal/store" + "io/fs" + "os" + "strings" +) + +// Call with workspaceMu held through the write. Runtime writes remain soft-limited. +func (a *API) quotaGrowth(uid string, growth int64) error { + var u store.User + u.ID = uid + if err := a.s.DB.QueryRow("SELECT quota FROM users WHERE id=?", uid).Scan(&u.Quota); err != nil { + return err + } + used, err := a.userUsage(u) + if err != nil { + return err + } + if growth > 0 && (used > u.Quota || growth > u.Quota-used) { + return errors.New("quota exceeded") + } + return nil +} + +func quotaTreeBytes(root *os.Root, name string) (int64, error) { + var total int64 + entries := 0 + err := fs.WalkDir(root.FS(), name, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + entries++ + if entries > 100000 || strings.Count(p, "/") > 128 { + return errors.New("tree limit") + } + info, err := d.Info() + if err != nil { + return err + } + if info.Mode().IsRegular() { + if info.Size() > upgradeSnapshotLimit-total { + return errors.New("snapshot size limit") + } + total += info.Size() + } + return nil + }) + return total, err +} + +func (a *API) quotaSnapshot(uid string, root *os.Root, extra int64) (int64, error) { + size, err := quotaTreeBytes(root, "home") + if err != nil { + return 0, err + } + if err = a.quotaGrowth(uid, size+extra); err != nil { + return 0, err + } + return size, nil +} diff --git a/internal/api/quota_write_test.go b/internal/api/quota_write_test.go new file mode 100644 index 0000000..2257619 --- /dev/null +++ b/internal/api/quota_write_test.go @@ -0,0 +1,94 @@ +package api + +import ( + "os" + "path/filepath" + "sync" + "testing" +) + +func TestProfileQuotaReplacement(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("requires chown") + } + a, i, home := configTestAPI(t, false) + profile := filepath.Join(home, "profiles/web/cordis.patch.yml") + if err := os.MkdirAll(filepath.Dir(profile), 0700); err != nil { + t.Fatal(err) + } + configWrite(t, profile, "foo: old") + if _, err := a.s.DB.Exec("UPDATE users SET quota=8 WHERE id=?", i.UID); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + body string + code int + }{ + {`{"content":"foo: biggg"}`, 413}, + {`{"content":"foo: new"}`, 200}, + {`{"content":"foo: x"}`, 200}, + } { + w := configCall(a, i, "PUT", "profile", tc.body) + if w.Code != tc.code { + t.Fatalf("%d %s", w.Code, w.Body.String()) + } + } + data, err := os.ReadFile(profile) + if err != nil || string(data) != "foo: x" { + t.Fatalf("%q %v", data, err) + } +} + +func TestSnapshotQuotaIncludesResourcesAndExistingSnapshots(t *testing.T) { + a, i, home := configTestAPI(t, false) + configWrite(t, filepath.Join(home, "state"), "1234") + if err := os.MkdirAll(filepath.Join(filepath.Dir(home), "pre-restore-old"), 0700); err != nil { + t.Fatal(err) + } + configWrite(t, filepath.Join(filepath.Dir(home), "pre-restore-old/state"), "1234") + root, err := os.OpenRoot(filepath.Dir(home)) + if err != nil { + t.Fatal(err) + } + defer root.Close() + if _, err = a.s.DB.Exec("UPDATE users SET quota=15 WHERE id=?", i.UID); err != nil { + t.Fatal(err) + } + if _, err = a.quotaSnapshot(i.UID, root, 4); err == nil { + t.Fatal("resource bytes omitted") + } + if size, err := a.quotaSnapshot(i.UID, root, 3); err != nil || size != 4 { + t.Fatalf("size=%d err=%v", size, err) + } + if _, err := root.Lstat("pre-restore-old/state"); err != nil { + t.Fatal("existing snapshot changed") + } +} + +func TestQuotaSerializedWriters(t *testing.T) { + a, i, home := configTestAPI(t, false) + if _, err := a.s.DB.Exec("UPDATE users SET quota=4 WHERE id=?", i.UID); err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + accepted := 0 + for _, name := range []string{"one", "two"} { + wg.Add(1) + go func() { + defer wg.Done() + workspaceMu.Lock() + defer workspaceMu.Unlock() + if a.quotaGrowth(i.UID, 4) == nil { + if err := os.WriteFile(filepath.Join(home, name), []byte("1234"), 0600); err != nil { + t.Error(err) + return + } + accepted++ + } + }() + } + wg.Wait() + if accepted != 1 { + t.Fatalf("accepted %d writes", accepted) + } +} diff --git a/internal/api/reconcile.go b/internal/api/reconcile.go new file mode 100644 index 0000000..4bff259 --- /dev/null +++ b/internal/api/reconcile.go @@ -0,0 +1,128 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "os" +) + +// Reconcile checks persisted identities against Docker without deleting unknown +// containers. Orphans require explicit administrator review, never blind adoption. +func (a *API) Reconcile(ctx context.Context) error { + a.mu.Lock() + defer a.mu.Unlock() + filters, _ := json.Marshal(map[string][]string{"label": {"dsh-mgr.instance"}}) + var containers []struct { + ID string + State string + Labels map[string]string + } + if err := a.docker.Do(ctx, "GET", "/containers/json?all=1&filters="+url.QueryEscape(string(filters)), nil, &containers); err != nil { + return err + } + rows, err := a.s.DB.QueryContext(ctx, "SELECT id,uid,container_id,status FROM instances") + if err != nil { + return err + } + type record struct{ id, uid, container, status string } + var records []record + for rows.Next() { + var r record + if err = rows.Scan(&r.id, &r.uid, &r.container, &r.status); err != nil { + break + } + records = append(records, r) + } + if err == nil { + err = rows.Err() + } + rows.Close() + if err != nil { + return err + } + known := make(map[string]record) + for _, r := range records { + known[r.id] = r + } + matched := make(map[string]bool) + panel := os.Getenv("PANEL_CONTAINER_ID") + var networkErrors []error + for _, c := range containers { + id := c.Labels["dsh-mgr.instance"] + r, ok := known[id] + if !ok || r.container != c.ID || r.uid != c.Labels["dsh-mgr.owner"] { + a.s.AddAudit("", "reconcile.orphan", c.ID, "container identity requires administrator review") + continue + } + matched[id] = true + if panel != "" { + if networkErr := a.reconcilePanelNetwork(ctx, id, panel); networkErr != nil { + if _, err = a.s.DB.ExecContext(ctx, "UPDATE instances SET status='error' WHERE id=?", id); err != nil { + return err + } + a.s.AddAudit(r.uid, "reconcile.network_error", id, networkErr.Error()) + networkErrors = append(networkErrors, fmt.Errorf("instance %s: %w", id, networkErr)) + continue + } + } + if r.status != "error" { + if _, err = a.s.DB.ExecContext(ctx, "UPDATE instances SET status=? WHERE id=?", c.State, id); err != nil { + return err + } + } + } + for _, r := range records { + if r.container == "" || matched[r.id] || r.status == "error" { + continue + } + if _, err = a.s.DB.ExecContext(ctx, "UPDATE instances SET status='error' WHERE id=?", r.id); err != nil { + return err + } + a.s.AddAudit(r.uid, "reconcile.missing", r.id, "recorded container is absent or identity mismatch") + } + if ctx.Err() != nil { + return fmt.Errorf("reconcile: %w", ctx.Err()) + } + return errors.Join(networkErrors...) +} + +func (a *API) reconcilePanelNetwork(ctx context.Context, id, panel string) error { + name := runtimeNetwork(id) + var network struct { + ID, Name, Driver string + Labels map[string]string + } + if err := a.docker.Do(ctx, "GET", "/networks/"+url.PathEscape(name), nil, &network); err != nil { + return fmt.Errorf("inspect instance network: %w", err) + } + if network.ID == "" || network.Name != name || network.Driver != "bridge" || network.Labels["dsh-mgr.instance"] != id { + return fmt.Errorf("instance network identity mismatch") + } + container, err := a.docker.Inspect(ctx, panel) + if err != nil { + return fmt.Errorf("inspect panel: %w", err) + } + if container.ID == "" { + return fmt.Errorf("panel container identity missing") + } + for _, endpoint := range container.NetworkSettings.Networks { + if endpoint.NetworkID == network.ID { + return nil + } + } + if err := a.docker.Do(ctx, "POST", "/networks/"+url.PathEscape(network.ID)+"/connect", map[string]string{"Container": container.ID}, nil); err != nil { + // Another reconciliation may have connected the panel after inspection. + if current, inspectErr := a.docker.Inspect(ctx, container.ID); inspectErr == nil && current.ID == container.ID { + for _, endpoint := range current.NetworkSettings.Networks { + if endpoint.NetworkID == network.ID { + return nil + } + } + } + return fmt.Errorf("connect panel network: %w", err) + } + return nil +} diff --git a/internal/api/reconcile_test.go b/internal/api/reconcile_test.go new file mode 100644 index 0000000..c72a9fc --- /dev/null +++ b/internal/api/reconcile_test.go @@ -0,0 +1,141 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "git.misaka.ren/m1saka/dsh-web/internal/store" + "net/http" + "testing" + "time" +) + +func TestReconcileIdentityAndMissing(t *testing.T) { + t.Setenv("PANEL_CONTAINER_ID", "") + a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Fatalf("unexpected destructive request %s", r.Method) + } + w.Write([]byte(`[{"Id":"c1","State":"running","Labels":{"dsh-mgr.instance":"one","dsh-mgr.owner":"owner"}},{"Id":"c3","State":"running","Labels":{"dsh-mgr.instance":"orphan","dsh-mgr.owner":"owner"}}]`)) + }) + for _, i := range []store.Instance{{ID: "one", ContainerID: "c1", Status: "exited"}, {ID: "missing", ContainerID: "c2", Status: "running"}, {ID: "locked", ContainerID: "c4", Status: "error"}} { + i.UID = "owner" + i.Created = time.Now() + if err := a.s.AddInstance(i); err != nil { + t.Fatal(err) + } + } + if err := a.Reconcile(context.Background()); err != nil { + t.Fatal(err) + } + for id, want := range map[string]string{"one": "running", "missing": "error", "locked": "error"} { + i, err := a.s.GetInstance(id) + if err != nil || i.Status != want { + t.Fatalf("%s: %+v %v", id, i, err) + } + } + var count int + if err := a.s.DB.QueryRow("SELECT count(*) FROM audit WHERE action='reconcile.orphan'").Scan(&count); err != nil || count != 1 { + t.Fatalf("orphan audit: %d %v", count, err) + } +} + +func TestReconcilePanelNetwork(t *testing.T) { + for _, tc := range []struct { + name string + connected, race, connectFail, missing, foreign, wrongDriver, panelFail bool + wantConnect int + wantError bool + }{ + {name: "already connected", connected: true}, + {name: "reconnect", wantConnect: 1}, + {name: "concurrent connection", race: true, wantConnect: 1}, + {name: "connect failure", connectFail: true, wantConnect: 1, wantError: true}, + {name: "missing network", missing: true, wantError: true}, + {name: "foreign network", foreign: true, wantError: true}, + {name: "not bridge", wrongDriver: true, wantError: true}, + {name: "panel inspect failure", panelFail: true, wantError: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("PANEL_CONTAINER_ID", "configured-panel") + connects := 0 + connected := tc.connected + a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + switch r.Method + " " + r.URL.Path { + case "GET /v1.47/containers/json": + fmt.Fprint(w, `[{"Id":"c1","State":"running","Labels":{"dsh-mgr.instance":"one","dsh-mgr.owner":"owner"}},{"Id":"foreign","State":"running","Labels":{"dsh-mgr.instance":"foreign","dsh-mgr.owner":"other"}},{"Id":"unknown","State":"running","Labels":{"dsh-mgr.instance":"unknown","dsh-mgr.owner":"owner"}}]`) + case "GET /v1.47/networks/" + runtimeNetwork("one"): + if tc.missing { + http.Error(w, "network not found", http.StatusNotFound) + return + } + owner, driver := "one", "bridge" + if tc.foreign { + owner = "other" + } + if tc.wrongDriver { + driver = "host" + } + json.NewEncoder(w).Encode(map[string]any{"Id": "network-id", "Name": runtimeNetwork("one"), "Driver": driver, "Labels": map[string]string{"dsh-mgr.instance": owner}}) + case "GET /v1.47/containers/configured-panel/json", "GET /v1.47/containers/panel-full-id/json": + if tc.panelFail { + http.Error(w, "panel missing", http.StatusNotFound) + return + } + networkID := "unrelated-network" + if connected { + networkID = "network-id" + } + fmt.Fprintf(w, `{"Id":"panel-full-id","NetworkSettings":{"Networks":{"bridge":{"NetworkID":%q}}}}`, networkID) + case "POST /v1.47/networks/network-id/connect": + connects++ + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body) != 1 || body["Container"] != "panel-full-id" { + t.Errorf("unsafe connection body: %v, %v", body, err) + } + if tc.connectFail || tc.race { + connected = tc.race + http.Error(w, "connection failed", http.StatusForbidden) + return + } + connected = true + w.WriteHeader(http.StatusOK) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + http.Error(w, "unexpected request", http.StatusBadRequest) + } + }) + for _, i := range []store.Instance{ + {ID: "one", UID: "owner", ContainerID: "c1", Status: "exited", Created: time.Now()}, + {ID: "foreign", UID: "owner", ContainerID: "foreign", Status: "running", Created: time.Now()}, + } { + if err := a.s.AddInstance(i); err != nil { + t.Fatal(err) + } + } + if err := a.Reconcile(context.Background()); (err != nil) != tc.wantError { + t.Fatalf("Reconcile error = %v, want error %v", err, tc.wantError) + } + if !tc.wantError { + if err := a.Reconcile(context.Background()); err != nil { + t.Fatal(err) + } + } + if connects != tc.wantConnect { + t.Fatalf("connections = %d, want %d", connects, tc.wantConnect) + } + wantStatus, wantAudits := "running", 0 + if tc.wantError { + wantStatus, wantAudits = "error", 1 + } + i, err := a.s.GetInstance("one") + if err != nil || i.Status != wantStatus { + t.Fatalf("instance: %+v %v", i, err) + } + var audits int + if err := a.s.DB.QueryRow("SELECT count(*) FROM audit WHERE action='reconcile.network_error'").Scan(&audits); err != nil || audits != wantAudits { + t.Fatalf("network audits: %d %v", audits, err) + } + }) + } +} diff --git a/internal/api/response_buffer.go b/internal/api/response_buffer.go new file mode 100644 index 0000000..96bb8ac --- /dev/null +++ b/internal/api/response_buffer.go @@ -0,0 +1,39 @@ +package api + +import ( + "bytes" + "net/http" +) + +// Only use for bounded JSON responses, never file or runtime streams. Register +// flush before lock-release defers so a slow client cannot hold storage locks. +func deferJSONResponse(w http.ResponseWriter) (http.ResponseWriter, func()) { + b := &jsonResponseBuffer{ResponseWriter: w} + return b, func() { + if b.status != 0 { + w.WriteHeader(b.status) + } + if b.body.Len() > 0 { + _, _ = w.Write(b.body.Bytes()) + } + } +} + +type jsonResponseBuffer struct { + http.ResponseWriter + body bytes.Buffer + status int +} + +func (w *jsonResponseBuffer) WriteHeader(status int) { + if w.status == 0 { + w.status = status + } +} +func (w *jsonResponseBuffer) Write(p []byte) (int, error) { + if w.status == 0 { + w.status = http.StatusOK + } + return w.body.Write(p) +} +func (w *jsonResponseBuffer) Unwrap() http.ResponseWriter { return w.ResponseWriter } diff --git a/internal/api/restore_snapshot_test.go b/internal/api/restore_snapshot_test.go new file mode 100644 index 0000000..8ca1d69 --- /dev/null +++ b/internal/api/restore_snapshot_test.go @@ -0,0 +1,116 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRestoreSnapshotCopyFailureDoesNotConsumeRetention(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("requires chown") + } + a, i, home := configTestAPI(t, false) + configWrite(t, filepath.Join(home, ".credentials.yaml"), "archive") + w := configCall(a, i, "POST", "backups", `{"password":"original-password"}`) + var result struct{ ID string } + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &result) != nil { + t.Fatal(w.Code, w.Body.String()) + } + configWrite(t, filepath.Join(home, ".credentials.yaml"), "live") + base := filepath.Dir(home) + for _, name := range []string{"pre-restore-valuable", "failed-restore-valuable"} { + if err := os.Mkdir(filepath.Join(base, name), 0700); err != nil { + t.Fatal(err) + } + configWrite(t, filepath.Join(base, name, "keep"), name) + } + if err := os.Symlink("missing-target", filepath.Join(home, "dangling")); err != nil { + t.Fatal(err) + } + body := fmt.Sprintf(`{"backup_id":%q,"password":"original-password"}`, result.ID) + for attempt := 0; attempt < 3; attempt++ { + w = configCall(a, i, "POST", "restore", body) + if w.Code != 400 || !strings.Contains(w.Body.String(), "cannot snapshot home") { + t.Fatalf("attempt %d: %d %s", attempt, w.Code, w.Body.String()) + } + entries, err := os.ReadDir(base) + if err != nil || len(entries) != 3 { + t.Fatalf("partial snapshot leaked: %v %v", entries, err) + } + for _, name := range []string{"pre-restore-valuable", "failed-restore-valuable"} { + data, err := os.ReadFile(filepath.Join(base, name, "keep")) + if err != nil || string(data) != name { + t.Fatalf("valuable snapshot lost: %s %v", name, err) + } + } + data, err := os.ReadFile(filepath.Join(home, ".credentials.yaml")) + if err != nil || string(data) != "live" { + t.Fatalf("live home changed: %q %v", data, err) + } + } + if err := os.Remove(filepath.Join(home, "dangling")); err != nil { + t.Fatal(err) + } + if w = configCall(a, i, "POST", "restore", body); w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + entries, err := os.ReadDir(base) + if err != nil || len(entries) != 4 { + t.Fatalf("completed snapshot not retained: %v %v", entries, err) + } + if w = configCall(a, i, "POST", "restore", body); w.Code != 409 { + t.Fatal("retention not enforced", w.Code, w.Body.String()) + } +} + +func TestWorkspaceLeaseBlocksConfigAndUpgrade(t *testing.T) { + a, i, _ := configTestAPI(t, false) + a.workspaceReaders = map[string]int{i.ID: 1} + for _, action := range []string{"profile", "restore", "backups"} { + w := configCall(a, i, "POST", action, `{}`) + if w.Code != 409 || !strings.Contains(w.Body.String(), "instance files busy") { + t.Fatal(w.Code, w.Body.String()) + } + } + w := configCall(a, i, "GET", "backups", "") + if w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + w = httptest.NewRecorder() + body, _ := json.Marshal(map[string]string{"image_id": "sha256:" + strings.Repeat("a", 64), "password": "original-password"}) + r := httptest.NewRequest("POST", "/", strings.NewReader(string(body))) + a.upgrade(w, r, i) + if w.Code != 409 || !strings.Contains(w.Body.String(), "instance files busy") { + t.Fatal(w.Code, w.Body.String()) + } +} + +func TestUpgradeCopyMultipleDirectoryBatches(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("requires chown") + } + root, err := os.OpenRoot(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer root.Close() + if err = root.Mkdir("home", 0700); err != nil { + t.Fatal(err) + } + for n := 0; n < 257; n++ { + configWrite(t, filepath.Join(root.Name(), "home", fmt.Sprint(n)), "x") + } + if err = upgradeCopy(context.Background(), root, "home", "snapshot", 257); err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(filepath.Join(root.Name(), "snapshot")) + if err != nil || len(entries) != 257 { + t.Fatal(len(entries), err) + } +} diff --git a/internal/api/runtime.go b/internal/api/runtime.go new file mode 100644 index 0000000..0a9c4b4 --- /dev/null +++ b/internal/api/runtime.go @@ -0,0 +1,965 @@ +package api + +import ( + "bufio" + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "net/http/httputil" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "syscall" + "time" + + "git.misaka.ren/m1saka/dsh-web/internal/docker" + "git.misaka.ren/m1saka/dsh-web/internal/store" +) + +var runtimeImageID = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`) +var runtimeToken = regexp.MustCompile(`(?i)[?&]token=([^\s&#\x1b"'<>]+)`) +var runtimeStartupToken = regexp.MustCompile(`(?im)^(?:dsh web:\s*)?https?://[^\s]*[?&]token=([^\s&#\x1b"'<>]+)`) +var runtimeSecrets = regexp.MustCompile(`(?i)((?:token|api[_-]?key|authorization|password|secret|cookie)\s*[=:]\s*)[^\r\n]+`) + +func runtimeNetwork(id string) string { return "dsh-inst-" + id } + +func (a *API) instances(w http.ResponseWriter, r *http.Request) { + u, _, err := a.user(r) + if err != nil { + fail(w, 401, "authentication required") + return + } + if r.Method == http.MethodGet { + instances, err := a.s.Instances(u.ID) + if err != nil { + fail(w, 500, "database error") + return + } + if instances == nil { + instances = []store.Instance{} + } + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + for n := range instances { + if instances[n].ContainerID == "" { + continue + } + c, err := a.docker.Inspect(ctx, instances[n].ContainerID) + if err == nil { + if instances[n].Status != "error" { + instances[n].Status = c.State.Status + } + } else { + instances[n].Status = "unavailable" + } + } + writeJSON(w, instances) + return + } + if r.Method != http.MethodPost { + fail(w, 405, "method not allowed") + return + } + var in struct { + Name string `json:"name"` + ImageID string `json:"image_id"` + } + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)) + if decoder.Decode(&in) != nil || decoder.Decode(new(any)) != io.EOF || strings.TrimSpace(in.Name) == "" || len(in.Name) > 200 || !runtimeImageID.MatchString(in.ImageID) { + fail(w, 400, "invalid instance") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() + var image struct { + ID string `json:"Id"` + Config struct{ Labels map[string]string } + } + if err := a.docker.Do(ctx, "GET", "/images/"+url.PathEscape(in.ImageID)+"/json", nil, &image); err != nil { + fail(w, 400, "local runtime image required") + return + } + if image.ID != in.ImageID || image.Config.Labels["dsh-mgr.role"] != "runtime" || image.Config.Labels["dsh-mgr.version"] == "" { + fail(w, 400, "local runtime image required") + return + } + verified, err := a.verifiedImages(ctx) + if err != nil { + fail(w, 500, "database error") + return + } + if verified[image.ID] != image.Config.Labels["dsh-mgr.version"] { + fail(w, 400, "verified runtime image required") + return + } + i := store.Instance{ID: store.ID(), UID: u.ID, Name: strings.TrimSpace(in.Name), ImageID: image.ID, Version: image.Config.Labels["dsh-mgr.version"], Status: "creating", Created: time.Now()} + if err := a.s.EnsureIntents(); err != nil { + fail(w, 500, "database error") + return + } + if err := a.s.ReserveInstance(i); err != nil { + if errors.Is(err, store.ErrInstanceLimit) { + fail(w, 409, err.Error()) + return + } + fail(w, 500, "database error") + return + } + created, err := a.createRuntime(ctx, u, i, a.runtimeHost(r)) + if err != nil { + fail(w, 502, err.Error()) + return + } + writeJSON(w, created) +} + +func (a *API) createRuntime(ctx context.Context, u store.User, i store.Instance, trustedHost string) (store.Instance, error) { + a.mu.Lock() + defer a.mu.Unlock() + if _, err := a.s.GetInstance(i.ID); err != nil { + return i, errors.New("instance reservation unavailable") + } + networkCreated, complete := false, false + defer func() { + if complete { + return + } + cleanup, done := context.WithTimeout(context.Background(), 20*time.Second) + defer done() + if i.ContainerID != "" { + if err := a.removeRuntimeContainer(cleanup, i.ContainerID); err != nil { + a.s.SetInstance(i.ID, "error") + return + } + } + if networkCreated { + if err := a.removeRuntimeNetwork(cleanup, i.ID); err != nil { + a.s.SetInstance(i.ID, "error") + return + } + } + a.s.DeleteInstance(i.ID) + }() + root, err := filepath.Abs(a.root) + if err != nil { + return i, errors.New("storage unavailable") + } + suffix := filepath.Join("workspaces", u.ID) + for _, relative := range []string{filepath.Join(suffix, "files"), filepath.Join(suffix, "instances", i.ID, "home")} { + dir := filepath.Join(root, relative) + if err := os.MkdirAll(dir, 0750); err != nil { + return i, errors.New("storage unavailable") + } + if err := os.Chown(dir, 1000, 1000); err != nil { + return i, errors.New("storage ownership unavailable") + } + } + hostRoot := os.Getenv("HOST_DATA_ROOT") + if hostRoot == "" { + hostRoot = root + } + if !filepath.IsAbs(hostRoot) { + return i, errors.New("HOST_DATA_ROOT must be absolute") + } + homeSource, err := toHostPath(root, hostRoot, filepath.Join(root, suffix, "instances", i.ID, "home")) + if err != nil { + return i, err + } + network := runtimeNetwork(i.ID) + if err := a.docker.Do(ctx, "POST", "/networks/create", map[string]any{"Name": network, "Driver": "bridge", "CheckDuplicate": true, "Labels": map[string]string{"dsh-mgr.instance": i.ID}}, nil); err != nil { + return i, errors.New("network creation failed") + } + networkCreated = true + if panel := os.Getenv("PANEL_CONTAINER_ID"); panel != "" { + if err := a.docker.Do(ctx, "POST", "/networks/"+network+"/connect", map[string]string{"Container": panel}, nil); err != nil { + return i, errors.New("panel network connection failed") + } + } + host := trustedHost + body := map[string]any{ + "Image": i.ImageID, "User": "1000:1000", "WorkingDir": "/workspace", + "Env": []string{"DSH_HOME=/workspace", "HOME=/workspace", "XDG_DATA_HOME=/workspace/.local/share", "XDG_CONFIG_HOME=/workspace/.config"}, + "Cmd": []string{"web", "--no-open", "--port", "3080", "--trusted-host", host}, + "Labels": map[string]string{"dsh-mgr.instance": i.ID, "dsh-mgr.owner": u.ID, "dsh-mgr.role": "dsh", "dsh-mgr.version": i.Version}, + "HostConfig": map[string]any{ + "NetworkMode": network, "ReadonlyRootfs": true, "CapDrop": []string{"ALL"}, "SecurityOpt": []string{"no-new-privileges"}, + "Memory": int64(2 << 30), "NanoCpus": int64(2e9), "PidsLimit": 256, + "Tmpfs": map[string]string{"/tmp": "rw,exec,nosuid,nodev,size=256m"}, + "LogConfig": map[string]any{"Type": "json-file", "Config": map[string]string{"max-size": "10m", "max-file": "3"}}, + "Mounts": []map[string]any{ + {"Type": "bind", "Source": homeSource, "Target": "/workspace"}, + // DSH_HOME is the same bind mount as the visible workspace. + }, + }, + } + var created struct { + ID string `json:"Id"` + } + if err := a.docker.Do(ctx, "POST", "/containers/create?name="+network, body, &created); err != nil || created.ID == "" { + return i, errors.New("container creation failed") + } + i.ContainerID = created.ID + if _, err := a.s.DB.ExecContext(ctx, "UPDATE instances SET container_id=? WHERE id=?", i.ContainerID, i.ID); err != nil { + return i, errors.New("database error") + } + if err := a.docker.Do(ctx, "POST", "/containers/"+url.PathEscape(i.ContainerID)+"/start", nil, nil); err != nil { + return i, errors.New("container start failed") + } + i.Status = "running" + if _, err := a.s.DB.ExecContext(ctx, "UPDATE instances SET status=? WHERE id=?", i.Status, i.ID); err != nil { + return i, errors.New("database error") + } + complete = true + a.s.AddAudit(u.ID, "instance.create", i.ID, i.ImageID) + return i, nil +} + +func dockerNotFound(err error) bool { + var e *docker.APIError + return errors.As(err, &e) && e.StatusCode == 404 +} +func (a *API) removeRuntimeContainer(ctx context.Context, id string) error { + err := a.docker.Do(ctx, "DELETE", "/containers/"+url.PathEscape(id)+"?force=1", nil, nil) + if dockerNotFound(err) { + return nil + } + return err +} +func (a *API) removeRuntimeNetwork(ctx context.Context, id string) error { + path := "/networks/" + runtimeNetwork(id) + if panel := os.Getenv("PANEL_CONTAINER_ID"); panel != "" { + err := a.docker.Do(ctx, "POST", path+"/disconnect", map[string]any{"Container": panel, "Force": true}, nil) + if err != nil && !dockerNotFound(err) { + return err + } + } + err := a.docker.Do(ctx, "DELETE", path, nil, nil) + if dockerNotFound(err) { + return nil + } + return err +} + +func (a *API) instance(w http.ResponseWriter, r *http.Request) { + u, _, err := a.user(r) + if err != nil { + fail(w, 401, "authentication required") + return + } + p := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/instances/"), "/") + if len(p) > 2 || p[0] == "" { + fail(w, 404, "not found") + return + } + i, err := a.s.GetInstance(p[0]) + if err != nil || i.UID != u.ID { + fail(w, 404, "not found") + return + } + action := "" + if len(p) == 2 { + action = p[1] + } + switch action { + case "profile-preview": + a.profilePreview(w, r, i) + return + case "profile", "backups", "restore": + a.config(w, r, i, action) + return + case "upgrade": + a.upgrade(w, r, i) + return + } + w, flush := deferJSONResponse(w) + defer flush() + ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second) + defer cancel() + if action == "logs" { + if r.Method != "GET" { + fail(w, 405, "method not allowed") + return + } + logs, err := a.docker.Logs(ctx, i.ContainerID) + if err != nil { + fail(w, 502, "logs unavailable") + return + } + writeJSON(w, map[string]string{"logs": redactRuntimeLogs(logs)}) + return + } + if action == "" && r.Method == "GET" { + c, err := a.docker.Inspect(ctx, i.ContainerID) + if err != nil { + fail(w, 502, "container unavailable") + return + } + if i.Status != "error" { + i.Status = c.State.Status + } + writeJSON(w, i) + return + } + if action == "" && r.Method == "DELETE" { + a.mu.Lock() + defer a.mu.Unlock() + i, err = a.s.GetInstance(p[0]) + if err != nil || i.UID != u.ID { + fail(w, 404, "not found") + return + } + if a.upgradeRecoveryRequired(i) { + fail(w, 409, "instance requires recovery") + return + } + if a.workspaceReaders[i.ID] > 0 { + fail(w, 409, "instance files busy; retry later") + return + } + if i.ContainerID != "" { + if err := a.removeRuntimeContainer(ctx, i.ContainerID); err != nil { + fail(w, 502, "container deletion failed") + return + } + } + if err := a.removeRuntimeNetwork(ctx, i.ID); err != nil { + fail(w, 502, "network deletion failed") + return + } + if _, err := a.s.DB.ExecContext(ctx, "DELETE FROM instances WHERE id=?", i.ID); err != nil { + fail(w, 500, "database error") + return + } + a.s.AddAudit(u.ID, "instance.delete", i.ID, "data retained") + runtimeAuthCache.Delete(runtimeAuthKey{a, i.ID}) + writeJSON(w, map[string]bool{"ok": true}) + return + } + if action != "start" && action != "stop" && action != "restart" { + fail(w, 404, "not found") + return + } + if r.Method != "POST" { + fail(w, 405, "method not allowed") + return + } + a.mu.Lock() + defer a.mu.Unlock() + i, err = a.s.GetInstance(p[0]) + if err != nil || i.UID != u.ID { + fail(w, 404, "not found") + return + } + if action != "stop" && (i.Status == "error" || a.upgradeRecoveryRequired(i)) { + fail(w, 409, "instance requires recovery") + return + } + if a.workspaceReaders[i.ID] > 0 { + fail(w, 409, "instance files busy; retry later") + return + } + if err := a.docker.Do(ctx, "POST", "/containers/"+url.PathEscape(i.ContainerID)+"/"+action+"?t=10", nil, nil); err != nil { + fail(w, 502, "container action failed") + return + } + c, err := a.docker.Inspect(ctx, i.ContainerID) + if err != nil { + fail(w, 502, "container inspection failed") + return + } + if i.Status != "error" { + i.Status = c.State.Status + } + if _, err := a.s.DB.ExecContext(ctx, "UPDATE instances SET status=? WHERE id=?", i.Status, i.ID); err != nil { + fail(w, 500, "database error") + return + } + a.s.AddAudit(u.ID, "instance."+action, i.ID, i.Status) + writeJSON(w, i) +} + +func redactRuntimeLogs(s string) string { + s = runtimeToken.ReplaceAllString(s, "?token=[REDACTED]") + return runtimeSecrets.ReplaceAllString(s, "${1}[REDACTED]") +} +func latestRuntimeToken(logs string) string { + matches := runtimeStartupToken.FindAllStringSubmatch(logs, -1) + if len(matches) == 0 { + return "" + } + token, err := url.QueryUnescape(matches[len(matches)-1][1]) + if err != nil { + return "" + } + return token +} +func (a *API) runtimeHost(r *http.Request) string { + if u, err := url.Parse(a.origin); err == nil && u.Host != "" { + return u.Host + } + return r.Host +} +func (a *API) runtimeOriginAllowed(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return (r.Method == "GET" || r.Method == "HEAD") && !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") + } + expected := strings.TrimRight(a.origin, "/") + if expected == "" { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + expected = scheme + "://" + r.Host + } + return origin == expected +} + +var runtimeTransport = &http.Transport{Proxy: nil, DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext, ResponseHeaderTimeout: 20 * time.Second, IdleConnTimeout: 90 * time.Second} + +type runtimeAuthKey struct { + api *API + id string +} +type runtimeAuth struct { + mu sync.Mutex + entries map[string]runtimeAuthEntry +} + +type runtimeAuthEntry struct { + Container string + Started string + Token string + Host string + Cookies string +} + +type runtimeAuthDisk struct { + Nonce string `json:"nonce"` + Data string `json:"data"` +} + +var runtimeAuthCache sync.Map + +func (a *API) runtimeCookie(ctx context.Context, i store.Instance, started string, target *url.URL, host string) (string, error) { + if a.authErr != nil || len(a.authKey) != 32 { + return "", errors.New("runtime authentication unavailable") + } + value, _ := runtimeAuthCache.LoadOrStore(runtimeAuthKey{a, i.ID}, &runtimeAuth{}) + auth := value.(*runtimeAuth) + auth.mu.Lock() + defer auth.mu.Unlock() + if auth.entries == nil { + auth.entries = make(map[string]runtimeAuthEntry) + } + baseKey := i.ContainerID + "/" + started + key := baseKey + "/" + host + if entry, ok := auth.entries[key]; ok && entry.Cookies != "" { + return entry.Cookies, nil + } + if entry, ok := a.loadRuntimeAuth(i.ID, baseKey); ok { + if entry.Host == host && entry.Cookies != "" { + auth.entries[key] = entry + return entry.Cookies, nil + } + cookies, err := exchangeRuntimeCookie(ctx, target, host, entry.Token) + if err != nil { + return "", err + } + entry.Host, entry.Cookies = host, cookies + if err := a.saveRuntimeAuth(i.ID, baseKey, entry); err != nil { + return "", errors.New("runtime authentication unavailable") + } + auth.entries[key] = entry + return cookies, nil + } + startedTime, err := time.Parse(time.RFC3339Nano, started) + if err != nil { + return "", errors.New("invalid start time") + } + since := fmt.Sprintf("%d.%09d", startedTime.Unix(), startedTime.Nanosecond()) + query := url.Values{"stdout": {"1"}, "stderr": {"1"}, "follow": {"0"}, "since": {since}} + resp, err := a.docker.Stream(ctx, "GET", "/containers/"+url.PathEscape(i.ContainerID)+"/logs?"+query.Encode(), nil, "") + if err != nil { + return "", errors.New("logs unavailable") + } + defer resp.Body.Close() + token, err := runtimeTokenFromStream(resp.Body) + if err != nil { + return "", errors.New("logs unavailable") + } + if token == "" { + return "", errors.New("token unavailable") + } + cookies, err := exchangeRuntimeCookie(ctx, target, host, token) + if err != nil { + return "", err + } + entry := runtimeAuthEntry{Container: i.ContainerID, Started: started, Token: token, Host: host, Cookies: cookies} + if err := a.saveRuntimeAuth(i.ID, baseKey, entry); err != nil { + return "", errors.New("runtime authentication unavailable") + } + auth.entries[key] = entry + return cookies, nil +} + +func (a *API) runtimeAuthKey(id string) []byte { + h := sha256.New() + h.Write(a.authKey) + h.Write([]byte("runtime-auth/" + id)) + return h.Sum(nil) +} + +func (a *API) runtimeAuthPath(id, key string) string { + h := sha256.Sum256([]byte(key)) + return filepath.Join(a.root, "runtime-auth", id, hex.EncodeToString(h[:])+".json") +} + +func (a *API) loadRuntimeAuth(id, key string) (runtimeAuthEntry, bool) { + var zero runtimeAuthEntry + if len(a.authKey) != 32 { + return zero, false + } + f, err := os.OpenFile(a.runtimeAuthPath(id, key), os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) + if err != nil { + return zero, false + } + defer f.Close() + st, err := f.Stat() + if err != nil || !st.Mode().IsRegular() || st.Mode().Perm() != 0600 || st.Size() > 64<<10 { + return zero, false + } + b, err := io.ReadAll(io.LimitReader(f, (64<<10)+1)) + if err != nil || len(b) > 64<<10 { + return zero, false + } + var disk runtimeAuthDisk + if json.Unmarshal(b, &disk) != nil { + return zero, false + } + nonce, err := base64.RawStdEncoding.DecodeString(disk.Nonce) + if err != nil { + return zero, false + } + ciphertext, err := base64.RawStdEncoding.DecodeString(disk.Data) + if err != nil { + return zero, false + } + bl, err := aes.NewCipher(a.runtimeAuthKey(id)) + if err != nil { + return zero, false + } + gcm, err := cipher.NewGCM(bl) + if err != nil || len(nonce) != gcm.NonceSize() { + return zero, false + } + plain, err := gcm.Open(nil, nonce, ciphertext, []byte(key)) + if err != nil { + return zero, false + } + var entry runtimeAuthEntry + if json.Unmarshal(plain, &entry) != nil || entry.Container == "" || entry.Started == "" || entry.Token == "" || entry.Host == "" || entry.Container+"/"+entry.Started != key { + return zero, false + } + return entry, true +} + +func (a *API) saveRuntimeAuth(id, key string, entry runtimeAuthEntry) error { + if len(a.authKey) != 32 { + return errors.New("invalid authentication key") + } + bl, err := aes.NewCipher(a.runtimeAuthKey(id)) + if err != nil { + return err + } + gcm, err := cipher.NewGCM(bl) + if err != nil { + return err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err = rand.Read(nonce); err != nil { + return err + } + plain, err := json.Marshal(entry) + if err != nil { + return err + } + disk, err := json.Marshal(runtimeAuthDisk{base64.RawStdEncoding.EncodeToString(nonce), base64.RawStdEncoding.EncodeToString(gcm.Seal(nil, nonce, plain, []byte(key)))}) + if err != nil { + return err + } + dir := filepath.Dir(a.runtimeAuthPath(id, key)) + if err = os.MkdirAll(dir, 0700); err != nil { + return err + } + if err = os.Chmod(dir, 0700); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, ".credential-*") + if err != nil { + return err + } + name := tmp.Name() + defer os.Remove(name) + if err = tmp.Chmod(0600); err == nil { + _, err = tmp.Write(disk) + } + if err == nil { + err = tmp.Sync() + } + closeErr := tmp.Close() + if err != nil { + return err + } + if closeErr != nil { + return closeErr + } + if err = os.Rename(name, a.runtimeAuthPath(id, key)); err != nil { + return err + } + d, err := os.Open(dir) + if err != nil { + return err + } + defer d.Close() + return d.Sync() +} + +// Bound total work while allowing all retained runtime logs (3 x 10 MiB). +const runtimeLogLimit int64 = 64 << 20 + +func runtimeTokenFromStream(r io.Reader) (string, error) { + br := bufio.NewReader(io.LimitReader(r, runtimeLogLimit+1)) + first, err := br.Peek(8) + if err != nil && err != io.EOF { + return "", err + } + framed := len(first) == 8 && first[0] <= 2 && first[1] == 0 && first[2] == 0 && first[3] == 0 + var used int64 + tail, token := "", "" + scan := func(p []byte) { + text := tail + string(p) + for { + at := strings.IndexByte(text, '\n') + if at < 0 { + break + } + if found := latestRuntimeToken(text[:at]); found != "" { + token = found + } + text = text[at+1:] + } + if len(text) > 8192 { + text = text[len(text)-8192:] + } + tail = text + } + buf := make([]byte, 32<<10) + for { + var source io.Reader = br + var frameLength int64 + if framed { + var header [8]byte + n, e := io.ReadFull(br, header[:]) + used += int64(n) + if e == io.EOF { + break + } + if e != nil { + return "", e + } + if header[0] > 2 || header[1] != 0 || header[2] != 0 || header[3] != 0 { + return "", errors.New("invalid logs") + } + frameLength = int64(binary.BigEndian.Uint32(header[4:])) + if frameLength > runtimeLogLimit-used { + return "", errors.New("log scan limit") + } + source = io.LimitReader(br, frameLength) + } + var read int64 + for { + n, e := source.Read(buf) + used += int64(n) + read += int64(n) + if used > runtimeLogLimit { + return "", errors.New("log scan limit") + } + if n > 0 { + scan(buf[:n]) + } + if e == io.EOF { + break + } + if e != nil { + return "", e + } + } + if !framed { + break + } + if read != frameLength { + return "", io.ErrUnexpectedEOF + } + } + if found := latestRuntimeToken(tail); found != "" { + token = found + } + return token, nil +} +func (a *API) proxy(w http.ResponseWriter, r *http.Request) { + u, _, err := a.user(r) + if err != nil { + destination := r.Header.Get("Sec-Fetch-Dest") + if r.Method == http.MethodGet && r.Header.Get("Sec-Fetch-Mode") == "navigate" && (destination == "document" || destination == "iframe") && r.Header.Get("Upgrade") == "" && a.runtimeOriginAllowed(r) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Referrer-Policy", "no-referrer") + target := r.URL.EscapedPath() + if query := withoutRuntimeToken(r.URL.RawQuery); query != "" { + target += "?" + query + } + http.Redirect(w, r, "/#runtime-renew?return="+url.QueryEscape(target), http.StatusSeeOther) + return + } + fail(w, 401, "authentication required") + return + } + a.mu.Lock() + locked := true + defer func() { + if locked { + a.mu.Unlock() + } + }() + p := strings.SplitN(strings.TrimPrefix(r.URL.Path, "/dsh/"), "/", 2) + i, err := a.s.GetInstance(p[0]) + if err != nil || i.UID != u.ID { + fail(w, 404, "not found") + return + } + if i.Status == "error" || a.upgradeRecoveryRequired(i) { + fail(w, 409, "instance requires recovery") + return + } + if !a.runtimeOriginAllowed(r) { + fail(w, 403, "origin denied") + return + } + prefix := "/dsh/" + i.ID + if len(p) == 1 { + http.Redirect(w, r, prefix+"/", http.StatusTemporaryRedirect) + return + } + host := a.runtimeHost(r) + bridge := runtimeFreeSearchPath(strings.TrimPrefix(r.URL.Path, prefix)) + if bridge { + if r.Method != http.MethodPost { + fail(w, 405, "method not allowed") + return + } + if r.Header.Get("Sec-Fetch-Site") == "cross-site" { + fail(w, 403, "origin denied") + return + } + host = "localhost" + } + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + var c struct { + State struct { + Running bool + StartedAt string + } + NetworkSettings struct { + Networks map[string]struct{ IPAddress string } + } + } + err = a.docker.Do(ctx, "GET", "/containers/"+url.PathEscape(i.ContainerID)+"/json", nil, &c) + if err != nil || !c.State.Running || c.State.StartedAt == "" { + fail(w, 502, "runtime unavailable") + return + } + ip := c.NetworkSettings.Networks[runtimeNetwork(i.ID)].IPAddress + if net.ParseIP(ip) == nil { + fail(w, 502, "runtime network unavailable") + return + } + target := &url.URL{Scheme: "http", Host: net.JoinHostPort(ip, "8080")} + cookies, err := a.runtimeCookie(ctx, i, c.State.StartedAt, target, host) + if err != nil { + fail(w, 502, "runtime authentication unavailable") + return + } + cancel() + a.mu.Unlock() + locked = false + proxy := runtimeReverseProxy(target, prefix, host, cookies) + a.invalidateRejectedRuntimeCookie(proxy, i, c.State.StartedAt, host, cookies) + if bridge { + // Only after panel authentication, ownership and Origin checks may the + // loopback-only plugin receive a matching local Origin through Caddy. + rewrite := proxy.Rewrite + proxy.Rewrite = func(p *httputil.ProxyRequest) { + rewrite(p) + p.Out.Header.Set("Origin", "http://localhost") + } + } + proxy.ServeHTTP(w, r) +} + +// Do not replay mutations after an authentication failure. The next request +// exchanges a new cookie using the retained token, even after logs rotate. +func (a *API) invalidateRejectedRuntimeCookie(proxy *httputil.ReverseProxy, i store.Instance, started, host, cookies string) { + modify := proxy.ModifyResponse + proxy.ModifyResponse = func(resp *http.Response) error { + if resp.StatusCode == 401 || resp.StatusCode == 403 { + value, ok := runtimeAuthCache.Load(runtimeAuthKey{a, i.ID}) + if ok { + auth := value.(*runtimeAuth) + auth.mu.Lock() + baseKey := i.ContainerID + "/" + started + key := baseKey + "/" + host + if entry, exists := auth.entries[key]; exists && entry.Cookies == cookies { + entry.Cookies = "" + delete(auth.entries, key) + if err := a.saveRuntimeAuth(i.ID, baseKey, entry); err != nil { + auth.mu.Unlock() + return errors.New("runtime authentication unavailable") + } + } + auth.mu.Unlock() + } + } + return modify(resp) + } +} + +func runtimeFreeSearchPath(path string) bool { + switch path { + case "/api/dsh-free-search-settings/describe", "/api/dsh-free-search-settings/mutate", + "/api/dsh-free-search-settings/credentials-status", "/api/dsh-free-search-settings/credentials-set", + "/api/dsh-free-search-settings/credentials-unset", "/api/dsh-free-search-settings/check-update", + "/api/dsh-free-search-settings/update", "/api/dsh-free-search-settings/raw-search": + return true + } + return false +} + +func exchangeRuntimeCookie(ctx context.Context, target *url.URL, host, token string) (string, error) { + if target == nil { + return "", errors.New("exchange target unavailable") + } + endpoint := *target + endpoint.Path = "/" + endpoint.RawQuery = url.Values{"token": {token}}.Encode() + req, err := http.NewRequestWithContext(ctx, "GET", endpoint.String(), nil) + if err != nil { + return "", err + } + req.Host = host + client := &http.Client{Transport: runtimeTransport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + resp, err := client.Do(req) + if err != nil { + return "", errors.New("exchange failed") + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusSeeOther { + return "", errors.New("exchange rejected") + } + var cookies []string + for _, cookie := range resp.Cookies() { + if cookie.Value != "" && cookie.MaxAge >= 0 { + cookies = append(cookies, (&http.Cookie{Name: cookie.Name, Value: cookie.Value}).String()) + } + } + if len(cookies) == 0 { + return "", errors.New("exchange missing cookie") + } + return strings.Join(cookies, "; "), nil +} + +func withoutRuntimeToken(rawQuery string) string { + parts := strings.Split(rawQuery, "&") + kept := parts[:0] + for _, part := range parts { + key, _, _ := strings.Cut(part, "=") + decoded, err := url.QueryUnescape(key) + if err == nil && decoded == "token" { + continue + } + kept = append(kept, part) + } + return strings.Join(kept, "&") +} + +func runtimeReverseProxy(target *url.URL, prefix, host, cookies string) *httputil.ReverseProxy { + return &httputil.ReverseProxy{ + Transport: runtimeTransport, FlushInterval: -1, ErrorLog: log.New(io.Discard, "", 0), + Rewrite: func(p *httputil.ProxyRequest) { + p.SetURL(target) + p.Out.Host = host + p.Out.URL.Path = strings.TrimPrefix(p.In.URL.Path, prefix) + p.Out.URL.RawPath = "" + // DSH combo assets use an opaque ?module1,module2 query prefix. + p.Out.URL.RawQuery = withoutRuntimeToken(p.In.URL.RawQuery) + p.Out.Header.Del("Cookie") + p.Out.Header.Del("Authorization") + p.Out.Header.Del("Proxy-Authorization") + p.Out.Header.Del("X-CSRF-Token") + p.Out.Header.Set("Accept-Encoding", "identity") + p.Out.Header.Set("Cookie", cookies) + p.SetXForwarded() + p.Out.Header.Set("X-Forwarded-Prefix", prefix) + }, + ModifyResponse: func(resp *http.Response) error { + resp.Header.Del("Set-Cookie") + if resp.StatusCode == http.StatusOK && strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html") && resp.Header.Get("Content-Encoding") == "" { + data, err := io.ReadAll(io.LimitReader(resp.Body, (8<<20)+1)) + resp.Body.Close() + if err != nil || len(data) > 8<<20 { + return fmt.Errorf("runtime document unavailable") + } + data = bytes.Replace(data, []byte(""), []byte(``), 1) + resp.Body = io.NopCloser(bytes.NewReader(data)) + resp.ContentLength = int64(len(data)) + resp.Header.Set("Content-Length", fmt.Sprint(len(data))) + resp.Header.Del("ETag") + resp.Header.Set("Cache-Control", "no-store") + } + if location := resp.Header.Get("Location"); location != "" { + u, err := url.Parse(location) + if err != nil { + return fmt.Errorf("invalid redirect") + } + u.RawQuery = withoutRuntimeToken(u.RawQuery) + if u.Host == target.Host || u.Host == host { + u.Scheme = "" + u.Host = "" + } + if u.Host == "" && strings.HasPrefix(u.Path, "/") { + u.Path = prefix + u.Path + u.RawPath = "" + } + resp.Header.Set("Location", u.String()) + } + return nil + }, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { fail(w, 502, "runtime proxy unavailable") }, + } +} diff --git a/internal/api/runtime_auth_test.go b/internal/api/runtime_auth_test.go new file mode 100644 index 0000000..bc55967 --- /dev/null +++ b/internal/api/runtime_auth_test.go @@ -0,0 +1,115 @@ +package api + +import ( + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +func TestRuntimeExpiredNavigationBridge(t *testing.T) { + a, req := authFixture(t) + login := req("POST", "/api/login", `{"username":"alice","password":"password-123"}`, "", nil) + jar, _ := cookiejar.New(nil) + base, _ := url.Parse(a.origin) + cookies := login.Result().Cookies() + sid := cookieValue(cookies, "dsh_session") + csrf := cookieValue(cookies, "dsh_csrf") + for _, c := range cookies { + if c.Name == "dsh_access" { + c.Value = a.accessJWT(sid, "alice", time.Now().Add(-time.Minute)) + } + } + jar.SetCookies(base, cookies) + call := func(method, path, mode, dest, origin, upgrade string) *httptest.ResponseRecorder { + u, _ := url.Parse(a.origin + path) + r := httptest.NewRequest(method, u.String(), nil) + r.Header.Set("Sec-Fetch-Mode", mode) + r.Header.Set("Sec-Fetch-Dest", dest) + r.Header.Set("Origin", origin) + r.Header.Set("Upgrade", upgrade) + for _, c := range jar.Cookies(u) { + r.AddCookie(c) + } + if _, err := r.Cookie("dsh_refresh"); err == nil { + t.Fatal("refresh leaked to runtime path") + } + w := httptest.NewRecorder() + a.Handler().ServeHTTP(w, r) + return w + } + w := call("GET", "/dsh/demo?x=1&token=secret", "navigate", "document", "", "") + if w.Code != 303 || w.Header().Get("Cache-Control") != "no-store" || len(w.Result().Cookies()) != 0 { + t.Fatal(w.Code, w.Header()) + } + location, _ := url.Parse(w.Header().Get("Location")) + values, _ := url.ParseQuery(location.Fragment[len("runtime-renew?"):]) + if location.Path != "/" || values.Get("return") != "/dsh/demo?x=1" { + t.Fatal(location) + } + for _, tc := range []struct{ method, mode, dest, origin, upgrade string }{ + {"GET", "cors", "empty", a.origin, ""}, + {"GET", "navigate", "iframe", "https://evil.test", ""}, + {"GET", "", "", a.origin, "websocket"}, + {"GET", "navigate", "document", a.origin, "websocket"}, + {"POST", "navigate", "document", a.origin, ""}, + {"GET", "navigate", "document", "https://evil.test", ""}, + } { + if w := call(tc.method, "/dsh/demo/", tc.mode, tc.dest, tc.origin, tc.upgrade); w.Code != 401 || w.Header().Get("Location") != "" { + t.Fatal(tc, w.Code) + } + } + refreshURL, _ := url.Parse(a.origin + "/api/refresh") + if w := call("GET", "/dsh/demo/", "navigate", "iframe", "", ""); w.Code != 303 { + t.Fatal("expired iframe did not reach renewal bridge", w.Code) + } + jar.SetCookies(base, []*http.Cookie{{Name: "dsh_access", Path: "/", MaxAge: -1}}) + if w := call("GET", "/dsh/demo/", "navigate", "document", "", ""); w.Code != 303 { + t.Fatal("browser-expired cookie did not reach bridge", w.Code) + } + if w := call("GET", "/dsh/demo/ws", "websocket", "empty", a.origin, "websocket"); w.Code != 401 { + t.Fatal("bare sid accepted", w.Code) + } + expired := jar.Cookies(refreshURL) + if w := req("POST", "/api/refresh", "", "", expired); w.Code != 403 { + t.Fatal("CSRF bypass", w.Code) + } + fresh := req("POST", "/api/refresh", "", csrf, expired) + if fresh.Code != 200 { + t.Fatal(fresh.Code, fresh.Body.String()) + } + jar.SetCookies(base, fresh.Result().Cookies()) + if w := call("GET", "/dsh/demo/", "navigate", "document", "", ""); w.Code != 404 { + t.Fatal("renewed navigation", w.Code) + } + if w := call("GET", "/dsh/demo/ws", "websocket", "empty", a.origin, "websocket"); w.Code != 404 { + t.Fatal("renewed WS authentication", w.Code) + } + a.s.DeleteSession(sid) + if w := call("GET", "/dsh/demo/ws", "websocket", "empty", a.origin, "websocket"); w.Code != 401 { + t.Fatal("revoked WS accepted", w.Code) + } +} + +func TestRuntimeSessionBootstrap(t *testing.T) { + target, _ := url.Parse("http://runtime.test") + proxy := runtimeReverseProxy(target, "/dsh/demo", "panel.test", "runtime=secret") + response := &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"text/html"}, "Set-Cookie": {"secret=runtime"}, "Etag": {"old"}, "Content-Length": {"1"}, "Cache-Control": {"public"}}, Body: io.NopCloser(strings.NewReader("runtime"))} + if err := proxy.ModifyResponse(response); err != nil { + t.Fatal(err) + } + data, _ := io.ReadAll(response.Body) + if !strings.Contains(string(data), ``) || response.ContentLength != int64(len(data)) || response.Header.Get("Set-Cookie") != "" || response.Header.Get("ETag") != "" || response.Header.Get("Cache-Control") != "no-store" || response.Header.Get("Content-Length") != fmt.Sprint(len(data)) { + t.Fatal(string(data), response.Header) + } + w := httptest.NewRecorder() + runtimeSessionScript(w, httptest.NewRequest("GET", "/api/runtime-session.js", nil)) + if w.Code != 200 || !strings.Contains(w.Body.String(), "240000") || !strings.Contains(w.Body.String(), `"X-CSRF-Token":csrf.slice(9)`) || strings.Contains(w.Body.String(), "dsh_refresh=") { + t.Fatal(w.Code, w.Body.String()) + } +} diff --git a/internal/api/runtime_free_search_test.go b/internal/api/runtime_free_search_test.go new file mode 100644 index 0000000..2014fc0 --- /dev/null +++ b/internal/api/runtime_free_search_test.go @@ -0,0 +1,141 @@ +package api + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "os/exec" + "strings" + "sync/atomic" + "testing" + + "git.misaka.ren/m1saka/dsh-web/internal/store" +) + +func TestRuntimeFreeSearchProxy(t *testing.T) { + var forwarded atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("token") == "launch" { + http.SetCookie(w, &http.Cookie{Name: "runtime", Value: r.Host}) + w.WriteHeader(303) + return + } + forwarded.Add(1) + cookie, err := r.Cookie("runtime") + if err != nil || cookie.Value != r.Host { + t.Error("Host-bound cookie mismatch", r.Host, cookie) + } + if r.Header.Get("Authorization") != "" || r.Header.Get("X-CSRF-Token") != "" || strings.Contains(r.Header.Get("Cookie"), "dsh_session") { + t.Error("panel credentials leaked") + } + if runtimeFreeSearchPath(r.URL.Path) { + remote, _, _ := net.SplitHostPort(r.RemoteAddr) + if !net.ParseIP(remote).IsLoopback() || r.Host != "localhost" || r.Header.Get("Origin") != "http://localhost" { + t.Error("loopback guard failed", r.Host, r.Header) + } + data, _ := io.ReadAll(r.Body) + if string(data) != "payload" || r.URL.RawQuery != "keep=1" { + t.Error("request changed", string(data), r.URL.RawQuery) + } + } else if r.Host != "public.example:8443" || r.Header.Get("Origin") != "https://public.example:8443" { + t.Error("ordinary resource authority changed", r.Host) + } + w.WriteHeader(204) + })) + defer upstream.Close() + old := runtimeTransport + transport := old.Clone() + transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { + if address != "192.0.2.10:8080" { + return nil, fmt.Errorf("not the Caddy target: %s", address) + } + return (&net.Dialer{}).DialContext(ctx, network, strings.TrimPrefix(upstream.URL, "http://")) + } + runtimeTransport = transport + defer func() { runtimeTransport = old; transport.CloseIdleConnections() }() + a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/json") { + fmt.Fprintf(w, `{"State":{"Running":true,"StartedAt":"1970-01-01T00:00:01Z"},"NetworkSettings":{"Networks":{%q:{"IPAddress":"192.0.2.10"}}}}`, runtimeNetwork("demo")) + } else { + io.WriteString(w, "http://localhost/?token=launch\n") + } + }) + a.origin = "https://public.example:8443" + defer runtimeAuthCache.Delete(runtimeAuthKey{a, "demo"}) + for _, i := range []store.Instance{{ID: "demo", UID: "owner", ContainerID: "container"}, {ID: "foreign", UID: "other"}} { + if err := a.s.AddInstance(i); err != nil { + t.Fatal(err) + } + } + call := func(method, path, origin, session, site string, want int) { + t.Helper() + r := runtimeRequest(method, path, "payload", session) + r.Host = "public.example:8443" + r.Header.Set("Origin", origin) + r.Header.Set("Sec-Fetch-Site", site) + r.Header.Set("Authorization", "Bearer panel-secret") + r.Header.Set("X-CSRF-Token", "panel-secret") + w := httptest.NewRecorder() + a.Handler().ServeHTTP(w, r) + if w.Code != want { + t.Fatalf("%s %s origin=%q: %d %s", method, path, origin, w.Code, w.Body.String()) + } + } + const bridge = "/api/dsh-free-search-settings/" + for _, endpoint := range []string{"describe", "mutate", "credentials-status", "credentials-set", "credentials-unset", "check-update", "update", "raw-search"} { + call("POST", "/dsh/demo"+bridge+endpoint+"?keep=1&token=browser", a.origin, sid, "same-origin", 204) + } + for _, path := range []string{"/assets/client.js", bridge + "unknown", bridge + "describe/", bridge + "describe-extra", "/api/dsh-free-search-settings-evil/describe"} { + call("POST", "/dsh/demo"+path, a.origin, sid, "same-origin", 204) + } + call("POST", "/dsh/demo"+bridge+"describe?keep=1", a.origin, sid, "same-origin", 204) + count := forwarded.Load() + for _, origin := range []string{"", "null", "https://evil.example", "http://localhost", "https://public.example:8443.evil"} { + call("POST", "/dsh/demo"+bridge+"mutate", origin, sid, "", 403) + } + call("POST", "/dsh/demo"+bridge+"mutate", a.origin, sid, "cross-site", 403) + call("POST", "/dsh/demo"+bridge+"mutate", a.origin, "", "", 401) + call("POST", "/dsh/foreign"+bridge+"mutate", a.origin, sid, "", 404) + call("GET", "/dsh/demo"+bridge+"mutate", "", sid, "", 405) + call("POST", bridge+"mutate", a.origin, "", "", 403) + call("GET", bridge+"describe", "", "", "", 404) + if forwarded.Load() != count { + t.Fatal("denied request reached runtime") + } +} + +func TestRuntimeFreeSearchFetchSemantics(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("Node required for Fetch API semantics") + } + script := `const assert = require("node:assert/strict"); global.window=global; global.location=new URL("https://panel.test/dsh/demo/"); global.document={baseURI:location.href,addEventListener(){}}; global.addEventListener=()=>{}; global.setInterval=()=>{}; let last; global.fetch=async (input,init)=>{last=[input,init]; return new Request(input,init)};` + runtimeSessionJS + + `(async()=>{ + const path='/api/dsh-free-search-settings/mutate'; + for (const input of [path,new URL(path,location)]) { + const result=await fetch(input,{method:'POST',body:'string-body',headers:{'X-Test':'yes'},credentials:'include'}); + assert.equal(result.url,location.origin+'/dsh/demo'+path); + assert.equal(await result.text(),'string-body'); assert.equal(result.headers.get('X-Test'),'yes'); assert.equal(result.credentials,'include'); + } + const abort=new AbortController(); + const input=new Request(new URL(path,location),{method:'POST',body:'request-body',headers:{'X-Test':'request'},signal:abort.signal,credentials:'omit',redirect:'manual',cache:'no-store'}); + const result=await fetch(input); + assert.equal(await result.text(),'request-body'); assert.equal(result.headers.get('X-Test'),'request'); assert.equal(result.credentials,'omit'); assert.equal(result.redirect,'manual'); assert.equal(result.cache,'no-store'); abort.abort(); assert.equal(result.signal.aborted,true); + const override=await fetch(new Request(new URL(path,location),{method:'POST',body:'old'}),{body:'new',headers:{'X-Test':'override'}}); + assert.equal(await override.text(),'new'); assert.equal(override.headers.get('X-Test'),'override'); + const stream = new ReadableStream({start(c){c.enqueue(new TextEncoder().encode('stream-body'));c.close()}}); + const streamed=await fetch(new Request(new URL(path,location),{method:'POST',body:stream,duplex:'half'})); + assert.equal(await streamed.text(),'stream-body'); + const used=new Request(new URL(path,location),{method:'POST',body:'used'}); await used.text(); + await assert.rejects(fetch(used),TypeError); + for(const path of ['/api/refresh','/api/dsh-free-search-settings/mutate/','/api/dsh-free-search-settings/mutate-extra','/dsh/demo/api/dsh-free-search-settings/mutate','https://evil.test/api/dsh-free-search-settings/mutate']) { + const input=new URL(path,location); const init={method:'POST',body:'unchanged'}; await fetch(input,init); assert.equal(last[0],input); assert.equal(last[1],init); + } +})().catch(e=>{console.error(e);process.exit(1)});` + if output, err := exec.Command(node, "-e", script).CombinedOutput(); err != nil { + t.Fatalf("%v: %s", err, output) + } +} diff --git a/internal/api/runtime_persistence_test.go b/internal/api/runtime_persistence_test.go new file mode 100644 index 0000000..8c13566 --- /dev/null +++ b/internal/api/runtime_persistence_test.go @@ -0,0 +1,195 @@ +package api + +import ( + "bytes" + "context" + "encoding/binary" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "testing/iotest" + + "git.misaka.ren/m1saka/dsh-web/internal/docker" + "git.misaka.ren/m1saka/dsh-web/internal/store" +) + +func runtimeTestFrame(payload []byte) []byte { + var h [8]byte + h[0] = 1 + binary.BigEndian.PutUint32(h[4:], uint32(len(payload))) + return append(h[:], payload...) +} + +func TestRuntimeTokenFromStreamLargeRawAndFramed(t *testing.T) { + for _, tc := range []struct { + name string + data []byte + token string + }{ + {"raw", append(bytes.Repeat([]byte("x"), 9<<20), []byte("\ndsh web: http://runtime/?token=raw-tail\n")...), "raw-tail"}, + {"framed", runtimeTestFrame(append(bytes.Repeat([]byte("x"), 9<<20), []byte("\ndsh web: http://runtime/?token=framed-tail\n")...)), "framed-tail"}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := runtimeTokenFromStream(bytes.NewReader(tc.data)) + if err != nil || got != tc.token { + t.Fatalf("token=%q err=%v", got, err) + } + }) + } +} + +func TestRuntimeTokenFromStreamSplitLatestAndTruncatedFrame(t *testing.T) { + data := []byte("dsh web: http://runtime/?token=first-token\ndsh web: http://runtime/?token=second-token\n") + got, err := runtimeTokenFromStream(iotest.OneByteReader(bytes.NewReader(data))) + if err != nil || got != "second-token" { + t.Fatalf("token=%q err=%v", got, err) + } + framed := runtimeTestFrame([]byte("dsh web: http://runtime/?token=truncated")) + framed = framed[:len(framed)-2] + if _, err := runtimeTokenFromStream(bytes.NewReader(framed)); err == nil { + t.Fatal("truncated frame accepted") + } +} + +func TestRuntimeCookiePersistenceAndHostExchange(t *testing.T) { + var mu sync.Mutex + logs, exchanges := 0, 0 + var seen []string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + if r.URL.Path == "/" { + exchanges++ + token := r.URL.Query().Get("token") + seen = append(seen, token) + w.Header().Set("Set-Cookie", "session="+token+"-"+r.Host+"; Max-Age=60") + w.WriteHeader(http.StatusSeeOther) + return + } + if strings.HasSuffix(r.URL.Path, "/logs") { + logs++ + io.WriteString(w, "dsh web: http://runtime/?token=persist-token\n") + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer upstream.Close() + t.Setenv("DOCKER_HOST", upstream.URL) + a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {}) + a.docker = docker.New(upstream.URL) + i := store.Instance{ID: "persist", UID: "owner", ContainerID: "container"} + if err := a.s.AddInstance(i); err != nil { + t.Fatal(err) + } + target, _ := url.Parse(upstream.URL) + ctx := context.Background() + if got, err := a.runtimeCookie(ctx, i, "2025-01-01T00:00:00Z", target, "panel.test"); err != nil || got != "session=persist-token-panel.test" { + t.Fatalf("first cookie=%q err=%v", got, err) + } + b := New(a.s, a.root) + b.docker = docker.New(upstream.URL) + if got, err := b.runtimeCookie(ctx, i, "2025-01-01T00:00:00Z", target, "panel.test"); err != nil || got != "session=persist-token-panel.test" { + t.Fatalf("persisted cookie=%q err=%v", got, err) + } + if got, err := b.runtimeCookie(ctx, i, "2025-01-01T00:00:00Z", target, "localhost"); err != nil || got != "session=persist-token-localhost" { + t.Fatalf("localhost cookie=%q err=%v", got, err) + } + if got, err := b.runtimeCookie(ctx, i, "2025-01-01T00:00:00Z", target, "panel.test"); err != nil || got != "session=persist-token-panel.test" { + t.Fatalf("host cookie=%q err=%v", got, err) + } + proxy := runtimeReverseProxy(target, "/dsh/persist", "panel.test", "session=persist-token-panel.test") + b.invalidateRejectedRuntimeCookie(proxy, i, "2025-01-01T00:00:00Z", "panel.test", "session=persist-token-panel.test") + if err := proxy.ModifyResponse(&http.Response{StatusCode: 401, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("denied"))}); err != nil { + t.Fatal(err) + } + if got, err := b.runtimeCookie(ctx, i, "2025-01-01T00:00:00Z", target, "panel.test"); err != nil || got != "session=persist-token-panel.test" { + t.Fatalf("reexchange: %q %v", got, err) + } + if _, err := b.runtimeCookie(ctx, i, "2025-01-02T00:00:00Z", target, "panel.test"); err != nil { + t.Fatalf("changed start time: %v", err) + } + mu.Lock() + defer mu.Unlock() + if logs != 2 { + t.Fatalf("log reads=%d, want 2", logs) + } + if exchanges != 4 { + t.Fatalf("exchanges=%d, want 4", exchanges) + } + for _, token := range seen { + if token != "persist-token" { + t.Fatalf("exchanged token=%q", token) + } + } +} + +func TestRuntimeAuthDiskEncryptedAndTamperRejected(t *testing.T) { + a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {}) + entry := runtimeAuthEntry{Container: "container", Started: "2025-01-01T00:00:00Z", Token: "super-secret-token", Host: "panel.test", Cookies: "session=super-secret-token"} + key := entry.Container + "/" + entry.Started + if err := a.saveRuntimeAuth("instance", key, entry); err != nil { + t.Fatal(err) + } + path := a.runtimeAuthPath("instance", key) + st, err := os.Stat(path) + if err != nil || st.Mode().Perm() != 0600 { + t.Fatalf("stat=%v mode=%v", err, st.Mode().Perm()) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(data, []byte(entry.Token)) || bytes.Contains(data, []byte(entry.Cookies)) { + t.Fatal("runtime token stored in plaintext") + } + data[len(data)/2] ^= 1 + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + if _, ok := a.loadRuntimeAuth("instance", key); ok { + t.Fatal("tampered runtime auth accepted") + } +} + +func TestRuntimeCookieSaveFailureDoesNotCache(t *testing.T) { + var mu sync.Mutex + logs := 0 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/logs") { + mu.Lock() + logs++ + mu.Unlock() + io.WriteString(w, "dsh web: http://runtime/?token=save-failure-token\n") + return + } + w.Header().Set("Set-Cookie", "session=save-failure; Max-Age=60") + w.WriteHeader(http.StatusSeeOther) + })) + defer upstream.Close() + t.Setenv("DOCKER_HOST", upstream.URL) + a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {}) + badRoot := filepath.Join(t.TempDir(), "not-a-directory") + a.docker = docker.New(upstream.URL) + if err := os.WriteFile(badRoot, []byte("x"), 0600); err != nil { + t.Fatal(err) + } + a.root = badRoot + i := store.Instance{ID: "save-failure", UID: "owner", ContainerID: "container"} + target, _ := url.Parse(upstream.URL) + for n := 0; n < 2; n++ { + if _, err := a.runtimeCookie(context.Background(), i, "2025-01-01T00:00:00Z", target, "panel.test"); err == nil { + t.Fatal("save failure accepted") + } + } + mu.Lock() + defer mu.Unlock() + if logs != 2 { + t.Fatalf("log reads=%d after save failures, want 2", logs) + } +} diff --git a/internal/api/runtime_session.go b/internal/api/runtime_session.go new file mode 100644 index 0000000..d24bef4 --- /dev/null +++ b/internal/api/runtime_session.go @@ -0,0 +1,52 @@ +package api + +import "net/http" + +func runtimeSessionScript(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + fail(w, 405, "method not allowed") + return + } + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.Write([]byte(runtimeSessionJS)) +} + +const runtimeSessionJS = `(function () { + const prefix = location.pathname.match(/^\/dsh\/[^/]+(?=\/|$)/); + if (prefix) { + const originalFetch = window.fetch; + const bridge = /^\/api\/dsh-free-search-settings\/(describe|mutate|credentials-status|credentials-set|credentials-unset|check-update|update|raw-search)$/; + window.fetch = async function (input, init) { + let url; + try { url = new URL(input instanceof Request ? input.url : input, document.baseURI); } catch (_) {} + if (url && url.origin === location.origin && bridge.test(url.pathname)) { + url.pathname = prefix[0] + url.pathname; + if (input instanceof Request) { + const request = new Request(input, init); + return originalFetch.call(this, new Request(url, request)); + } + input = url; + } + return originalFetch.call(this, input, init); + }; + } + let pending; + async function renew() { + if (pending) return pending; + pending = (async function () { + const csrf = document.cookie.split("; ").find(c => c.startsWith("dsh_csrf=")); + if (!csrf) return; + const response = await fetch("/api/refresh", {method:"POST", credentials:"same-origin", headers:{"X-CSRF-Token":csrf.slice(9)}}); + if (response.status === 401 || response.status === 403) { + const check = await fetch("/api/me", {credentials:"same-origin"}); + if (check.status === 401) location.reload(); + } + })().catch(function () {}).finally(function () { pending = undefined; }); + return pending; + } + setInterval(renew, 240000); + window.addEventListener("pageshow", renew); + window.addEventListener("online", renew); + document.addEventListener("visibilitychange", function () { if (!document.hidden) renew(); }); +})();` diff --git a/internal/api/runtime_test.go b/internal/api/runtime_test.go new file mode 100644 index 0000000..1848ecf --- /dev/null +++ b/internal/api/runtime_test.go @@ -0,0 +1,581 @@ +package api + +import ( + "bufio" + "context" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "git.misaka.ren/m1saka/dsh-web/internal/docker" + "git.misaka.ren/m1saka/dsh-web/internal/store" +) + +func runtimeTestAPI(t *testing.T, handler http.HandlerFunc) (*API, string) { + t.Helper() + s, err := store.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(s.Close) + if err := s.CreateUser(store.User{ID: "owner", Username: "owner"}, "unused"); err != nil { + t.Fatal(err) + } + sid, _, err := s.Session("owner") + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + a := &API{s: s, root: t.TempDir(), origin: "http://panel.test", docker: docker.New(server.URL)} + if err := a.initAuth(); err != nil { + t.Fatal(err) + } + registerTestAccess(t, a, sid) + return a, sid +} +func runtimeRequest(method, path, body, sid string) *http.Request { + r := httptest.NewRequest(method, path, strings.NewReader(body)) + r.Host = "panel.test" + r.AddCookie(&http.Cookie{Name: "dsh_session", Value: sid}) + addTestAccess(r, sid) + return r +} + +func TestRuntimeProxyRejectsErrorState(t *testing.T) { + a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + t.Error("error-state proxy must not contact Docker") + }) + if err := a.s.AddInstance(store.Instance{ID: "broken", UID: "owner", Status: "error", Created: time.Now()}); err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + a.proxy(w, runtimeRequest("GET", "/dsh/broken/", "", sid)) + if w.Code != http.StatusConflict { + t.Fatalf("status = %d", w.Code) + } +} + +func TestRuntimeCreateLimitAndPolicy(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("chown requires root") + } + t.Setenv("HOST_DATA_ROOT", "/host/data") + t.Setenv("PANEL_CONTAINER_ID", "panel") + imageID := "sha256:" + strings.Repeat("a", 64) + var mu sync.Mutex + var bodies []map[string]any + var calls []string + a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + calls = append(calls, r.Method+" "+r.URL.Path) + switch { + case strings.Contains(r.URL.Path, "/images/"): + writeJSON(w, map[string]any{"Id": imageID, "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.role": "runtime", "dsh-mgr.version": "1.2.3"}}}) + case strings.HasSuffix(r.URL.Path, "/containers/create"): + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + bodies = append(bodies, body) + writeJSON(w, map[string]string{"Id": fmt.Sprintf("container%d", len(bodies))}) + default: + w.WriteHeader(204) + } + }) + body := fmt.Sprintf("{\"name\":\"one\",\"image_id\":%q,\"version\":\"untrusted\"}", imageID) + seedVerifiedImage(t, a, imageID, "1.2.3") + var wg sync.WaitGroup + statuses := make(chan int, 4) + for n := 0; n < 4; n++ { + wg.Add(1) + go func() { + defer wg.Done() + w := httptest.NewRecorder() + a.instances(w, runtimeRequest("POST", "/api/instances", body, sid)) + statuses <- w.Code + }() + } + wg.Wait() + close(statuses) + successes, limits := 0, 0 + for status := range statuses { + if status == 200 { + successes++ + } else if status == 409 { + limits++ + } else { + t.Errorf("status %d", status) + } + } + if successes != 2 || limits != 2 { + t.Fatalf("success=%d limit=%d", successes, limits) + } + instances, err := a.s.Instances("owner") + if err != nil || len(instances) != 2 { + t.Fatalf("instances=%v err=%v", instances, err) + } + for _, i := range instances { + if i.Version != "1.2.3" || i.ContainerID == "" || i.Status != "running" { + t.Errorf("%+v", i) + } + if _, err := os.Stat(filepath.Join(a.root, "workspaces", "owner", "instances", i.ID, "home")); err != nil { + t.Error(err) + } + } + for _, b := range bodies { + h := b["HostConfig"].(map[string]any) + mounts := h["Mounts"].([]any) + if len(mounts) != 1 { + t.Fatalf("unexpected mounts: %v", mounts) + } + mount := mounts[0].(map[string]any) + if mount["Target"] != "/workspace" || !strings.HasSuffix(mount["Source"].(string), "/home") { + t.Fatal(mount) + } + if !strings.Contains(fmt.Sprint(b["Env"]), "DSH_HOME=/workspace") { + t.Fatal("wrong data root", b["Env"]) + } + if b["User"] != "1000:1000" || h["ReadonlyRootfs"] != true || h["Memory"] != float64(2<<30) || h["NanoCpus"] != float64(2e9) || h["PidsLimit"] != float64(256) { + t.Errorf("policy %v", b) + } + if h["PortBindings"] != nil || h["PublishAllPorts"] != nil { + t.Error("ports published") + } + if fmt.Sprint(h["CapDrop"]) != "[ALL]" || !strings.Contains(fmt.Sprint(h["Tmpfs"]), "exec") { + t.Error(h) + } + if strings.Contains(fmt.Sprint(b["Cmd"]), "public-url") || !strings.Contains(fmt.Sprint(b["Cmd"]), "--trusted-host panel.test") { + t.Error(b["Cmd"]) + } + if !strings.Contains(fmt.Sprint(h["Mounts"]), "/host/data/workspaces/owner") { + t.Error(h["Mounts"]) + } + } + if !strings.Contains(strings.Join(calls, "\n"), "/connect") { + t.Fatal("panel not connected") + } +} + +func TestRuntimeLifecycleAndOwnership(t *testing.T) { + t.Setenv("PANEL_CONTAINER_ID", "") + var calls []string + a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + if strings.HasSuffix(r.URL.Path, "/json") { + io.WriteString(w, "{\"State\":{\"Status\":\"running\"}}") + } else if strings.HasSuffix(r.URL.Path, "/logs") { + io.WriteString(w, "dsh web: http://localhost/?token=secret-value\nAuthorization: Bearer another-secret\n") + } else { + w.WriteHeader(204) + } + }) + i := store.Instance{ID: "instance", UID: "owner", ContainerID: "container"} + if err := a.s.AddInstance(i); err != nil { + t.Fatal(err) + } + dir := filepath.Join(a.root, "workspaces", "owner", "instances", i.ID, "home") + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + for _, action := range []string{"start", "stop", "restart"} { + w := httptest.NewRecorder() + a.instance(w, runtimeRequest("POST", "/api/instances/instance/"+action, "", sid)) + if w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + w = httptest.NewRecorder() + a.instance(w, runtimeRequest("GET", "/api/instances/instance/"+action, "", sid)) + if w.Code != 405 { + t.Fatal(w.Code) + } + } + w := httptest.NewRecorder() + a.instance(w, runtimeRequest("GET", "/api/instances/instance/logs", "", sid)) + if strings.Contains(w.Body.String(), "secret-value") || strings.Contains(w.Body.String(), "another-secret") { + t.Fatal("credentials in logs") + } + foreign := store.Instance{ID: "foreign", UID: "someone", ContainerID: "other"} + a.s.AddInstance(foreign) + before := len(calls) + for _, path := range []string{"/api/instances/foreign/start", "/api/instances/foreign/logs"} { + w = httptest.NewRecorder() + a.instance(w, runtimeRequest("POST", path, "", sid)) + if w.Code != 404 { + t.Fatal(w.Code) + } + } + w = httptest.NewRecorder() + a.proxy(w, runtimeRequest("GET", "/dsh/foreign/", "", sid)) + if w.Code != 404 || len(calls) != before { + t.Fatal("owner check bypass") + } + w = httptest.NewRecorder() + a.instance(w, runtimeRequest("DELETE", "/api/instances/instance", "", sid)) + if w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + if _, err := os.Stat(dir); err != nil { + t.Fatal("data removed", err) + } + if _, err := a.s.GetInstance(i.ID); err == nil { + t.Fatal("row retained") + } + if !strings.Contains(strings.Join(calls, "\n"), "DELETE /v1.47/containers/container") { + t.Fatal(calls) + } +} + +func TestRuntimeTokenOriginAndExchange(t *testing.T) { + logs := "dsh web: http://localhost/?token=old\ndsh web: http://localhost/?token=latest%2Btoken\x1b[0m\n" + if got := latestRuntimeToken(logs); got != "latest+token" { + t.Fatal(got) + } + if got := latestRuntimeToken(logs + `{"uri":"/?token=old"}`); got != "latest+token" { + t.Fatalf("proxy log replaced startup token: %q", got) + } + if strings.Contains(redactRuntimeLogs(logs), "latest") || strings.Contains(redactRuntimeLogs(logs), "old") { + t.Fatal("token not redacted") + } + a := &API{origin: "http://panel.test"} + for _, tc := range []struct { + method, origin, upgrade string + want bool + }{{"GET", "", "", true}, {"POST", "", "", false}, {"POST", "http://panel.test", "", true}, {"POST", "http://evil.test", "", false}, {"GET", "", "websocket", false}, {"GET", "http://panel.test", "websocket", true}} { + r := runtimeRequest(tc.method, "/dsh/id/", "", "") + r.Header.Set("Origin", tc.origin) + r.Header.Set("Upgrade", tc.upgrade) + if a.runtimeOriginAllowed(r) != tc.want { + t.Errorf("%+v", tc) + } + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Query().Get("token") != "latest+token" || r.Host != "panel.test" { + t.Error("invalid exchange") + } + http.SetCookie(w, &http.Cookie{Name: "dsh_auth", Value: "server-secret", Path: "/"}) + w.Header().Set("Location", "/") + w.WriteHeader(303) + })) + defer server.Close() + target, _ := url.Parse(server.URL) + cookie, err := exchangeRuntimeCookie(context.Background(), target, "panel.test", "latest+token") + if err != nil || cookie != "dsh_auth=server-secret" { + t.Fatalf("exchange failed: %v", err) + } +} + +func TestRuntimeProxyHeadersRedirectAndSSE(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Cookie") != "dsh_auth=server-secret" || r.Header.Get("Authorization") != "" || r.Header.Get("X-CSRF-Token") != "" || r.URL.Query().Get("token") != "" { + t.Error("credential boundary violated") + } + if r.Host != "panel.test" { + t.Error(r.Host) + } + http.SetCookie(w, &http.Cookie{Name: "dsh_auth", Value: "not-for-browser"}) + if r.URL.Path == "/redirect" { + w.Header().Set("Location", "/next?token=not-for-browser") + w.WriteHeader(303) + return + } + if r.URL.Path != "/events" { + t.Error(r.URL.Path) + } + w.Header().Set("Content-Type", "text/event-stream") + io.WriteString(w, "data: first\n\n") + w.(http.Flusher).Flush() + <-r.Context().Done() + })) + defer upstream.Close() + target, _ := url.Parse(upstream.URL) + proxy := httptest.NewServer(runtimeReverseProxy(target, "/dsh/id", "panel.test", "dsh_auth=server-secret")) + defer proxy.Close() + client := &http.Client{Timeout: 3 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + for _, path := range []string{"redirect", "events"} { + req, _ := http.NewRequest("GET", proxy.URL+"/dsh/id/"+path+"?token=browser-token", nil) + req.Header.Set("Cookie", "dsh_session=panel-secret") + req.Header.Set("Authorization", "Bearer panel-secret") + req.Header.Set("X-CSRF-Token", "panel-secret") + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + if resp.Header.Get("Set-Cookie") != "" { + t.Fatal("cookie escaped") + } + if path == "redirect" { + if resp.Header.Get("Location") != "/dsh/id/next" { + t.Fatal(resp.Header.Get("Location")) + } + } else { + line, err := bufio.NewReader(resp.Body).ReadString('\n') + if err != nil || line != "data: first\n" { + t.Fatal("SSE did not flush", err) + } + } + resp.Body.Close() + } +} + +func TestRuntimeProxyPreservesComboQuery(t *testing.T) { + const query = "?@deepseek-ai/dsh-client-modules/client.js,@deepseek-ai/dsh-client-ui/client.js&rev=abc" + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.RawQuery != query { + t.Errorf("combo query changed: %q", r.URL.RawQuery) + } + w.Header().Set("Location", "/plugins/?"+query+"&%74oken=secret") + w.WriteHeader(http.StatusTemporaryRedirect) + })) + defer upstream.Close() + target, _ := url.Parse(upstream.URL) + proxy := runtimeReverseProxy(target, "/dsh/id", "panel.test", "") + for _, suffix := range []string{"", "&token=secret", "&%74oken=secret&token=second"} { + w := httptest.NewRecorder() + proxy.ServeHTTP(w, httptest.NewRequest("GET", "http://panel.test/dsh/id/plugins/?"+query+suffix, nil)) + if w.Code != http.StatusTemporaryRedirect || w.Header().Get("Location") != "/dsh/id/plugins/?"+query { + t.Fatalf("unexpected combo redirect: %d %q", w.Code, w.Header().Get("Location")) + } + } +} + +func TestRuntimeProxyWebSocketUpgrade(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/socket" || r.Header.Get("Cookie") != "dsh_auth=secret" || r.Header.Get("Authorization") != "" { + t.Error("invalid upgraded request") + } + conn, rw, err := w.(http.Hijacker).Hijack() + if err != nil { + t.Error(err) + return + } + defer conn.Close() + rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n") + rw.Flush() + data := make([]byte, 4) + if _, err := io.ReadFull(rw, data); err != nil { + t.Error(err) + return + } + rw.Write(data) + rw.Flush() + })) + defer upstream.Close() + target, _ := url.Parse(upstream.URL) + proxy := httptest.NewServer(runtimeReverseProxy(target, "/dsh/id", "panel.test", "dsh_auth=secret")) + defer proxy.Close() + conn, err := net.DialTimeout("tcp", strings.TrimPrefix(proxy.URL, "http://"), time.Second) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(3 * time.Second)) + fmt.Fprint(conn, "GET /dsh/id/socket HTTP/1.1\r\nHost: panel.test\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nAuthorization: Bearer panel-secret\r\n\r\n") + reader := bufio.NewReader(conn) + resp, err := http.ReadResponse(reader, nil) + if err != nil || resp.StatusCode != 101 { + t.Fatalf("upgrade: %v %v", resp, err) + } + fmt.Fprint(conn, "ping") + data := make([]byte, 4) + if _, err := io.ReadFull(reader, data); err != nil || string(data) != "ping" { + t.Fatal("upgrade tunnel failed", err) + } +} + +func TestRuntimeCreateRollbackAndImageRejection(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("chown requires root") + } + t.Setenv("PANEL_CONTAINER_ID", "") + t.Setenv("HOST_DATA_ROOT", "") + imageID := "sha256:" + strings.Repeat("b", 64) + for _, role := range []string{"foreign", "runtime"} { + t.Run(role, func(t *testing.T) { + var calls []string + a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + switch { + case strings.Contains(r.URL.Path, "/images/"): + writeJSON(w, map[string]any{"Id": imageID, "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.role": role, "dsh-mgr.version": "1"}}}) + case strings.HasSuffix(r.URL.Path, "/containers/create"): + writeJSON(w, map[string]string{"Id": "container"}) + case strings.HasSuffix(r.URL.Path, "/start"): + w.WriteHeader(500) + io.WriteString(w, "token=must-not-leak") + default: + w.WriteHeader(204) + } + }) + seedVerifiedImage(t, a, imageID, "1") + w := httptest.NewRecorder() + a.instances(w, runtimeRequest("POST", "/api/instances", fmt.Sprintf("{\"name\":\"test\",\"image_id\":%q}", imageID), sid)) + want := 400 + if role == "runtime" { + want = 502 + } + if w.Code != want { + t.Fatal(w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), "must-not-leak") { + t.Fatal("docker error leaked") + } + instances, err := a.s.Instances("owner") + if err != nil || len(instances) != 0 { + t.Fatal("reservation not rolled back", err) + } + if role == "foreign" && len(calls) != 1 { + t.Fatal("untrusted image used") + } + if role == "runtime" && !strings.Contains(strings.Join(calls, "\n"), "DELETE /v1.47/containers/container") { + t.Fatal("container not cleaned") + } + }) + } +} + +func TestRuntimeProxyMissingNetwork(t *testing.T) { + a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, "{\"State\":{\"Running\":true},\"NetworkSettings\":{\"Networks\":null}}") + }) + if err := a.s.AddInstance(store.Instance{ID: "instance", UID: "owner", ContainerID: "container"}); err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + a.proxy(w, runtimeRequest("GET", "/dsh/instance/", "", sid)) + if w.Code != 502 { + t.Fatal(w.Code) + } +} + +func TestRuntimeCookieCacheRestart(t *testing.T) { + exchanges, logs := 0, 0 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + exchanges++ + token := r.URL.Query().Get("token") + if token != "first" && token != "second" { + t.Error("wrong token") + } + http.SetCookie(w, &http.Cookie{Name: "dsh_auth", Value: token}) + w.WriteHeader(303) + })) + defer upstream.Close() + target, _ := url.Parse(upstream.URL) + a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + logs++ + token := "first" + if r.URL.Query().Get("since") == "2.000000000" { + token = "second" + } else if r.URL.Query().Get("since") != "1.000000000" { + t.Error("missing startup filter") + } + io.WriteString(w, "dsh web: http://localhost/?token="+token+"\n") + }) + i := store.Instance{ID: "cache-test", ContainerID: "container"} + defer runtimeAuthCache.Delete(runtimeAuthKey{a, i.ID}) + for n, started := range []string{"1970-01-01T00:00:01Z", "1970-01-01T00:00:01Z", "1970-01-01T00:00:02Z"} { + cookie, err := a.runtimeCookie(context.Background(), i, started, target, "panel.test") + want := "dsh_auth=first" + if n == 2 { + want = "dsh_auth=second" + } + if err != nil || cookie != want { + t.Fatal("cookie exchange", err) + } + } + if exchanges != 2 || logs != 2 { + t.Fatalf("exchanges=%d logs=%d", exchanges, logs) + } +} + +func TestRuntimeCookieCurrentStartupLargeMultiplexedLogs(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("token") != "current" { + t.Error("stale token exchanged") + } + http.SetCookie(w, &http.Cookie{Name: "dsh_auth", Value: "current"}) + w.WriteHeader(303) + })) + defer upstream.Close() + target, _ := url.Parse(upstream.URL) + a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("since") != "2.123456789" { + t.Error("logs not filtered by StartedAt") + io.WriteString(w, strings.Repeat("old logs\n", 200000)) + return + } + for _, payload := range []string{strings.Repeat("startup noise\n", 100000), "http://localhost/?token=current\n"} { + header := [8]byte{1} + binary.BigEndian.PutUint32(header[4:], uint32(len(payload))) + w.Write(header[:]) + io.WriteString(w, payload) + } + }) + i := store.Instance{ID: "large-log", ContainerID: "container"} + defer runtimeAuthCache.Delete(runtimeAuthKey{a, i.ID}) + cookie, err := a.runtimeCookie(context.Background(), i, "1970-01-01T00:00:02.123456789Z", target, "panel.test") + if err != nil || cookie != "dsh_auth=current" { + t.Fatalf("cookie=%s err=%v", cookie, err) + } +} + +func TestRuntimeCookieRejectsTruncatedLogs(t *testing.T) { + a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, "http://localhost/?token=partial\n"+strings.Repeat("x", 8<<20)) + }) + i := store.Instance{ID: "oversize", ContainerID: "container"} + defer runtimeAuthCache.Delete(runtimeAuthKey{a, i.ID}) + if _, err := a.runtimeCookie(context.Background(), i, "1970-01-01T00:00:01Z", nil, "panel.test"); err == nil { + t.Fatal("truncated log accepted") + } +} + +func TestRuntimeCreateRejectsTrailingJSON(t *testing.T) { + a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + t.Error("invalid request reached Docker") + w.WriteHeader(500) + }) + body := fmt.Sprintf(`{"name":"test","image_id":%q} {}`, "sha256:"+strings.Repeat("a", 64)) + w := httptest.NewRecorder() + a.instances(w, runtimeRequest("POST", "/api/instances", body, sid)) + if w.Code != 400 { + t.Fatal(w.Code, w.Body.String()) + } +} + +func TestRuntimeBlocksPendingUpgrade(t *testing.T) { + a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + t.Error("pending upgrade reached Docker") + w.WriteHeader(500) + }) + if err := a.s.AddInstance(store.Instance{ID: "pending", UID: "owner", ContainerID: "old"}); err != nil { + t.Fatal(err) + } + if _, err := a.s.DB.Exec("CREATE TABLE upgrade_journal(instance_id TEXT,phase TEXT); INSERT INTO upgrade_journal VALUES('pending','snapshotting')"); err != nil { + t.Fatal(err) + } + for _, tc := range []struct{ method, path string }{{"POST", "/api/instances/pending/start"}, {"POST", "/api/instances/pending/restart"}, {"DELETE", "/api/instances/pending"}, {"GET", "/dsh/pending/"}} { + w := httptest.NewRecorder() + r := runtimeRequest(tc.method, tc.path, "", sid) + if strings.HasPrefix(tc.path, "/dsh/") { + a.proxy(w, r) + } else { + a.instance(w, r) + } + if w.Code != 409 { + t.Fatalf("%s: %d %s", tc.path, w.Code, w.Body.String()) + } + } +} diff --git a/internal/api/upgrade.go b/internal/api/upgrade.go new file mode 100644 index 0000000..3c7df07 --- /dev/null +++ b/internal/api/upgrade.go @@ -0,0 +1,629 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "syscall" + "time" + + "git.misaka.ren/m1saka/dsh-web/internal/backup" + "git.misaka.ren/m1saka/dsh-web/internal/store" + "golang.org/x/crypto/bcrypt" +) + +const upgradeSnapshotLimit int64 = 1 << 30 + +func upgradeWorkspace(config, host map[string]json.RawMessage, recipe, home, shared string) error { + var mounts []map[string]json.RawMessage + if err := json.Unmarshal(host["Mounts"], &mounts); err != nil { + return errors.New("unsupported runtime mounts") + } + get := func(m map[string]json.RawMessage, key string) string { + var value string + _ = json.Unmarshal(m[key], &value) + return value + } + homeIndex, sharedIndex := -1, -1 + for n, m := range mounts { + target, source := get(m, "Target"), get(m, "Source") + if target != "/workspace" && target != "/data/dsh-home" && target != "/shared" { + continue + } + if get(m, "Type") != "bind" { + return errors.New("unsupported runtime mount type") + } + switch { + case source == home && (target == "/workspace" || target == "/data/dsh-home") && homeIndex == -1: + homeIndex = n + case source == shared && (target == "/workspace" || target == "/shared") && sharedIndex == -1: + sharedIndex = n + default: + return errors.New("ambiguous runtime mounts") + } + } + if homeIndex == -1 { + return errors.New("instance home mount required") + } + if recipe != "7" { + if get(mounts[homeIndex], "Target") == "/workspace" { + return errors.New("workspace home requires template 7") + } + return nil + } + for _, key := range []string{"Binds", "VolumesFrom"} { + var values []string + if raw := host[key]; len(raw) != 0 { + if json.Unmarshal(raw, &values) != nil || len(values) != 0 { + return errors.New("unsupported additional mounts") + } + } + } + var env []string + if raw := config["Env"]; len(raw) != 0 { + if err := json.Unmarshal(raw, &env); err != nil { + return err + } + } + kept := make([]string, 0, len(env)+4) + for _, entry := range env { + key, _, _ := strings.Cut(entry, "=") + switch key { + case "DSH_HOME", "HOME", "XDG_DATA_HOME", "XDG_CONFIG_HOME": + default: + kept = append(kept, entry) + } + } + kept = append(kept, "DSH_HOME=/workspace", "HOME=/workspace", "XDG_DATA_HOME=/workspace/.local/share", "XDG_CONFIG_HOME=/workspace/.config") + mounts[homeIndex]["Target"], _ = json.Marshal("/workspace") + if sharedIndex != -1 { + mounts = append(mounts[:sharedIndex], mounts[sharedIndex+1:]...) + } + host["Mounts"], _ = json.Marshal(mounts) + config["Env"], _ = json.Marshal(kept) + return nil +} + +func upgradeRoot(root string, i store.Instance) (*os.Root, error) { + r, err := os.OpenRoot(root) + if err != nil { + return nil, err + } + for _, part := range []string{"workspaces", i.UID, "instances", i.ID} { + if part == "" || part == "." || part == ".." || strings.ContainsAny(part, "/\\") { + r.Close() + return nil, errors.New("invalid storage path") + } + st, e := r.Lstat(part) + if e != nil || !st.IsDir() { + r.Close() + return nil, errors.New("unsafe storage path") + } + next, e := r.OpenRoot(part) + r.Close() + if e != nil { + return nil, e + } + r = next + } + return r, nil +} + +func upgradeSync(r *os.Root) error { + f, err := r.Open(".") + if err != nil { + return err + } + defer f.Close() + return f.Sync() +} + +func upgradeCopy(ctx context.Context, r *os.Root, source, target string, limit int64) error { + remaining, entries := limit, 100000 + var links []string + var copyTree func(string, string, int) error + copyTree = func(src, dst string, depth int) error { + if err := ctx.Err(); err != nil { + return err + } + entries-- + if entries < 0 || depth > 128 { + return errors.New("snapshot entry limit") + } + st, err := r.Lstat(src) + if err != nil { + return err + } + if st.IsDir() { + if err = r.Mkdir(dst, 0700); err != nil { + return err + } + f, err := r.OpenFile(src, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_DIRECTORY, 0) + if err != nil { + return err + } + defer f.Close() + for { + children, err := f.ReadDir(128) + if err != nil && err != io.EOF { + return err + } + for _, child := range children { + if err := copyTree(filepath.Join(src, child.Name()), filepath.Join(dst, child.Name()), depth+1); err != nil { + return err + } + } + if err == io.EOF { + break + } + } + if err = r.Chown(dst, 1000, 1000); err != nil { + return err + } + if err = r.Chmod(dst, st.Mode().Perm()); err != nil { + return err + } + d, err := r.Open(dst) + if err != nil { + return err + } + defer d.Close() + return d.Sync() + } + if st.Mode()&os.ModeSymlink != 0 && depth > 0 { + link, err := r.Readlink(src) + if err != nil { + return err + } + rel, err := filepath.Rel(source, src) + if err != nil || filepath.IsAbs(link) || !filepath.IsLocal(filepath.Join(filepath.Dir(rel), link)) { + return errors.New("unsafe snapshot link") + } + if err = r.Symlink(link, dst); err != nil { + return err + } + links = append(links, rel) + return r.Lchown(dst, 1000, 1000) + } + if !st.Mode().IsRegular() || st.Size() > remaining { + return errors.New("unsafe or oversized snapshot") + } + in, err := r.OpenFile(src, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) + if err != nil { + return err + } + defer in.Close() + actual, err := in.Stat() + if err != nil || !actual.Mode().IsRegular() || !os.SameFile(st, actual) { + return errors.New("snapshot source changed") + } + out, err := r.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL|syscall.O_NOFOLLOW, 0600) + if err != nil { + return err + } + defer out.Close() + n, err := io.Copy(out, io.LimitReader(in, remaining+1)) + if err != nil { + return err + } + remaining -= n + if remaining < 0 { + return errors.New("snapshot size limit") + } + if err = out.Chown(1000, 1000); err != nil { + return err + } + if err = out.Chmod(st.Mode().Perm()); err != nil { + return err + } + return out.Sync() + } + if err := copyTree(source, target, 0); err != nil { + return err + } + snapshot, err := r.OpenRoot(target) + if err != nil { + return err + } + defer snapshot.Close() + // Resolve only within the completed snapshot, including intermediate symlinks. + for _, link := range links { + if _, err := snapshot.Stat(link); err != nil { + return errors.New("unsafe or unresolved snapshot link") + } + } + return upgradeSync(r) +} + +// Call while holding a.mu before any operation that can start or mutate a runtime. +func (a *API) upgradeRecoveryRequired(i store.Instance) bool { + var exists int + if err := a.s.DB.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='upgrade_journal'").Scan(&exists); err != nil { + return true + } + if exists == 0 { + return false + } + var pending int + if err := a.s.DB.QueryRow("SELECT count(*) FROM upgrade_journal WHERE instance_id=? AND phase NOT IN ('complete','rolled_back')", i.ID).Scan(&pending); err != nil { + return true + } + if pending != 0 { + a.s.DB.Exec("UPDATE instances SET status='error' WHERE id=?", i.ID) + } + return pending != 0 +} + +func (a *API) upgrade(w http.ResponseWriter, r *http.Request, i store.Instance) { + if r.Method != http.MethodPost { + fail(w, 405, "method not allowed") + return + } + var in struct { + ImageID string `json:"image_id"` + Password string `json:"password"` + } + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)) + if dec.Decode(&in) != nil || !runtimeImageID.MatchString(in.ImageID) || dec.Decode(new(any)) != io.EOF { + fail(w, 400, "invalid image_id") + return + } + a.mu.Lock() + defer a.mu.Unlock() + if a.workspaceReaders[i.ID] > 0 { + fail(w, 409, "instance files busy; retry later") + return + } + workspaceMu.Lock() + defer workspaceMu.Unlock() + current, err := a.s.GetInstance(i.ID) + if err != nil || current.UID != i.UID { + fail(w, 404, "not found") + return + } + i = current + if len(in.Password) == 0 || len(in.Password) > 72 { + fail(w, 400, "login password required for pre-upgrade backup") + return + } + var passwordHash string + if a.s.DB.QueryRow("SELECT pass_hash FROM users WHERE id=?", i.UID).Scan(&passwordHash) != nil || bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(in.Password)) != nil { + fail(w, 403, "incorrect login password") + return + } + if a.upgradeRecoveryRequired(i) || i.Status == "error" { + fail(w, 409, "instance requires recovery") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute) + defer cancel() + var old struct { + Config map[string]json.RawMessage + HostConfig map[string]json.RawMessage + State struct { + Status string + Running, Paused, Restarting bool + } + NetworkSettings struct{ Networks map[string]json.RawMessage } + } + if i.ContainerID == "" || a.docker.Do(ctx, "GET", "/containers/"+url.PathEscape(i.ContainerID)+"/json", nil, &old) != nil { + fail(w, 502, "container inspection failed") + return + } + if old.State.Paused || old.State.Restarting || (old.State.Status != "running" && old.State.Status != "exited" && old.State.Status != "created") { + fail(w, 409, "unsupported container state") + return + } + if old.Config == nil || old.HostConfig == nil { + fail(w, 502, "container configuration unavailable") + return + } + verified, err := a.verifiedImages(ctx) + if err != nil { + fail(w, 500, "database error") + return + } + var image struct { + ID string `json:"Id"` + Config struct{ Labels map[string]string } + } + if a.docker.Do(ctx, "GET", "/images/"+url.PathEscape(in.ImageID)+"/json", nil, &image) != nil || image.ID != in.ImageID || image.Config.Labels["dsh-mgr.role"] != "runtime" || image.Config.Labels["dsh-mgr.recipe"] != imageRecipeRev || verified[image.ID] == "" || verified[image.ID] != image.Config.Labels["dsh-mgr.version"] { + fail(w, 400, "local runtime image required") + return + } + root, err := upgradeRoot(a.root, i) + if err != nil { + fail(w, 500, "storage unavailable") + return + } + defer root.Close() + hostRoot := os.Getenv("HOST_DATA_ROOT") + if hostRoot == "" { + hostRoot = a.root + } + homeSource, homeErr := toHostPath(a.root, hostRoot, filepath.Join(root.Name(), "home")) + sharedSource, sharedErr := toHostPath(a.root, hostRoot, filepath.Join(a.root, "workspaces", i.UID, "files")) + if homeErr != nil || sharedErr != nil { + fail(w, 500, "host storage unavailable") + return + } + if err = upgradeWorkspace(old.Config, old.HostConfig, image.Config.Labels["dsh-mgr.recipe"], homeSource, sharedSource); err != nil { + fail(w, 409, "incompatible runtime workspace layout") + return + } + if _, err = a.quotaSnapshot(i.UID, root, 0); err != nil { + fail(w, 413, "quota exceeded or snapshot size unavailable") + return + } + op := store.ID() + snapshot, failedHome := "upgrade-"+op+"-snapshot", "failed-home-"+op + newName := runtimeNetwork(i.ID) + "-upgrade-" + op + _, err = a.s.DB.Exec(`CREATE TABLE IF NOT EXISTS upgrade_journal (id TEXT PRIMARY KEY, instance_id TEXT NOT NULL, old_container TEXT NOT NULL, new_container TEXT NOT NULL DEFAULT '', new_name TEXT NOT NULL, image_id TEXT NOT NULL, snapshot TEXT NOT NULL, failed_home TEXT NOT NULL, phase TEXT NOT NULL)`) + if err != nil { + fail(w, 500, "upgrade journal unavailable") + return + } + _, err = a.s.DB.Exec("CREATE TABLE IF NOT EXISTS upgrade_state (id TEXT PRIMARY KEY, old_status TEXT NOT NULL, was_running INTEGER NOT NULL)") + if err != nil { + fail(w, 500, "upgrade journal unavailable") + return + } + tx, err := a.s.DB.Begin() + if err != nil { + fail(w, 500, "database error") + return + } + _, err = tx.Exec("INSERT INTO upgrade_journal(id,instance_id,old_container,new_name,image_id,snapshot,failed_home,phase) VALUES(?,?,?,?,?,?,?,'snapshotting')", op, i.ID, i.ContainerID, newName, image.ID, snapshot, failedHome) + if err == nil { + _, err = tx.Exec("INSERT INTO upgrade_state VALUES(?,?,?)", op, old.State.Status, old.State.Running) + } + if err == nil { + _, err = tx.Exec("UPDATE instances SET status='error' WHERE id=?", i.ID) + } + if err != nil { + tx.Rollback() + fail(w, 500, "database error") + return + } + if tx.Commit() != nil { + fail(w, 500, "database error") + return + } + phase := func(p string) error { + _, err := a.s.DB.Exec("UPDATE upgrade_journal SET phase=? WHERE id=?", p, op) + return err + } + newID, complete, snapReady, createAttempted := "", false, false, false + stopAttempted := false + defer func() { + if complete { + return + } + cleanup, done := context.WithTimeout(context.Background(), 30*time.Second) + defer done() + if phase("rolling_back") != nil { + return + } + if createAttempted { + id := newID + if id == "" { + id = newName + } + // A timed-out create can still have succeeded; the journaled unique name is authoritative. + _ = a.docker.Do(cleanup, "POST", "/containers/"+url.PathEscape(id)+"/stop?t=10", nil, nil) + if a.removeRuntimeContainer(cleanup, id) != nil { + return + } + } + if snapReady && createAttempted { + if phase("saving_failed_home") != nil { + return + } + if root.Rename("home", failedHome) != nil || upgradeSync(root) != nil { + return + } + if phase("restoring_home") != nil { + return + } + if root.Rename(snapshot, "home") != nil || upgradeSync(root) != nil { + return + } + } + if stopAttempted && old.State.Running { + if phase("restoring_runtime") != nil || a.docker.Do(cleanup, "POST", "/containers/"+url.PathEscape(i.ContainerID)+"/start", nil, nil) != nil { + return + } + } + runtimeAuthCache.Delete(runtimeAuthKey{a, i.ID}) + tx, err := a.s.DB.Begin() + if err != nil { + return + } + defer tx.Rollback() + if _, err = tx.Exec("UPDATE upgrade_journal SET phase='rolled_back' WHERE id=?", op); err != nil { + return + } + if _, err = tx.Exec("UPDATE instances SET status=? WHERE id=?", old.State.Status, i.ID); err != nil { + return + } + _ = tx.Commit() + }() + if old.State.Running { + if phase("stopping") != nil { + fail(w, 500, "upgrade journal unavailable") + return + } + stopAttempted = true + if a.docker.Do(ctx, "POST", "/containers/"+url.PathEscape(i.ContainerID)+"/stop?t=10", nil, nil) != nil { + fail(w, 502, "old runtime stop failed") + return + } + } + stopped, inspectErr := a.docker.Inspect(ctx, i.ContainerID) + if inspectErr != nil || stopped.State.Running || stopped.State.Paused || stopped.State.Restarting { + fail(w, 502, "old runtime did not stop") + return + } + if phase("snapshotting") != nil { + fail(w, 500, "upgrade journal unavailable") + return + } + snapshotBytes, quotaErr := a.quotaSnapshot(i.UID, root, 0) + if quotaErr != nil { + fail(w, 413, "quota exceeded or snapshot size unavailable") + return + } + if upgradeCopy(ctx, root, "home", snapshot, snapshotBytes) != nil { + fail(w, 500, "home snapshot failed") + return + } + snapReady = true + backupDir := filepath.Join(a.root, "backups", i.UID, i.ID) + storage, err := os.OpenRoot(a.root) + if err != nil { + fail(w, 500, "backup storage unavailable") + return + } + err = configCheckPath(storage, filepath.ToSlash(filepath.Join("backups", i.UID, i.ID, "archive"))) + storage.Close() + if err != nil || os.MkdirAll(backupDir, 0700) != nil { + fail(w, 500, "unsafe backup storage") + return + } + entries, err := os.ReadDir(backupDir) + if err != nil || len(entries) >= 20 { + fail(w, 409, "backup retention limit reached") + return + } + backupID := store.ID() + backupPath := filepath.Join(backupDir, backupID+".dshbak") + if err = backup.CreateWithPassword(filepath.Join(root.Name(), "home"), backupPath, in.Password, backup.Metadata{DSHVersion: i.Version, CreatedAt: time.Now().UTC()}); err != nil { + fail(w, 500, "pre-upgrade backup failed") + return + } + backupStat, err := os.Stat(backupPath) + if err != nil { + fail(w, 500, "pre-upgrade backup unavailable") + return + } + if err = a.indexBackup(i, backupID, "pre-upgrade", 2, backupStat.Size()); err != nil { + os.Remove(backupPath) + fail(w, 500, "backup index unavailable") + return + } + delete(old.Config, "Hostname") + old.Config["Image"], _ = json.Marshal(image.ID) + old.Config["Cmd"], _ = json.Marshal([]string{"web", "--no-open", "--port", "3080", "--trusted-host", a.runtimeHost(r)}) + old.Config["HostConfig"], _ = json.Marshal(old.HostConfig) + endpoints := map[string]any{} + for network := range old.NetworkSettings.Networks { + endpoints[network] = map[string]any{} + } + old.Config["NetworkingConfig"], _ = json.Marshal(map[string]any{"EndpointsConfig": endpoints}) + if phase("creating") != nil { + fail(w, 500, "upgrade journal unavailable") + return + } + createAttempted = true + var created struct { + ID string `json:"Id"` + } + if a.docker.Do(ctx, "POST", "/containers/create?name="+url.QueryEscape(newName), old.Config, &created) != nil || created.ID == "" { + fail(w, 502, "upgrade container creation failed") + return + } + newID = created.ID + if _, err = a.s.DB.Exec("UPDATE upgrade_journal SET new_container=?, phase='starting' WHERE id=?", newID, op); err != nil { + fail(w, 500, "upgrade journal unavailable") + return + } + if a.docker.Do(ctx, "POST", "/containers/"+url.PathEscape(newID)+"/start", nil, nil) != nil { + fail(w, 502, "upgrade start failed") + return + } + if phase("readiness") != nil { + fail(w, 500, "upgrade journal unavailable") + return + } + if a.upgradeReady(ctx, newID, i.ID, a.runtimeHost(r)) != nil { + fail(w, 502, "upgrade readiness failed") + return + } + tx, err = a.s.DB.Begin() + if err != nil { + fail(w, 500, "database error") + return + } + defer tx.Rollback() + _, err = tx.Exec("UPDATE instances SET container_id=?,image_id=?,version=?,status='running' WHERE id=?", newID, image.ID, image.Config.Labels["dsh-mgr.version"], i.ID) + if err == nil { + _, err = tx.Exec("UPDATE upgrade_journal SET phase='committed' WHERE id=?", op) + } + if err != nil { + fail(w, 500, "database error") + return + } + if tx.Commit() != nil { + fail(w, 500, "database error") + return + } + complete = true + if a.removeRuntimeContainer(ctx, i.ContainerID) != nil || phase("complete") != nil { + a.s.SetInstance(i.ID, "error") + fail(w, 502, "upgrade committed; old container cleanup requires recovery") + return + } + runtimeAuthCache.Delete(runtimeAuthKey{a, i.ID}) + i.ContainerID, i.ImageID, i.Version, i.Status = newID, image.ID, image.Config.Labels["dsh-mgr.version"], "running" + a.s.AddAudit(i.UID, "instance.upgrade", i.ID, image.ID) + writeJSON(w, i) +} + +func (a *API) upgradeReady(ctx context.Context, id, instanceID, host string) error { + ctx, cancel := context.WithTimeout(ctx, 150*time.Second) + defer cancel() + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + for { + c, err := a.docker.Inspect(ctx, id) + if err != nil { + return errors.New("readiness inspection failed") + } + if !c.State.Running { + return errors.New("runtime stopped") + } + logs, err := a.docker.Logs(ctx, id) + if err == nil && latestRuntimeToken(logs) != "" && (c.State.Health == nil || c.State.Health.Status == "healthy") { + { + ip := c.NetworkSettings.Networks[runtimeNetwork(instanceID)].IPAddress + if net.ParseIP(ip) != nil { + target := &url.URL{Scheme: "http", Host: net.JoinHostPort(ip, "8080")} + if cookies, e := exchangeRuntimeCookie(ctx, target, host, latestRuntimeToken(logs)); e == nil { + req, _ := http.NewRequestWithContext(ctx, "GET", target.String()+"/", nil) + req.Host = host + req.Header.Set("Cookie", cookies) + client := &http.Client{Transport: runtimeTransport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + if resp, e := client.Do(req); e == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + } + } + } + } + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} diff --git a/internal/api/upgrade_test.go b/internal/api/upgrade_test.go new file mode 100644 index 0000000..245f095 --- /dev/null +++ b/internal/api/upgrade_test.go @@ -0,0 +1,528 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "git.misaka.ren/m1saka/dsh-web/internal/store" + "golang.org/x/crypto/bcrypt" +) + +func TestUpgradeWorkspaceCompatibility(t *testing.T) { + for _, tc := range []struct { + name, recipe, target string + shared, reject bool + }{ + {"legacy-to-seven", "7", "/data/dsh-home", true, false}, + {"single-to-seven", "7", "/workspace", false, false}, + {"migrated-to-seven", "7", "/workspace", true, false}, + {"legacy-to-six", "6", "/data/dsh-home", true, false}, + {"legacy-to-unknown", "", "/data/dsh-home", true, false}, + {"single-to-six", "6", "/workspace", false, true}, + {"single-to-unknown", "", "/workspace", false, true}, + } { + t.Run(tc.name, func(t *testing.T) { + mounts := []map[string]any{{"Type": "bind", "Source": "/instance/home", "Target": tc.target, "ReadOnly": false}} + if tc.shared { + target := "/workspace" + if tc.target == "/workspace" { + target = "/shared" + } + mounts = append(mounts, map[string]any{"Type": "bind", "Source": "/user/files", "Target": target, "ReadOnly": true}) + } + raw, _ := json.Marshal(mounts) + host := map[string]json.RawMessage{"Mounts": raw} + config := map[string]json.RawMessage{"Env": json.RawMessage(`["CUSTOM=yes","DSH_HOME=/data/dsh-home","HOME=/data/dsh-home","XDG_DATA_HOME=/old/data","XDG_CONFIG_HOME=/old/config"]`)} + before := string(config["Env"]) + err := upgradeWorkspace(config, host, tc.recipe, "/instance/home", "/user/files") + if (err != nil) != tc.reject { + t.Fatalf("error=%v", err) + } + if tc.reject || tc.recipe != "7" { + if string(host["Mounts"]) != string(raw) || string(config["Env"]) != before { + t.Fatal("legacy or rejected config changed") + } + return + } + if !strings.Contains(string(config["Env"]), "CUSTOM=yes") || strings.Contains(string(config["Env"]), "/data/dsh-home") { + t.Fatal(string(config["Env"])) + } + var got []map[string]any + if err := json.Unmarshal(host["Mounts"], &got); err != nil { + t.Fatal(err) + } + if got[0]["Source"] != "/instance/home" || got[0]["Target"] != "/workspace" || len(got) != 1 { + t.Fatal(got) + } + first, _ := json.Marshal([]any{config, host}) + if err := upgradeWorkspace(config, host, tc.recipe, "/instance/home", "/user/files"); err != nil { + t.Fatal(err) + } + second, _ := json.Marshal([]any{config, host}) + if string(first) != string(second) { + t.Fatal("conversion is not idempotent") + } + }) + } +} + +func TestUpgradeWorkspaceRejectsAmbiguousMounts(t *testing.T) { + for _, extra := range []string{ + `{"Type":"bind","Source":"/other","Target":"/shared"}`, + `{"Type":"bind","Source":"/other","Target":"/workspace"}`, + `{"Type":"volume","Source":"/user/files","Target":"/workspace"}`, + `{"Type":"bind","Source":"/instance/home","Target":"/workspace"}`, + } { + config := map[string]json.RawMessage{"Env": json.RawMessage(`["CUSTOM=yes"]`)} + host := map[string]json.RawMessage{"Mounts": json.RawMessage(`[{"Type":"bind","Source":"/instance/home","Target":"/data/dsh-home"},` + extra + `]`)} + before, _ := json.Marshal([]any{config, host}) + if err := upgradeWorkspace(config, host, "7", "/instance/home", "/user/files"); err == nil { + t.Fatal("accepted ambiguous mounts", extra) + } + after, _ := json.Marshal([]any{config, host}) + if string(before) != string(after) { + t.Fatal("rejected config mutated") + } + } +} + +func TestUpgradeLifecycle(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("requires chown") + } + for _, mode := range []string{"success", "start-failure", "readiness-failure", "cleanup-failure", "running", "running-failure", "restart-failure", "delete-failure", "invalid-image", "unverified-image", "failed-build", "version-mismatch", "image-id-mismatch", "db-failure"} { + t.Run(mode, func(t *testing.T) { + image := "sha256:" + strings.Repeat("b", 64) + var a *API + var home, shared string + var calls []string + var body map[string]json.RawMessage + running := mode == "running" || mode == "running-failure" || mode == "restart-failure" + stopped := false + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Host != "panel.test" { + t.Error("wrong trusted host") + } + if r.URL.Query().Get("token") == "must-not-leak" { + http.SetCookie(w, &http.Cookie{Name: "dsh_auth", Value: "ready"}) + w.WriteHeader(303) + return + } + if r.Header.Get("Cookie") != "dsh_auth=ready" { + t.Error("missing authenticated readiness") + } + w.WriteHeader(200) + })) + defer upstream.Close() + transport := runtimeTransport + runtimeTransport = transport.Clone() + runtimeTransport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, network, strings.TrimPrefix(upstream.URL, "http://")) + } + defer func() { runtimeTransport.CloseIdleConnections(); runtimeTransport = transport }() + handler := func(w http.ResponseWriter, r *http.Request) { + p := strings.TrimPrefix(r.URL.Path, "/v1.47") + calls = append(calls, r.Method+" "+p) + switch { + case p == "/containers/new/exec": + writeJSON(w, map[string]string{"Id": "reconcile"}) + case p == "/exec/reconcile/json": + writeJSON(w, map[string]any{"Running": false, "ExitCode": 0}) + case p == "/containers/old/json": + state := "exited" + if running { + state = "running" + } + writeJSON(w, map[string]any{"Config": map[string]any{"Hostname": "old", "Cmd": []string{"obsolete-flag"}, "Image": "old-image", "Env": []string{"CUSTOM=yes"}, "User": "1000:1000"}, "HostConfig": map[string]any{"NetworkMode": "dsh-inst-one", "Memory": 9007199254740993, "Mounts": []map[string]string{{"Type": "bind", "Source": home, "Target": "/data/dsh-home"}, {"Type": "bind", "Source": shared, "Target": "/workspace"}}}, "State": map[string]any{"Status": state, "Running": running && !stopped}, "NetworkSettings": map[string]any{"Networks": map[string]any{"dsh-inst-one": map[string]any{"IPAddress": "172.18.0.3", "Aliases": []string{"old"}}}}}) + case strings.HasPrefix(p, "/images/"): + role := "runtime" + if mode == "invalid-image" { + role = "builder" + } + writeJSON(w, map[string]any{"Id": image, "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.role": role, "dsh-mgr.version": "v2", "dsh-mgr.recipe": imageRecipeRev}}}) + case p == "/containers/create": + if !strings.HasPrefix(r.URL.Query().Get("name"), "dsh-inst-one-upgrade-") { + t.Error("missing unique name") + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + before, _ := a.s.GetInstance("one") + if before.ContainerID != "old" || before.Status != "error" { + t.Errorf("premature switch: %+v", before) + } + writeJSON(w, map[string]string{"Id": "new"}) + case p == "/containers/old/stop": + stopped = true + w.WriteHeader(204) + case p == "/containers/old/start": + restored, _ := os.ReadFile(filepath.Join(home, ".config", "state")) + if string(restored) != "original" { + t.Error("old runtime restarted before home restore") + } + if mode == "restart-failure" { + http.Error(w, "failed", 500) + return + } + w.WriteHeader(204) + case p == "/containers/old" && r.Method == "DELETE": + current, _ := a.s.GetInstance("one") + if current.ContainerID != "new" { + t.Error("old removed before commit") + } + if mode == "delete-failure" { + http.Error(w, "failed", 500) + return + } + w.WriteHeader(204) + case p == "/containers/new/start": + if err := os.WriteFile(filepath.Join(home, ".config", "state"), []byte("migrated"), 0600); err != nil { + t.Error(err) + } + if err := os.WriteFile(filepath.Join(shared, "file"), []byte("shared-new"), 0600); err != nil { + t.Error(err) + } + if mode == "start-failure" || mode == "cleanup-failure" || mode == "running-failure" || mode == "restart-failure" { + http.Error(w, "token=must-not-leak", 500) + return + } + w.WriteHeader(204) + case p == "/containers/new/json": + writeJSON(w, map[string]any{"State": map[string]any{"Running": mode != "readiness-failure"}, "NetworkSettings": map[string]any{"Networks": map[string]any{"dsh-inst-one": map[string]string{"IPAddress": "127.0.0.1"}}}}) + case p == "/containers/new/logs": + if mode == "db-failure" { + _, err := a.s.DB.Exec("CREATE TRIGGER reject_switch BEFORE UPDATE OF container_id ON instances BEGIN SELECT RAISE(FAIL, 'blocked'); END") + if err != nil { + t.Error(err) + } + } + fmt.Fprint(w, "http://runtime/?token=must-not-leak\n") + case p == "/containers/new" && r.Method == "DELETE": + if mode == "cleanup-failure" { + http.Error(w, "token=must-not-leak", 500) + return + } + w.WriteHeader(204) + default: + w.WriteHeader(204) + } + } + var sid string + a, sid = runtimeTestAPI(t, handler) + hash, _ := bcrypt.GenerateFromPassword([]byte("upgrade-password"), bcrypt.MinCost) + a.s.DB.Exec("UPDATE users SET pass_hash=?,quota=10737418240 WHERE id='owner'", string(hash)) + if _, err := a.verifiedImages(context.Background()); err != nil { + t.Fatal(err) + } + if mode != "unverified-image" { + status, version, builtImage := "success", "v2", image + switch mode { + case "failed-build": + status = "failed" + case "version-mismatch": + version = "v3" + case "image-id-mismatch": + builtImage = "sha256:" + strings.Repeat("c", 64) + } + if _, err := a.s.DB.Exec("INSERT INTO image_builds(id,version,status,image_id,created_at,recipe_rev,platform) VALUES(?,?,?,?,?,?,?)", store.ID(), version, status, builtImage, time.Now().UTC().Format(time.RFC3339Nano), imageRecipeRev, imagePlatform); err != nil { + t.Fatal(err) + } + } + base := filepath.Join(a.root, "workspaces", "owner", "instances", "one") + home = filepath.Join(base, "home") + shared = filepath.Join(a.root, "workspaces", "owner", "files") + for _, p := range []string{filepath.Join(home, ".config"), shared} { + if err := os.MkdirAll(p, 0750); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(home, ".config", "state"), []byte("original"), 0600); err != nil { + t.Fatal(err) + } + i := store.Instance{ID: "one", UID: "owner", Name: "one", ContainerID: "old", ImageID: "old-image", Version: "v1", Status: "exited", Created: time.Now()} + if err := a.s.AddInstance(i); err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + stale := i + stale.ContainerID = "stale" + a.upgrade(w, runtimeRequest("POST", "/api/instances/one/upgrade", fmt.Sprintf(`{"image_id":%q,"password":"upgrade-password"}`, image), sid), stale) + if strings.Contains(w.Body.String(), "must-not-leak") { + t.Fatal("leaked token") + } + got, err := a.s.GetInstance(i.ID) + if err != nil { + t.Fatal(err) + } + if mode == "invalid-image" || mode == "unverified-image" || mode == "failed-build" || mode == "version-mismatch" || mode == "image-id-mismatch" { + want := 400 + if w.Code != want { + t.Fatalf("%d %s", w.Code, w.Body.String()) + } + if got.ContainerID != i.ContainerID || got.ImageID != i.ImageID || got.Version != i.Version || got.Status != i.Status { + t.Fatalf("rejected upgrade changed instance: %+v", got) + } + for _, call := range calls { + if !strings.HasPrefix(call, "GET ") { + t.Errorf("rejected upgrade mutated runtime: %s", call) + } + } + return + } + var phase, snapshot, failed string + if err := a.s.DB.QueryRow("SELECT phase,snapshot,failed_home FROM upgrade_journal WHERE instance_id='one'").Scan(&phase, &snapshot, &failed); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(home, ".config", "state")) + if err != nil { + t.Fatal(err) + } + if mode == "success" || mode == "running" { + if w.Code != 200 || got.ContainerID != "new" || got.Version != "v2" || phase != "complete" || string(data) != "migrated" { + t.Fatalf("%d %s %+v phase=%s home=%s", w.Code, w.Body.String(), got, phase, data) + } + saved, err := os.ReadFile(filepath.Join(base, snapshot, ".config", "state")) + if err != nil || string(saved) != "original" { + t.Fatalf("snapshot %q %v", saved, err) + } + st, _ := os.Stat(filepath.Join(base, snapshot, ".config", "state")) + if st.Sys().(*syscall.Stat_t).Uid != 1000 { + t.Error("snapshot ownership") + } + } else if mode == "delete-failure" { + if w.Code != 502 || got.ContainerID != "new" || got.Status != "error" || phase != "committed" || string(data) != "migrated" { + t.Fatalf("unsafe delete failure %+v %s", got, phase) + } + } else if mode == "restart-failure" { + if w.Code < 400 || got.ContainerID != "old" || got.Status != "error" || phase != "restoring_runtime" || string(data) != "original" { + t.Fatalf("unsafe restart failure %+v %s", got, phase) + } + } else if mode == "cleanup-failure" { + if got.Status != "error" || phase != "rolling_back" || string(data) != "migrated" || !a.upgradeRecoveryRequired(got) { + t.Fatalf("unsafe cleanup %+v %s %s", got, phase, data) + } + } else { + if w.Code < 400 || got.ContainerID != "old" || got.ImageID != "old-image" || got.Status != map[bool]string{true: "running", false: "exited"}[running] || phase != "rolled_back" || string(data) != "original" { + t.Fatalf("%d %+v phase=%s home=%s", w.Code, got, phase, data) + } + failedData, err := os.ReadFile(filepath.Join(base, failed, ".config", "state")) + if err != nil || string(failedData) != "migrated" { + t.Fatalf("failed home %s %v", failedData, err) + } + } + sharedData, _ := os.ReadFile(filepath.Join(shared, "file")) + if string(sharedData) != "shared-new" { + t.Error("shared workspace rolled back") + } + if _, ok := body["Hostname"]; ok { + t.Error("hostname copied") + } + if string(body["Env"]) != `["CUSTOM=yes","DSH_HOME=/workspace","HOME=/workspace","XDG_DATA_HOME=/workspace/.local/share","XDG_CONFIG_HOME=/workspace/.config"]` || !strings.Contains(string(body["HostConfig"]), "9007199254740993") { + t.Error("raw config not preserved") + } + if strings.Contains(string(body["HostConfig"]), `"Target":"/shared"`) || strings.Contains(string(body["HostConfig"]), "/data/dsh-home") { + t.Error("workspace mounts not migrated") + } + if strings.Contains(string(body["NetworkingConfig"]), "172.18") || strings.Contains(string(body["NetworkingConfig"]), "Aliases") { + t.Error("dynamic endpoint copied") + } + if string(body["Cmd"]) != `["web","--no-open","--port","3080","--trusted-host","panel.test"]` { + t.Errorf("command not recalculated: %s", body["Cmd"]) + } + has := func(want string) bool { + for _, call := range calls { + if call == want { + return true + } + } + return false + } + if has("DELETE /containers/old") != (mode == "success" || mode == "running" || mode == "delete-failure") { + t.Error("old deletion lifecycle") + } + if has("POST /containers/old/start") != (mode == "running-failure" || mode == "restart-failure") { + t.Error("old restart lifecycle") + } + if has("POST /containers/old/stop") != running { + t.Error("old stop lifecycle") + } + }) + } +} + +func TestUpgradeSnapshotRejectsUnsafeFiles(t *testing.T) { + for _, kind := range []string{"symlink", "fifo", "oversize", "directory-symlink"} { + t.Run(kind, func(t *testing.T) { + base := t.TempDir() + if err := os.Mkdir(filepath.Join(base, "home"), 0700); err != nil { + t.Fatal(err) + } + p := filepath.Join(base, "home", "bad") + var err error + switch kind { + case "symlink": + err = os.Symlink("/etc/passwd", p) + case "fifo": + err = syscall.Mkfifo(p, 0600) + case "oversize": + err = os.WriteFile(p, []byte("12345"), 0600) + case "directory-symlink": + err = os.Symlink("/tmp", p) + } + if err != nil { + t.Fatal(err) + } + root, err := os.OpenRoot(base) + if err != nil { + t.Fatal(err) + } + defer root.Close() + if upgradeCopy(context.Background(), root, "home", "snapshot", 4) == nil { + t.Fatal("unsafe file accepted") + } + }) + } +} + +func TestUpgradeSnapshotRelativeLinks(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("requires chown") + } + for _, mode := range []string{"pnpm", "escape", "chain-escape", "loop", "dangling"} { + t.Run(mode, func(t *testing.T) { + base := t.TempDir() + home := filepath.Join(base, "home") + pkg := filepath.Join(home, "node_modules/.pnpm/pkg/node_modules/pkg") + if err := os.MkdirAll(pkg, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(pkg, "index.js"), []byte("original"), 0640); err != nil { + t.Fatal(err) + } + link := ".pnpm/pkg/node_modules/pkg" + switch mode { + case "escape": + link = "../../outside" + case "chain-escape": + if err := os.Symlink(".", filepath.Join(home, "alias")); err != nil { + t.Fatal(err) + } + link = "../alias/../outside" + case "loop": + link = "pkg" + case "dangling": + link = "missing" + } + if err := os.Symlink(link, filepath.Join(home, "node_modules/pkg")); err != nil { + t.Fatal(err) + } + root, err := os.OpenRoot(base) + if err != nil { + t.Fatal(err) + } + defer root.Close() + err = upgradeCopy(context.Background(), root, "home", "snapshot", 1024) + if mode != "pnpm" { + if err == nil { + t.Fatal("unsafe link accepted") + } + return + } + if err != nil { + t.Fatal(err) + } + got, err := root.Readlink("snapshot/node_modules/pkg") + if err != nil || got != link { + t.Fatalf("link not preserved: %q %v", got, err) + } + if err := root.Rename("home", "failed-home"); err != nil { + t.Fatal(err) + } + if err := root.Rename("snapshot", "home"); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(home, "node_modules/pkg/index.js")) + if err != nil || string(data) != "original" { + t.Fatalf("rollback link broken: %q %v", data, err) + } + }) + } +} + +func TestUpgradeReadinessRequiresToken(t *testing.T) { + a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/json") { + writeJSON(w, map[string]any{"State": map[string]bool{"Running": true}}) + } else { + fmt.Fprint(w, "starting without token") + } + }) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + if a.upgradeReady(ctx, "new", "one", "panel.test") == nil { + t.Fatal("accepted without token") + } +} + +func TestUpgradeReadinessRequiresAuthenticatedHTTP(t *testing.T) { + for _, mode := range []string{"success", "token-rejected", "missing-cookie", "unauthorized", "redirect", "unavailable"} { + t.Run(mode, func(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("token") != "" { + if mode == "token-rejected" { + w.WriteHeader(401) + return + } + if mode != "missing-cookie" { + http.SetCookie(w, &http.Cookie{Name: "dsh_auth", Value: "ready"}) + } + w.WriteHeader(303) + return + } + if r.Header.Get("Cookie") != "dsh_auth=ready" { + t.Error("cookie not sent") + } + switch mode { + case "unauthorized": + w.WriteHeader(401) + case "redirect": + w.WriteHeader(302) + case "unavailable": + w.WriteHeader(503) + default: + w.WriteHeader(200) + } + })) + defer upstream.Close() + transport := runtimeTransport + runtimeTransport = transport.Clone() + runtimeTransport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, network, strings.TrimPrefix(upstream.URL, "http://")) + } + defer func() { runtimeTransport.CloseIdleConnections(); runtimeTransport = transport }() + a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/json") { + writeJSON(w, map[string]any{"State": map[string]bool{"Running": true}, "NetworkSettings": map[string]any{"Networks": map[string]any{"dsh-inst-one": map[string]string{"IPAddress": "127.0.0.1"}}}}) + } else { + fmt.Fprint(w, "http://runtime/?token=ready\n") + } + }) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + err := a.upgradeReady(ctx, "new", "one", "panel.test") + if (err == nil) != (mode == "success") { + t.Fatalf("readiness=%v", err) + } + }) + } +} diff --git a/internal/api/workspace_concurrency_test.go b/internal/api/workspace_concurrency_test.go new file mode 100644 index 0000000..8f49419 --- /dev/null +++ b/internal/api/workspace_concurrency_test.go @@ -0,0 +1,219 @@ +package api + +import ( + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "git.misaka.ren/m1saka/dsh-web/internal/store" +) + +type pausedDownload struct { + *httptest.ResponseRecorder + started chan struct{} + release chan struct{} + once sync.Once +} + +func (w *pausedDownload) Write(p []byte) (int, error) { + w.once.Do(func() { close(w.started) }) + <-w.release + return w.ResponseRecorder.Write(p) +} + +func TestSlowDownloadDoesNotHoldLifecycleLock(t *testing.T) { + a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { t.Error("unexpected Docker call") }) + for _, id := range []string{"one", "two"} { + if err := a.s.AddInstance(store.Instance{ID: id, UID: "owner"}); err != nil { + t.Fatal(err) + } + dir := filepath.Join(a.root, "workspaces/owner/instances", id, "home") + if err := os.MkdirAll(dir, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "file.txt"), []byte("content"), 0600); err != nil { + t.Fatal(err) + } + } + w := &pausedDownload{ResponseRecorder: httptest.NewRecorder(), started: make(chan struct{}), release: make(chan struct{})} + done := make(chan struct{}) + go func() { + defer close(done) + a.workspace(w, runtimeRequest("GET", "/api/ws/download?instance=one&path=file.txt", "", sid)) + }() + defer func() { close(w.release); <-done }() + select { + case <-w.started: + case <-time.After(3 * time.Second): + t.Fatal("download not started") + } + if !a.mu.TryLock() { + t.Fatal("slow network holds global lifecycle lock") + } + active := a.workspaceReaders["one"] + a.mu.Unlock() + if active != 1 { + t.Fatalf("lease count %d", active) + } + other := httptest.NewRecorder() + otherDone := make(chan struct{}) + go func() { + defer close(otherDone) + a.workspace(other, runtimeRequest("GET", "/api/ws/tree?instance=two", "", sid)) + }() + select { + case <-otherDone: + case <-time.After(3 * time.Second): + t.Fatal("other instance blocked") + } + if other.Code != 200 { + t.Fatalf("other instance: %d", other.Code) + } + proxyDone := make(chan struct{}) + proxyResponse := httptest.NewRecorder() + go func() { + defer close(proxyDone) + a.proxy(proxyResponse, runtimeRequest("GET", "/dsh/missing/", "", sid)) + }() + select { + case <-proxyDone: + case <-time.After(3 * time.Second): + t.Fatal("proxy entry blocked by download") + } + if proxyResponse.Code != 404 { + t.Fatalf("proxy status %d", proxyResponse.Code) + } + for _, action := range []string{"restore", "upgrade", "stop", ""} { + method := "POST" + if action == "" { + method = "DELETE" + } + response := httptest.NewRecorder() + url := "/api/instances/one" + if action != "" { + url += "/" + action + } + body := `{}` + if action == "upgrade" { + body = `{"image_id":"sha256:` + strings.Repeat("a", 64) + `"}` + } + a.instance(response, runtimeRequest(method, url, body, sid)) + if response.Code != 409 { + t.Errorf("%s during download: %d %s", action, response.Code, response.Body.String()) + } + } +} + +func TestDirectoryEntryLimit(t *testing.T) { + dir := t.TempDir() + for i := 0; i <= maxDirectoryEntries; i++ { + f, err := os.Create(filepath.Join(dir, fmt.Sprint(i))) + if err != nil { + t.Fatal(err) + } + f.Close() + } + root, err := os.OpenRoot(dir) + if err != nil { + t.Fatal(err) + } + defer root.Close() + if _, err = readRootDir(root, "."); !errors.Is(err, errDirectoryLimit) { + t.Fatalf("limit: %v", err) + } + if err = os.Remove(filepath.Join(dir, fmt.Sprint(maxDirectoryEntries))); err != nil { + t.Fatal(err) + } + entries, err := readRootDir(root, ".") + if err != nil || len(entries) != maxDirectoryEntries { + t.Fatalf("boundary: %d %v", len(entries), err) + } +} + +type pausedUpload struct { + started chan struct{} + release chan struct{} + once sync.Once +} + +func (r *pausedUpload) Read(p []byte) (int, error) { + r.once.Do(func() { close(r.started) }) + <-r.release + return 0, io.EOF +} +func (r *pausedUpload) Close() error { return nil } + +func TestSlowUploadDoesNotHoldLifecycleLock(t *testing.T) { + a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { t.Error("unexpected Docker call") }) + if err := a.s.AddInstance(store.Instance{ID: "one", UID: "owner"}); err != nil { + t.Fatal(err) + } + if _, err := a.s.DB.Exec("UPDATE users SET quota=? WHERE id='owner'", 1<<30); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(a.root, "workspaces/owner/instances/one/home"), 0750); err != nil { + t.Fatal(err) + } + body := &pausedUpload{started: make(chan struct{}), release: make(chan struct{})} + r := runtimeRequest("POST", "/api/ws/upload?instance=one", "", sid) + r.Body = body + r.Header.Set("Content-Type", "multipart/form-data; boundary=test") + done := make(chan struct{}) + go func() { defer close(done); a.workspace(httptest.NewRecorder(), r) }() + defer func() { close(body.release); <-done }() + select { + case <-body.started: + case <-time.After(3 * time.Second): + t.Fatal("upload not reading body") + } + if !a.mu.TryLock() { + t.Fatal("slow upload holds global lock") + } + a.mu.Unlock() + if !workspaceMu.TryLock() { + t.Fatal("slow upload holds workspace write lock") + } + workspaceMu.Unlock() +} + +func TestWorkspaceErrorResponseReleasesLocks(t *testing.T) { + a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { t.Error("unexpected Docker call") }) + if err := a.s.AddInstance(store.Instance{ID: "one", UID: "owner"}); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(a.root, "workspaces/owner/instances/one/home"), 0750); err != nil { + t.Fatal(err) + } + w := &pausedDownload{ResponseRecorder: httptest.NewRecorder(), started: make(chan struct{}), release: make(chan struct{})} + done := make(chan struct{}) + go func() { + defer close(done) + a.workspace(w, runtimeRequest("GET", "/api/ws/tree?instance=one&path=missing", "", sid)) + }() + defer func() { close(w.release); <-done }() + select { + case <-w.started: + case <-time.After(3 * time.Second): + t.Fatal("response not started") + } + if !workspaceMu.TryLock() { + t.Fatal("error response holds workspace lock") + } + workspaceMu.Unlock() + if !a.mu.TryLock() { + t.Fatal("error response holds lifecycle lock") + } + leases := len(a.workspaceReaders) + a.mu.Unlock() + if leases != 0 { + t.Fatal("response retains lease") + } +} diff --git a/internal/api/workspace_instance_test.go b/internal/api/workspace_instance_test.go new file mode 100644 index 0000000..02ec356 --- /dev/null +++ b/internal/api/workspace_instance_test.go @@ -0,0 +1,70 @@ +package api + +import ( + "git.misaka.ren/m1saka/dsh-web/internal/store" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWorkspaceInstanceScope(t *testing.T) { + a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { t.Error("unexpected Docker call") }) + for _, x := range []struct{ id, uid string }{{"one", "owner"}, {"two", "owner"}, {"foreign", "someone"}} { + if err := a.s.AddInstance(store.Instance{ID: x.id, UID: x.uid}); err != nil { + t.Fatal(err) + } + dir := filepath.Join(a.root, "workspaces", x.uid, "instances", x.id, "home") + if err := os.MkdirAll(dir, 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, x.id+".txt"), []byte(x.id), 0600); err != nil { + t.Fatal(err) + } + } + for _, tc := range []struct { + query string + status int + contains string + }{ + {"instance=one", 200, "one.txt"}, {"instance=two", 200, "two.txt"}, + {"instance=foreign", 404, ""}, {"instance=missing", 404, ""}, + {"instance=one&path=../two", 400, ""}, + } { + w := httptest.NewRecorder() + a.workspace(w, runtimeRequest("GET", "/api/ws/tree?"+tc.query, "", sid)) + if w.Code != tc.status || tc.contains != "" && !strings.Contains(w.Body.String(), tc.contains) { + t.Fatalf("%s: %d %s", tc.query, w.Code, w.Body.String()) + } + } + w := httptest.NewRecorder() + a.workspace(w, runtimeRequest("POST", "/api/ws/mkdir?instance=two", `{"path":"created"}`, sid)) + if w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + if _, err := os.Stat(filepath.Join(a.root, "workspaces/owner/instances/two/home/created")); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(a.root, "workspaces/owner/instances/one/home/created")); !os.IsNotExist(err) { + t.Fatal("cross instance write") + } + dir := filepath.Join(a.root, "workspaces/owner/instances/one/home") + if err := os.Symlink(t.TempDir(), filepath.Join(dir, "escape")); err != nil { + t.Fatal(err) + } + w = httptest.NewRecorder() + a.workspace(w, runtimeRequest("GET", "/api/ws/tree?instance=one&path=escape", "", sid)) + if w.Code != 404 { + t.Fatal("symlink traversal allowed", w.Code) + } + if _, err := a.s.DB.Exec("UPDATE instances SET status='error' WHERE id='two'"); err != nil { + t.Fatal(err) + } + w = httptest.NewRecorder() + a.workspace(w, runtimeRequest("POST", "/api/ws/mkdir?instance=two", "{\"path\":\"blocked\"}", sid)) + if w.Code != 409 { + t.Fatal("recovery write permitted", w.Code) + } +} diff --git a/internal/backup/backup.go b/internal/backup/backup.go new file mode 100644 index 0000000..c34af83 --- /dev/null +++ b/internal/backup/backup.go @@ -0,0 +1,515 @@ +// Package backup handles configuration archives. Callers must stop containers, +// exclude concurrent filesystem writers, and manage rollback across files. +package backup + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "sort" + "strings" + "time" +) + +const ( + MaxTotalBytes = 32 << 20 + MaxFiles = 4096 + maxArchiveBytes = 4*((MaxTotalBytes+2)/3) + 4096 + magic = "DSHBACKUP\x01" + magicV2 = "DSHBACKUP\x02" + SchemaVersion = 2 + maxManifestBytes = 4096 + maxTarBytes = MaxTotalBytes + MaxFiles*4096 + maxManifestBytes +) + +var allowed = [...]string{".credentials.yaml", "cordis.patch.yml", "profiles/web/package.json", "profiles/web/cordis.patch.yml", "profiles/web/pnpm-lock.yaml", "dsh-ssh.json"} +var resources = [...]string{"skins", "pets", "agent-presets", ".agent-presets"} + +type Metadata struct { + DSHVersion string `json:"dsh_version"` + CreatedAt time.Time `json:"created_at"` +} + +type manifest struct { + SchemaVersion int `json:"schema_version"` + Metadata +} + +func allowedPath(name string, recursive bool) bool { + if name == "." || len(name) > 1024 || path.Clean(name) != name || strings.ContainsAny(name, "\\\x00:") || path.IsAbs(name) { + return false + } + for _, valid := range allowed { + if name == valid { + return true + } + } + if recursive { + for _, dir := range resources { + if strings.HasPrefix(name, dir+"/") { + return true + } + } + } + return false +} + +type payload struct { + Version int `json:"version"` + Files map[string][]byte `json:"files"` +} + +func newGCM(key []byte) (cipher.AEAD, error) { + b, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + return cipher.NewGCM(b) +} + +func openHome(home string) (*os.Root, error) { + info, err := os.Lstat(home) + if err != nil { + return nil, err + } + if info.Mode()&os.ModeSymlink != 0 { + return nil, errors.New("home must not be a symlink") + } + return os.OpenRoot(home) +} + +// os.Root permits in-root symlinks, so reject them explicitly in each component. +func checkPath(root *os.Root, name string) error { + parts := strings.Split(name, "/") + for i := range parts { + info, err := root.Lstat(strings.Join(parts[:i+1], "/")) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("symlink rejected: %s", name) + } + if i < len(parts)-1 && !info.IsDir() { + return fmt.Errorf("not a directory: %s", name) + } + if i == len(parts)-1 && !info.Mode().IsRegular() { + return fmt.Errorf("not a regular file: %s", name) + } + } + return nil +} + +func readBounded(f *os.File, limit int64) ([]byte, error) { + info, err := f.Stat() + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, errors.New("not a regular file") + } + if info.Size() > limit { + return nil, errors.New("backup size limit exceeded") + } + b, err := io.ReadAll(io.LimitReader(f, limit+1)) + if err != nil { + return nil, err + } + if int64(len(b)) > limit { + return nil, errors.New("backup size limit exceeded") + } + return b, nil +} + +// Create writes a 0600 archive using a raw 16, 24, or 32 byte AES key. +// Missing allowlisted files are omitted; destination's parent must exist. +func Create(home, destination string, key []byte) error { + return CreateWithMetadata(home, destination, key, Metadata{}) +} + +// CreateWithMetadata writes an AES-GCM encrypted tar.gz with an authenticated +// manifest. Empty resource directories are omitted; callers must exclude writers. +func CreateWithMetadata(home, destination string, key []byte, metadata Metadata) error { + gcm, err := newGCM(key) + if err != nil { + return err + } + root, err := openHome(home) + if err != nil { + return err + } + defer root.Close() + p := payload{Version: SchemaVersion, Files: make(map[string][]byte)} + remaining := int64(MaxTotalBytes) + addFile := func(name string) error { + if !allowedPath(name, true) { + return fmt.Errorf("path not allowed: %q", name) + } + if err := checkPath(root, name); err != nil { + return err + } + f, err := root.Open(name) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + data, readErr := readBounded(f, remaining) + closeErr := f.Close() + if readErr != nil { + return fmt.Errorf("%s: %w", name, readErr) + } + if closeErr != nil { + return closeErr + } + remaining -= int64(len(data)) + p.Files[name] = data + if len(p.Files) > MaxFiles { + return errors.New("backup file count limit exceeded") + } + return nil + } + for _, name := range allowed { + if err := addFile(name); err != nil { + return err + } + } + visited := 0 + var walk func(string) error + walk = func(name string) error { + visited++ + if visited > MaxFiles*2 || len(name) > 1024 { + return errors.New("resource traversal limit exceeded") + } + info, err := root.Lstat(name) + if err != nil { + return err + } + if !info.IsDir() { + return addFile(name) + } + dir, err := root.Open(name) + if err != nil { + return err + } + defer dir.Close() + for { + entries, err := dir.ReadDir(1) + if err == io.EOF { + return nil + } + if err != nil { + return err + } + for _, entry := range entries { + if err := walk(name + "/" + entry.Name()); err != nil { + return err + } + } + } + } + for _, name := range resources { + info, err := root.Lstat(name) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return err + } + if !info.IsDir() { + return fmt.Errorf("resource root is not a directory: %s", name) + } + if err := walk(name); err != nil { + return err + } + } + plain, err := encodeV2(p.Files, metadata) + if err != nil { + return err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return err + } + archive := append([]byte(magicV2), nonce...) + archive = gcm.Seal(archive, nonce, plain, []byte(magicV2)) + if len(archive) > maxArchiveBytes { + return errors.New("backup archive size limit exceeded") + } + destRoot, err := os.OpenRoot(filepath.Dir(destination)) + if err != nil { + return err + } + defer destRoot.Close() + return replace(destRoot, filepath.Base(destination), archive) +} + +// Restore authenticates and validates everything before changing home. Files +// absent from the archive remain untouched. Replacement is atomic per file only. +func Restore(archive, home string, key []byte) error { + gcm, err := newGCM(key) + if err != nil { + return err + } + aroot, err := os.OpenRoot(filepath.Dir(archive)) + if err != nil { + return err + } + defer aroot.Close() + if err := checkPath(aroot, filepath.Base(archive)); err != nil { + return err + } + f, err := aroot.Open(filepath.Base(archive)) + if err != nil { + return err + } + data, readErr := readBounded(f, maxArchiveBytes) + closeErr := f.Close() + if readErr != nil { + return readErr + } + if closeErr != nil { + return closeErr + } + if len(data) < len(magic)+gcm.NonceSize()+gcm.Overhead() || (!bytes.HasPrefix(data, []byte(magic)) && !bytes.HasPrefix(data, []byte(magicV2))) { + return errors.New("invalid backup format") + } + nonce := data[len(magic) : len(magic)+gcm.NonceSize()] + header := string(data[:len(magic)]) + plain, err := gcm.Open(nil, nonce, data[len(magic)+gcm.NonceSize():], []byte(header)) + if err != nil { + return fmt.Errorf("authenticate backup: %w", err) + } + var p payload + if header == magicV2 { + p.Files, err = decodeV2(plain) + if err != nil { + return err + } + } else { + if err := decodeJSON(plain, &p); err != nil { + return err + } + if p.Version != 1 || p.Files == nil { + return errors.New("unsupported backup payload") + } + } + if len(p.Files) > MaxFiles { + return errors.New("backup file count limit exceeded") + } + total := 0 + for name, data := range p.Files { + if !allowedPath(name, header == magicV2) { + return fmt.Errorf("path not allowed: %q", name) + } + for parent := path.Dir(name); parent != "."; parent = path.Dir(parent) { + if _, exists := p.Files[parent]; exists { + return errors.New("conflicting backup paths") + } + } + total += len(data) + if total > MaxTotalBytes { + return errors.New("backup size limit exceeded") + } + } + root, err := openHome(home) + if err != nil { + return err + } + defer root.Close() + for name := range p.Files { + if err := checkPath(root, name); err != nil { + return err + } + } + for name, data := range p.Files { + if err := root.MkdirAll(filepath.Dir(name), 0700); err != nil { + return err + } + if err := replace(root, name, data); err != nil { + return err + } + } + return nil +} + +func decodeJSON(data []byte, value any) error { + d := json.NewDecoder(bytes.NewReader(data)) + d.DisallowUnknownFields() + if err := d.Decode(value); err != nil { + return err + } + if err := d.Decode(new(any)); err != io.EOF { + return errors.New("trailing backup data") + } + return nil +} + +func encodeV2(files map[string][]byte, metadata Metadata) ([]byte, error) { + if metadata.CreatedAt.IsZero() { + metadata.CreatedAt = time.Now().UTC() + } + if metadata.DSHVersion == "" { + metadata.DSHVersion = "unknown" + } + data, err := json.Marshal(manifest{SchemaVersion, metadata}) + if err != nil { + return nil, err + } + if len(data) > maxManifestBytes { + return nil, errors.New("manifest size limit exceeded") + } + var out bytes.Buffer + gz := gzip.NewWriter(&out) + tw := tar.NewWriter(gz) + write := func(name string, data []byte) error { + if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0600, Size: int64(len(data)), Typeflag: tar.TypeReg, Format: tar.FormatPAX}); err != nil { + return err + } + _, err := tw.Write(data) + return err + } + if err := write("manifest.json", data); err != nil { + return nil, err + } + names := make([]string, 0, len(files)) + for name := range files { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + if err := write(name, files[name]); err != nil { + return nil, err + } + } + if err := tw.Close(); err != nil { + return nil, err + } + if err := gz.Close(); err != nil { + return nil, err + } + return out.Bytes(), nil +} + +func decodeV2(plain []byte) (map[string][]byte, error) { + source := bytes.NewReader(plain) + gz, err := gzip.NewReader(source) + if err != nil { + return nil, err + } + defer gz.Close() + gz.Multistream(false) + unpacked, err := io.ReadAll(io.LimitReader(gz, maxTarBytes+1)) + if err != nil { + return nil, err + } + if len(unpacked) > maxTarBytes { + return nil, errors.New("expanded archive size limit exceeded") + } + if source.Len() != 0 { + return nil, errors.New("trailing gzip data") + } + input := bytes.NewReader(unpacked) + tr := tar.NewReader(input) + files := make(map[string][]byte) + seenManifest := false + remaining := int64(MaxTotalBytes) + for { + h, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + if h.Typeflag != tar.TypeReg || h.Linkname != "" { + return nil, errors.New("non-regular archive entry") + } + limit := remaining + if h.Name == "manifest.json" { + if seenManifest { + return nil, errors.New("duplicate manifest") + } + limit = maxManifestBytes + } else { + if !allowedPath(h.Name, true) { + return nil, fmt.Errorf("path not allowed: %q", h.Name) + } + if _, exists := files[h.Name]; exists { + return nil, errors.New("duplicate archive entry") + } + if len(files) >= MaxFiles { + return nil, errors.New("backup file count limit exceeded") + } + } + if h.Size < 0 || h.Size > limit { + return nil, errors.New("backup size limit exceeded") + } + data, err := io.ReadAll(tr) + if err != nil { + return nil, err + } + if h.Name == "manifest.json" { + var m manifest + if err := decodeJSON(data, &m); err != nil { + return nil, err + } + if m.SchemaVersion != SchemaVersion || m.CreatedAt.IsZero() || m.DSHVersion == "" { + return nil, errors.New("invalid manifest") + } + seenManifest = true + } else { + files[h.Name] = data + remaining -= int64(len(data)) + } + } + if !seenManifest || input.Len() != 0 { + return nil, errors.New("missing manifest or trailing tar data") + } + return files, nil +} + +func replace(root *os.Root, name string, data []byte) error { + if err := checkPath(root, name); err != nil { + return err + } + var random [16]byte + if _, err := rand.Read(random[:]); err != nil { + return err + } + tmp := filepath.Join(filepath.Dir(name), ".backup-"+hex.EncodeToString(random[:])) + f, err := root.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return err + } + defer root.Remove(tmp) + _, writeErr := f.Write(data) + if writeErr == nil { + writeErr = f.Sync() + } + closeErr := f.Close() + if writeErr != nil { + return writeErr + } + if closeErr != nil { + return closeErr + } + if err := checkPath(root, name); err != nil { + return err + } + return root.Rename(tmp, name) +} diff --git a/internal/backup/backup_test.go b/internal/backup/backup_test.go new file mode 100644 index 0000000..c48ca2e --- /dev/null +++ b/internal/backup/backup_test.go @@ -0,0 +1,521 @@ +package backup + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "net" + "os" + "path/filepath" + "testing" + "time" +) + +var testKey = bytes.Repeat([]byte{42}, 32) + +func put(t *testing.T, name string, data []byte) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(name), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(name, data, 0600); err != nil { + t.Fatal(err) + } +} + +func sealed(t *testing.T, p payload) string { + t.Helper() + plain, err := json.Marshal(p) + if err != nil { + t.Fatal(err) + } + return sealPlain(t, magic, plain) +} + +func sealPlain(t *testing.T, header string, plain []byte) string { + t.Helper() + gcm, err := newGCM(testKey) + if err != nil { + t.Fatal(err) + } + nonce := make([]byte, gcm.NonceSize()) + data := append([]byte(header), nonce...) + data = gcm.Seal(data, nonce, plain, []byte(header)) + name := filepath.Join(t.TempDir(), "archive") + put(t, name, data) + return name +} + +func TestResourcesAndManifest(t *testing.T) { + home, dest := t.TempDir(), t.TempDir() + for _, dir := range resources { + put(t, filepath.Join(home, dir, "nested/资源.bin"), []byte{0, 1, 255}) + } + for _, name := range []string{"node_modules/pkg", "worktrees/x", "sessions/x", "logs/x", "manifest.json"} { + put(t, filepath.Join(home, name), []byte("excluded")) + } + metadata := Metadata{DSHVersion: "1.2.3", CreatedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)} + archive := filepath.Join(t.TempDir(), "archive") + if err := CreateWithMetadata(home, archive, testKey, metadata); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(archive) + if err != nil { + t.Fatal(err) + } + if !bytes.HasPrefix(data, []byte(magicV2)) { + t.Fatal("not v2") + } + gcm, _ := newGCM(testKey) + nonce := data[len(magicV2) : len(magicV2)+gcm.NonceSize()] + plain, err := gcm.Open(nil, nonce, data[len(magicV2)+gcm.NonceSize():], []byte(magicV2)) + if err != nil { + t.Fatal(err) + } + gz, err := gzip.NewReader(bytes.NewReader(plain)) + if err != nil { + t.Fatal(err) + } + defer gz.Close() + tr := tar.NewReader(gz) + h, err := tr.Next() + if err != nil || h.Name != "manifest.json" { + t.Fatal("missing manifest", err) + } + b, err := io.ReadAll(tr) + if err != nil { + t.Fatal(err) + } + var m manifest + if err := json.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + if m.SchemaVersion != SchemaVersion || m.Metadata != metadata { + t.Fatalf("manifest: %+v", m) + } + if err := Restore(archive, dest, testKey); err != nil { + t.Fatal(err) + } + for _, dir := range resources { + b, err := os.ReadFile(filepath.Join(dest, dir, "nested/资源.bin")) + if err != nil || !bytes.Equal(b, []byte{0, 1, 255}) { + t.Fatal(dir, err, b) + } + } + if _, err := os.Stat(filepath.Join(dest, "manifest.json")); !os.IsNotExist(err) { + t.Fatal("manifest restored into home") + } +} + +func TestLegacyRestore(t *testing.T) { + home := t.TempDir() + if err := Restore(sealed(t, payload{1, map[string][]byte{allowed[0]: []byte("legacy")}}), home, testKey); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(filepath.Join(home, allowed[0])) + if err != nil || string(b) != "legacy" { + t.Fatal(err, string(b)) + } +} + +type tarEntry struct { + name string + kind byte + data []byte +} + +func makeTar(t *testing.T, entries []tarEntry) []byte { + t.Helper() + var b bytes.Buffer + gz := gzip.NewWriter(&b) + tw := tar.NewWriter(gz) + for _, e := range entries { + h := &tar.Header{Name: e.name, Typeflag: e.kind, Mode: 0600, Size: int64(len(e.data))} + if e.kind == tar.TypeSymlink || e.kind == tar.TypeLink { + h.Linkname = "target" + } + if err := tw.WriteHeader(h); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(e.data); err != nil { + t.Fatal(err) + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + return b.Bytes() +} + +func TestV2RejectsInvalidArchivesBeforeWrites(t *testing.T) { + m, _ := json.Marshal(manifest{SchemaVersion, Metadata{"test", time.Now().UTC()}}) + base := []tarEntry{{"manifest.json", tar.TypeReg, m}, {allowed[0], tar.TypeReg, []byte("new")}} + cases := map[string][]tarEntry{ + "missing manifest": base[1:], + "duplicate manifest": append(append([]tarEntry{}, base...), base[0]), + "duplicate file": append(append([]tarEntry{}, base...), base[1]), + "invalid manifest": {{"manifest.json", tar.TypeReg, []byte(`{"schema_version":3}`)}}, + "conflict": append(append([]tarEntry{}, base...), tarEntry{"pets/a", tar.TypeReg, nil}, tarEntry{"pets/a/b", tar.TypeReg, nil}), + } + for _, name := range []string{"pets/../escape", "pets//a", "pets/./a", "/pets/a", "pets/a/../../escape", "pets/a\\b", "pets/C:bad", "pets", "other/a"} { + cases[name] = append(append([]tarEntry{}, base...), tarEntry{name, tar.TypeReg, nil}) + } + for _, kind := range []byte{tar.TypeSymlink, tar.TypeLink, tar.TypeFifo, tar.TypeChar, tar.TypeBlock, tar.TypeDir} { + cases[fmt.Sprint(kind)] = append(append([]tarEntry{}, base...), tarEntry{"pets/unsafe", kind, nil}) + } + for name, entries := range cases { + t.Run(name, func(t *testing.T) { + home := t.TempDir() + put(t, filepath.Join(home, allowed[0]), []byte("old")) + if err := Restore(sealPlain(t, magicV2, makeTar(t, entries)), home, testKey); err == nil { + t.Fatal("accepted invalid archive") + } + b, _ := os.ReadFile(filepath.Join(home, allowed[0])) + if string(b) != "old" { + t.Fatal("partial write") + } + }) + } +} + +func TestResourceFilesystemRejections(t *testing.T) { + for _, name := range []string{"pets", "pets/nested", "pets/nested/file"} { + t.Run(name, func(t *testing.T) { + home := t.TempDir() + if err := os.MkdirAll(filepath.Dir(filepath.Join(home, name)), 0700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(t.TempDir(), filepath.Join(home, name)); err != nil { + t.Fatal(err) + } + if err := Create(home, filepath.Join(t.TempDir(), "archive"), testKey); err == nil { + t.Fatal("followed resource symlink") + } + p, err := encodeV2(map[string][]byte{"pets/nested/file": []byte("bad")}, Metadata{}) + if err != nil { + t.Fatal(err) + } + if err := Restore(sealPlain(t, magicV2, p), home, testKey); err == nil { + t.Fatal("restore followed symlink") + } + }) + } + home := t.TempDir() + if err := os.Mkdir(filepath.Join(home, "pets"), 0700); err != nil { + t.Fatal(err) + } + l, err := net.Listen("unix", filepath.Join(home, "pets/socket")) + if err != nil { + t.Fatal(err) + } + defer l.Close() + if err := Create(home, filepath.Join(t.TempDir(), "archive"), testKey); err == nil { + t.Fatal("special file accepted") + } +} + +func TestV2LimitsAndCorruption(t *testing.T) { + files := make(map[string][]byte) + home := t.TempDir() + for i := 0; i < MaxFiles; i++ { + name := fmt.Sprintf("pets/%d", i) + files[name] = nil + put(t, filepath.Join(home, name), nil) + } + archive := filepath.Join(t.TempDir(), "archive") + if err := Create(home, archive, testKey); err != nil { + t.Fatal(err) + } + if err := Restore(archive, t.TempDir(), testKey); err != nil { + t.Fatal(err) + } + put(t, filepath.Join(home, "pets/extra"), nil) + if err := Create(home, archive, testKey); err == nil { + t.Fatal("file count accepted") + } + files["pets/extra"] = nil + p, err := encodeV2(files, Metadata{}) + if err != nil { + t.Fatal(err) + } + if _, err := decodeV2(p); err == nil { + t.Fatal("archive file count accepted") + } + p, err = encodeV2(map[string][]byte{"pets/large": make([]byte, MaxTotalBytes+1)}, Metadata{}) + if err != nil { + t.Fatal(err) + } + if _, err := decodeV2(p); err == nil { + t.Fatal("expanded file limit accepted") + } + p, err = encodeV2(nil, Metadata{}) + if err != nil { + t.Fatal(err) + } + badChecksum := bytes.Clone(p) + badChecksum[len(badChecksum)-8] ^= 1 + for _, bad := range [][]byte{p[:len(p)-1], append(bytes.Clone(p), 0), append(bytes.Clone(p), p...), badChecksum} { + if _, err := decodeV2(bad); err == nil { + t.Fatal("corrupt gzip accepted") + } + } + if _, err := encodeV2(nil, Metadata{DSHVersion: string(make([]byte, maxManifestBytes))}); err == nil { + t.Fatal("oversized manifest accepted") + } +} + +func TestRoundTrip(t *testing.T) { + home, dest := t.TempDir(), t.TempDir() + for _, name := range allowed { + put(t, filepath.Join(home, name), []byte("secret:"+name)) + } + put(t, filepath.Join(home, "not-allowed"), []byte("ignore")) + archive := filepath.Join(t.TempDir(), "archive") + if err := Create(home, archive, testKey); err != nil { + t.Fatal(err) + } + first, err := os.ReadFile(archive) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(first, []byte("secret:")) { + t.Fatal("plaintext leaked") + } + if err := Create(home, archive, testKey); err != nil { + t.Fatal(err) + } + second, err := os.ReadFile(archive) + if err != nil { + t.Fatal(err) + } + if bytes.Equal(first, second) { + t.Fatal("nonce reused") + } + put(t, filepath.Join(dest, allowed[0]), []byte("old")) + if err := os.Chmod(filepath.Join(dest, allowed[0]), 0644); err != nil { + t.Fatal(err) + } + if err := Restore(archive, dest, testKey); err != nil { + t.Fatal(err) + } + for _, name := range allowed { + data, err := os.ReadFile(filepath.Join(dest, name)) + if err != nil { + t.Fatal(err) + } + if string(data) != "secret:"+name { + t.Fatalf("wrong data: %s", name) + } + info, err := os.Stat(filepath.Join(dest, name)) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("mode: %v", info.Mode()) + } + } + if _, err := os.Stat(filepath.Join(dest, "not-allowed")); !os.IsNotExist(err) { + t.Fatal("unlisted file restored") + } + info, err := os.Stat(archive) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatal("archive permissions") + } +} + +func TestAuthentication(t *testing.T) { + archive := sealed(t, payload{1, map[string][]byte{allowed[0]: []byte("new")}}) + original, err := os.ReadFile(archive) + if err != nil { + t.Fatal(err) + } + for _, offset := range []int{0, len(magic), len(original) - 1} { + t.Run(fmt.Sprint(offset), func(t *testing.T) { + data := bytes.Clone(original) + data[offset] ^= 1 + put(t, archive, data) + home := t.TempDir() + put(t, filepath.Join(home, allowed[0]), []byte("old")) + if err := Restore(archive, home, testKey); err == nil { + t.Fatal("tampering accepted") + } + got, _ := os.ReadFile(filepath.Join(home, allowed[0])) + if string(got) != "old" { + t.Fatal("modified on failure") + } + }) + } + put(t, archive, original) + if err := Restore(archive, t.TempDir(), bytes.Repeat([]byte{1}, 32)); err == nil { + t.Fatal("wrong key accepted") + } + put(t, archive, original[:len(original)-1]) + if err := Restore(archive, t.TempDir(), testKey); err == nil { + t.Fatal("truncation accepted") + } +} + +func TestInvalidPathsAndVersion(t *testing.T) { + for _, name := range []string{"../escape", "/tmp/escape", "profiles/web/../../../escape", "profiles//web/package.json", "other", "profiles\\web\\package.json"} { + t.Run(name, func(t *testing.T) { + home := t.TempDir() + put(t, filepath.Join(home, allowed[0]), []byte("old")) + archive := sealed(t, payload{1, map[string][]byte{allowed[0]: []byte("new"), name: []byte("bad")}}) + if err := Restore(archive, home, testKey); err == nil { + t.Fatal("invalid path accepted") + } + got, _ := os.ReadFile(filepath.Join(home, allowed[0])) + if string(got) != "old" { + t.Fatal("partial restore before validation") + } + }) + } + if err := Restore(sealed(t, payload{2, map[string][]byte{}}), t.TempDir(), testKey); err == nil { + t.Fatal("version accepted") + } + files := map[string][]byte{} + for i := 0; i < MaxFiles+1; i++ { + files[fmt.Sprint(i)] = nil + } + if err := Restore(sealed(t, payload{1, files}), t.TempDir(), testKey); err == nil { + t.Fatal("file count accepted") + } +} + +func TestSymlinks(t *testing.T) { + for _, outside := range []bool{false, true} { + for _, parent := range []bool{false, true} { + t.Run(fmt.Sprintf("outside=%v/parent=%v", outside, parent), func(t *testing.T) { + home := t.TempDir() + target := filepath.Join(home, "target") + linkTarget := "target" + if outside { + target = filepath.Join(t.TempDir(), "target") + linkTarget = target + } + name := allowed[0] + link := filepath.Join(home, name) + if parent { + name = "profiles/web/package.json" + link = filepath.Join(home, "profiles") + put(t, filepath.Join(target, "web/package.json"), []byte("safe")) + } else { + put(t, target, []byte("safe")) + } + if err := os.Symlink(linkTarget, link); err != nil { + t.Fatal(err) + } + if err := Create(home, filepath.Join(t.TempDir(), "archive"), testKey); err == nil { + t.Fatal("create followed symlink") + } + archive := sealed(t, payload{1, map[string][]byte{name: []byte("bad")}}) + if err := Restore(archive, home, testKey); err == nil { + t.Fatal("restore followed symlink") + } + }) + } + } +} + +func TestSymlinkPreflightAndArchive(t *testing.T) { + home := t.TempDir() + put(t, filepath.Join(home, allowed[0]), []byte("old")) + target := filepath.Join(t.TempDir(), "target") + put(t, target, []byte("safe")) + if err := os.Symlink(target, filepath.Join(home, "dsh-ssh.json")); err != nil { + t.Fatal(err) + } + archive := sealed(t, payload{1, map[string][]byte{allowed[0]: []byte("new"), "dsh-ssh.json": []byte("bad")}}) + if err := Restore(archive, home, testKey); err == nil { + t.Fatal("symlink accepted") + } + got, err := os.ReadFile(filepath.Join(home, allowed[0])) + if err != nil { + t.Fatal(err) + } + if string(got) != "old" { + t.Fatal("preflight modified earlier file") + } + got, err = os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(got) != "safe" { + t.Fatal("outside target modified") + } + link := filepath.Join(t.TempDir(), "archive-link") + if err := os.Symlink(archive, link); err != nil { + t.Fatal(err) + } + if err := Restore(link, t.TempDir(), testKey); err == nil { + t.Fatal("archive symlink accepted") + } + if err := Create(t.TempDir(), link, testKey); err == nil { + t.Fatal("destination symlink accepted") + } +} + +func TestSizeLimits(t *testing.T) { + home := t.TempDir() + name := filepath.Join(home, allowed[0]) + put(t, name, nil) + if err := os.Truncate(name, MaxTotalBytes+1); err != nil { + t.Fatal(err) + } + archive := filepath.Join(t.TempDir(), "archive") + if err := Create(home, archive, testKey); err == nil { + t.Fatal("oversized file accepted") + } + if err := os.Truncate(name, MaxTotalBytes); err != nil { + t.Fatal(err) + } + if err := Create(home, archive, testKey); err != nil { + t.Fatal("exact limit:", err) + } + if err := Restore(archive, t.TempDir(), testKey); err != nil { + t.Fatal("exact limit restore:", err) + } + put(t, filepath.Join(home, allowed[1]), []byte{1}) + if err := Create(home, archive, testKey); err == nil { + t.Fatal("aggregate limit accepted") + } + archive = sealed(t, payload{1, map[string][]byte{allowed[0]: make([]byte, MaxTotalBytes), allowed[1]: {1}}}) + if err := Restore(archive, t.TempDir(), testKey); err == nil { + t.Fatal("restore aggregate limit accepted") + } + put(t, archive, nil) + if err := os.Truncate(archive, maxArchiveBytes+1); err != nil { + t.Fatal(err) + } + if err := Restore(archive, t.TempDir(), testKey); err == nil { + t.Fatal("oversized archive accepted") + } +} + +func TestMissingFilesAndInvalidKey(t *testing.T) { + archive := filepath.Join(t.TempDir(), "archive") + if err := Create(t.TempDir(), archive, nil); err == nil { + t.Fatal("invalid key accepted") + } + if err := Create(t.TempDir(), archive, testKey); err != nil { + t.Fatal(err) + } + home := t.TempDir() + put(t, filepath.Join(home, allowed[0]), []byte("keep")) + if err := Restore(archive, home, testKey); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(filepath.Join(home, allowed[0])) + if string(got) != "keep" { + t.Fatal("missing file removed") + } +} diff --git a/internal/backup/password.go b/internal/backup/password.go new file mode 100644 index 0000000..cd82579 --- /dev/null +++ b/internal/backup/password.go @@ -0,0 +1,102 @@ +package backup + +import ( + "bytes" + "crypto/rand" + "errors" + "os" + "path/filepath" + "syscall" + + "golang.org/x/crypto/argon2" +) + +const passwordMagic = "DSHPASS\x01" +const MaxEncryptedBytes int64 = int64(maxArchiveBytes + len(passwordMagic) + 16) + +// The format fixes KDF costs; untrusted archives cannot request arbitrary memory. +func passwordKey(password string, salt []byte) ([]byte, error) { + if len(password) == 0 || len(password) > 1024 { + return nil, errors.New("backup password required (maximum 1024 bytes)") + } + return argon2.IDKey([]byte(password), salt, 3, 64*1024, 2, 32), nil +} + +func CreateWithPassword(home, destination, password string, metadata Metadata) error { + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return err + } + key, err := passwordKey(password, salt) + if err != nil { + return err + } + defer clear(key) + dir, err := os.MkdirTemp(filepath.Dir(destination), ".password-backup-") + if err != nil { + return err + } + defer os.RemoveAll(dir) + inner := filepath.Join(dir, "archive") + if err = CreateWithMetadata(home, inner, key, metadata); err != nil { + return err + } + data, err := os.ReadFile(inner) + if err != nil { + return err + } + data = append(append([]byte(passwordMagic), salt...), data...) + root, err := os.OpenRoot(filepath.Dir(destination)) + if err != nil { + return err + } + defer root.Close() + return replace(root, filepath.Base(destination), data) +} + +// Prepare authenticates and validates into a private staging home before the caller +// stops a container or snapshots its live home. Only ciphertext is retained. +func Prepare(archive, password string, legacyKey []byte) (restore func(string) error, cleanup func(), err error) { + f, err := os.OpenFile(archive, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) + if err != nil { + return nil, nil, err + } + data, err := readBounded(f, MaxEncryptedBytes) + f.Close() + if err != nil { + return nil, nil, err + } + key := append([]byte(nil), legacyKey...) + if bytes.HasPrefix(data, []byte(passwordMagic)) { + if len(data) < len(passwordMagic)+16 { + return nil, nil, errors.New("truncated backup") + } + key, err = passwordKey(password, data[len(passwordMagic):len(passwordMagic)+16]) + if err != nil { + return nil, nil, err + } + data = data[len(passwordMagic)+16:] + } + dir, err := os.MkdirTemp("", "dsh-backup-validate-") + if err != nil { + clear(key) + return nil, nil, err + } + cleanup = func() { clear(key); os.RemoveAll(dir) } + inner := filepath.Join(dir, "archive") + if err = os.WriteFile(inner, data, 0600); err == nil { + err = os.Mkdir(filepath.Join(dir, "home"), 0700) + } + if err == nil { + err = Restore(inner, filepath.Join(dir, "home"), key) + } + if err != nil { + cleanup() + return nil, nil, err + } + if err = os.RemoveAll(filepath.Join(dir, "home")); err != nil { + cleanup() + return nil, nil, err + } + return func(home string) error { return Restore(inner, home, key) }, cleanup, nil +} diff --git a/internal/backup/password_test.go b/internal/backup/password_test.go new file mode 100644 index 0000000..10bc06a --- /dev/null +++ b/internal/backup/password_test.go @@ -0,0 +1,77 @@ +package backup + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestPasswordArchive(t *testing.T) { + home := t.TempDir() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(home, ".credentials.yaml"), []byte("secret"), 0600); err != nil { + t.Fatal(err) + } + first, second := filepath.Join(dir, "first"), filepath.Join(dir, "second") + for _, path := range []string{first, second} { + if err := CreateWithPassword(home, path, "old-password", Metadata{DSHVersion: "test"}); err != nil { + t.Fatal(err) + } + } + a, _ := os.ReadFile(first) + b, _ := os.ReadFile(second) + if bytes.Equal(a[:len(passwordMagic)+16], b[:len(passwordMagic)+16]) { + t.Fatal("reused salt") + } + if bytes.Contains(a, []byte("secret")) { + t.Fatal("plaintext") + } + if _, _, err := Prepare(first, "wrong-password", nil); err == nil { + t.Fatal("accepted wrong password") + } + restore, cleanup, err := Prepare(first, "old-password", nil) + if err != nil { + t.Fatal(err) + } + defer cleanup() + destination := t.TempDir() + if err := restore(destination); err != nil { + t.Fatal(err) + } + restored, _ := os.ReadFile(filepath.Join(destination, ".credentials.yaml")) + if string(restored) != "secret" { + t.Fatal(string(restored)) + } + a[len(passwordMagic)] ^= 1 + os.WriteFile(second, a, 0600) + if _, _, err := Prepare(second, "old-password", nil); err == nil { + t.Fatal("accepted altered salt") + } + legacy := filepath.Join(dir, "legacy") + key := bytes.Repeat([]byte{9}, 32) + if err := Create(home, legacy, key); err != nil { + t.Fatal(err) + } + restore, legacyCleanup, err := Prepare(legacy, "", key) + if err != nil { + t.Fatal(err) + } + defer legacyCleanup() + if err := restore(t.TempDir()); err != nil { + t.Fatal(err) + } + os.WriteFile(second, []byte(passwordMagic), 0600) + if _, _, err := Prepare(second, "old-password", nil); err == nil { + t.Fatal("accepted truncated header") + } + f, err := os.Create(second) + if err != nil { + t.Fatal(err) + } + f.Truncate(MaxEncryptedBytes + 1) + f.Close() + if _, _, err := Prepare(second, "old-password", nil); err == nil { + t.Fatal("accepted oversized file") + } +} diff --git a/internal/docker/client.go b/internal/docker/client.go new file mode 100644 index 0000000..c4d9bf2 --- /dev/null +++ b/internal/docker/client.go @@ -0,0 +1,244 @@ +package docker + +import ( + "bufio" + "bytes" + "context" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" +) + +const ( + apiVersion = "/v1.47" + maxErrorBytes = 64 << 10 + maxLogBytes = 1 << 20 +) + +type Client struct { + baseURL string + http *http.Client +} + +type APIError struct { + StatusCode int + Message string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("docker API: HTTP %d: %s", e.StatusCode, e.Message) +} + +func New(host string) *Client { + if host == "" { + host = "unix:///var/run/docker.sock" + } + transport := http.DefaultTransport.(*http.Transport).Clone() + if strings.HasPrefix(host, "unix://") { + socket := strings.TrimPrefix(host, "unix://") + transport.Proxy = nil + transport.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", socket) + } + host = "http://docker" + } else if strings.HasPrefix(host, "tcp://") { + host = "http://" + strings.TrimPrefix(host, "tcp://") + } + return &Client{baseURL: strings.TrimRight(host, "/"), http: &http.Client{ + Transport: transport, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, + }} +} + +// Stream transfers ownership of a successful response body to the caller. +func (c *Client) Stream(ctx context.Context, method, path string, body io.Reader, contentType string) (*http.Response, error) { + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + if path != apiVersion && !strings.HasPrefix(path, apiVersion+"/") && !strings.HasPrefix(path, apiVersion+"?") { + path = apiVersion + path + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) + if err != nil { + return nil, err + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return resp, nil + } + defer resp.Body.Close() + data, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBytes)) + message := strings.TrimSpace(string(data)) + var detail struct { + Message string `json:"message"` + } + if json.Unmarshal(data, &detail) == nil && detail.Message != "" { + message = detail.Message + } + if message == "" { + message = resp.Status + } + return nil, &APIError{StatusCode: resp.StatusCode, Message: message} +} + +func (c *Client) Do(ctx context.Context, method, path string, body any, out any) error { + var input io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return err + } + input = bytes.NewReader(data) + } + resp, err := c.Stream(ctx, method, path, input, "application/json") + if err != nil { + return err + } + defer resp.Body.Close() + if out == nil || resp.StatusCode == http.StatusNoContent { + return nil + } + return json.NewDecoder(resp.Body).Decode(out) +} + +type Container struct { + ID string `json:"Id"` + Name string + Image string + Config struct { + Image string + Tty bool + Labels map[string]string + Env []string + } + State struct { + Status string + Running bool + Paused bool + Restarting bool + ExitCode int + Error string + Health *struct{ Status string } + } + NetworkSettings struct { + IPAddress string + Networks map[string]struct { + IPAddress string + Gateway string + NetworkID string + } + } +} + +func (c *Client) Inspect(ctx context.Context, id string) (*Container, error) { + var container Container + if err := c.Do(ctx, http.MethodGet, "/containers/"+url.PathEscape(id)+"/json", nil, &container); err != nil { + return nil, err + } + return &container, nil +} + +func (c *Client) Logs(ctx context.Context, id string) (string, error) { + resp, err := c.Stream(ctx, http.MethodGet, "/containers/"+url.PathEscape(id)+"/logs?stdout=1&stderr=1&follow=0", nil, "") + if err != nil { + return "", err + } + defer resp.Body.Close() + reader := bufio.NewReader(resp.Body) + header, err := reader.Peek(8) + if err != nil && err != io.EOF { + return "", err + } + if len(header) == 0 || header[0] > 2 { + data, err := io.ReadAll(io.LimitReader(reader, maxLogBytes)) + return string(data), err + } + var output bytes.Buffer + for output.Len() < maxLogBytes { + var frame [8]byte + _, err := io.ReadFull(reader, frame[:]) + if err == io.EOF { + break + } + if err != nil { + return output.String(), fmt.Errorf("docker logs header: %w", err) + } + if frame[0] > 2 || frame[1] != 0 || frame[2] != 0 || frame[3] != 0 { + return output.String(), fmt.Errorf("docker logs: invalid stream header") + } + size := int64(binary.BigEndian.Uint32(frame[4:])) + remaining := int64(maxLogBytes - output.Len()) + if size > remaining { + size = remaining + } + if _, err := io.CopyN(&output, reader, size); err != nil { + return output.String(), fmt.Errorf("docker logs payload: %w", err) + } + } + return output.String(), nil +} + +func (c *Client) Build(ctx context.Context, tar io.Reader, version, tag string, onLog func(string)) (string, error) { + args, err := json.Marshal(map[string]string{"DSH_VERSION": version}) + if err != nil { + return "", err + } + query := url.Values{"t": {tag}, "buildargs": {string(args)}, "rm": {"1"}} + resp, err := c.Stream(ctx, http.MethodPost, "/build?"+query.Encode(), tar, "application/x-tar") + if err != nil { + return "", err + } + defer resp.Body.Close() + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 4096), 1<<20) + var imageID string + for scanner.Scan() { + line := bytes.TrimSpace(scanner.Bytes()) + if len(line) == 0 { + continue + } + var event struct { + Stream string `json:"stream"` + Error string `json:"error"` + ErrorDetail struct { + Message string `json:"message"` + } `json:"errorDetail"` + Aux struct { + ID string `json:"ID"` + } `json:"aux"` + } + if err := json.Unmarshal(line, &event); err != nil { + return "", fmt.Errorf("docker build event: %w", err) + } + if event.ErrorDetail.Message != "" { + return "", fmt.Errorf("docker build: %s", event.ErrorDetail.Message) + } + if event.Error != "" { + return "", fmt.Errorf("docker build: %s", event.Error) + } + if event.Stream != "" && onLog != nil { + onLog(event.Stream) + } + if event.Aux.ID != "" { + imageID = event.Aux.ID + } + } + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("docker build stream: %w", err) + } + if imageID == "" { + return "", fmt.Errorf("docker build: response ended without image ID") + } + return imageID, nil +} diff --git a/internal/docker/client_test.go b/internal/docker/client_test.go new file mode 100644 index 0000000..8674477 --- /dev/null +++ b/internal/docker/client_test.go @@ -0,0 +1,242 @@ +package docker + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" +) + +func testClient(t *testing.T, handler http.HandlerFunc) *Client { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + client := New(strings.Replace(server.URL, "http://", "tcp://", 1)) + t.Cleanup(client.http.CloseIdleConnections) + return client +} + +func TestDo(t *testing.T) { + for _, path := range []string{"/containers/create?name=test", "containers/create?name=test", "/v1.47/containers/create?name=test"} { + t.Run(path, func(t *testing.T) { + client := testClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.RequestURI() != "/v1.47/containers/create?name=test" || r.Header.Get("Content-Type") != "application/json" { + t.Errorf("unexpected request: %s %s %v", r.Method, r.URL, r.Header) + } + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body["Image"] != "test" { + t.Errorf("body = %v, err = %v", body, err) + } + w.WriteHeader(http.StatusCreated) + io.WriteString(w, `{"Id":"container"}`) + }) + var out struct{ ID string } + if err := client.Do(context.Background(), http.MethodPost, path, map[string]string{"Image": "test"}, &out); err != nil || out.ID != "container" { + t.Fatalf("out = %v, err = %v", out, err) + } + }) + } +} + +func TestDoNoContentAndInvalidJSON(t *testing.T) { + client := testClient(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }) + var out any + if err := client.Do(context.Background(), "POST", "/stop", nil, &out); err != nil { + t.Fatal(err) + } + if err := client.Do(context.Background(), "POST", "/stop", make(chan int), nil); err == nil { + t.Fatal("expected marshal error") + } + client = testClient(t, func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "invalid") }) + if err := client.Do(context.Background(), "GET", "/test", nil, &out); err == nil { + t.Fatal("expected JSON decode error") + } +} + +func TestAPIErrors(t *testing.T) { + for _, tc := range []struct { + name, body, want string + status int + }{ + {"json", `{"message":"not found"}`, "not found", 404}, + {"text", "unavailable", "unavailable", 503}, + {"empty", "", "500 Internal Server Error", 500}, + {"bounded", strings.Repeat("x", maxErrorBytes*2), strings.Repeat("x", maxErrorBytes), 500}, + {"redirect", "no redirect", "no redirect", 302}, + } { + t.Run(tc.name, func(t *testing.T) { + client := testClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", "/elsewhere") + w.WriteHeader(tc.status) + io.WriteString(w, tc.body) + }) + err := client.Do(context.Background(), "GET", "/test", nil, nil) + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != tc.status || apiErr.Message != tc.want { + t.Fatalf("unexpected API error: %v", err) + } + if !strings.Contains(err.Error(), http.StatusText(tc.status)) && tc.name == "empty" { + t.Fatal(err) + } + }) + } +} + +func TestUnixAndCancellation(t *testing.T) { + socket := filepath.Join(t.TempDir(), "docker.sock") + listener, err := net.Listen("unix", socket) + if err != nil { + t.Fatal(err) + } + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1.47/info" { + t.Error(r.URL.Path) + } + io.WriteString(w, `{"ok":true}`) + })) + server.Listener.Close() + server.Listener = listener + server.Start() + defer server.Close() + client := New("unix://" + socket) + defer client.http.CloseIdleConnections() + var out map[string]bool + if err := client.Do(context.Background(), "GET", "/info", nil, &out); err != nil || !out["ok"] { + t.Fatalf("%v %v", out, err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + transport := client.http.Transport.(*http.Transport) + if conn, err := transport.DialContext(ctx, "tcp", "ignored"); err == nil { + conn.Close() + t.Fatal("unix dial ignored cancellation") + } + if err := client.Do(ctx, "GET", "/info", nil, nil); !errors.Is(err, context.Canceled) { + t.Fatalf("canceled request: %v", err) + } +} + +func TestInspect(t *testing.T) { + client := testClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.EscapedPath() != "/v1.47/containers/a%2Fb/json" { + t.Error(r.URL.EscapedPath()) + } + io.WriteString(w, `{"Id":"abc","Name":"/example","Config":{"Tty":true},"State":{"Running":true,"Status":"running","Health":{"Status":"healthy"}},"NetworkSettings":{"Networks":{"bridge":{"IPAddress":"172.18.0.2"}}}}`) + }) + container, err := client.Inspect(context.Background(), "a/b") + if err != nil { + t.Fatal(err) + } + if container.ID != "abc" || !container.State.Running || !container.Config.Tty || container.State.Health.Status != "healthy" || container.NetworkSettings.Networks["bridge"].IPAddress != "172.18.0.2" { + t.Fatalf("%+v", container) + } +} + +func frame(stream byte, text string) []byte { + data := make([]byte, 8, 8+len(text)) + data[0] = stream + binary.BigEndian.PutUint32(data[4:], uint32(len(text))) + return append(data, text...) +} + +func TestLogs(t *testing.T) { + for _, tc := range []struct { + name string + data []byte + want string + fail bool + }{ + {"multiplexed", append(frame(1, "out\n"), frame(2, "err\n")...), "out\nerr\n", false}, + {"tty", []byte("plain text\n"), "plain text\n", false}, + {"short tty", []byte("ok"), "ok", false}, + {"empty", nil, "", false}, + {"empty frame", append(frame(1, ""), frame(2, "ok")...), "ok", false}, + {"limited frame", frame(1, strings.Repeat("x", maxLogBytes+10)), strings.Repeat("x", maxLogBytes), false}, + {"limited tty", bytes.Repeat([]byte("x"), maxLogBytes+10), strings.Repeat("x", maxLogBytes), false}, + {"limited multiple frames", append(frame(1, strings.Repeat("x", maxLogBytes-1)), frame(2, "yz")...), strings.Repeat("x", maxLogBytes-1) + "y", false}, + {"short header", []byte{1, 0}, "", true}, + {"short payload", frame(1, "test")[:10], "te", true}, + {"invalid header", []byte{1, 1, 0, 0, 0, 0, 0, 0}, "", true}, + } { + t.Run(tc.name, func(t *testing.T) { + client := testClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1.47/containers/example/logs" || r.URL.Query().Get("stdout") != "1" || r.URL.Query().Get("stderr") != "1" || r.URL.Query().Get("follow") != "0" { + t.Error(r.URL) + } + w.Write(tc.data) + }) + got, err := client.Logs(context.Background(), "example") + if (err != nil) != tc.fail || got != tc.want { + t.Fatalf("got %d bytes want %d, err=%v", len(got), len(tc.want), err) + } + }) + } +} + +func TestBuild(t *testing.T) { + client := testClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/v1.47/build" || r.Header.Get("Content-Type") != "application/x-tar" || r.URL.Query().Get("t") != "test:1" { + t.Errorf("%s %s %v", r.Method, r.URL, r.Header) + } + var args map[string]string + if err := json.Unmarshal([]byte(r.URL.Query().Get("buildargs")), &args); err != nil || args["DSH_VERSION"] != "1.2.3" { + t.Errorf("%v %v", args, err) + } + body, _ := io.ReadAll(r.Body) + if string(body) != "caller tar bytes" { + t.Errorf("tar changed: %q", body) + } + for _, chunk := range []string{`{"stream":"step 1\n"}`, "\r\n\n", `{"au`, `x":{"ID":"sha256:image"}}`} { + io.WriteString(w, chunk) + w.(http.Flusher).Flush() + } + }) + var logs []string + id, err := client.Build(context.Background(), strings.NewReader("caller tar bytes"), "1.2.3", "test:1", func(s string) { logs = append(logs, s) }) + if err != nil || id != "sha256:image" || len(logs) != 1 || logs[0] != "step 1\n" { + t.Fatalf("id=%q logs=%v err=%v", id, logs, err) + } +} + +func TestBuildErrors(t *testing.T) { + for _, tc := range []struct{ name, data, want string }{ + {"error", `{"error":"build failed"}`, "build failed"}, + {"detail", `{"errorDetail":{"message":"detailed failure"}}`, "detailed failure"}, + {"both", `{"error":"generic","errorDetail":{"message":"specific"}}`, "specific"}, + {"late error", "{\"aux\":{\"ID\":\"partial\"}}\n{\"error\":\"late failure\"}", "late failure"}, + {"malformed", `{"stream":`, "docker build event"}, + {"too large", strings.Repeat("x", (1<<20)+1), "token too long"}, + {"missing ID", `{"stream":"done"}`, "without image ID"}, + } { + t.Run(tc.name, func(t *testing.T) { + client := testClient(t, func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, tc.data) }) + id, err := client.Build(context.Background(), nil, "1", "test", nil) + if err == nil || !strings.Contains(err.Error(), tc.want) || id != "" { + t.Fatalf("id=%q err=%v", id, err) + } + }) + } +} + +func TestBuildCancellation(t *testing.T) { + client := testClient(t, func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, "{\"stream\":\"started\"}\n") + w.(http.Flusher).Flush() + <-r.Context().Done() + }) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _, err := client.Build(ctx, nil, "1", "test", func(string) { cancel() }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("err=%v", err) + } +} diff --git a/internal/store/intents.go b/internal/store/intents.go new file mode 100644 index 0000000..2d706de --- /dev/null +++ b/internal/store/intents.go @@ -0,0 +1,133 @@ +package store + +import ( + "database/sql" + "errors" + "time" +) + +var ErrInstanceLimit = errors.New("instance limit reached") +var ErrIntentState = errors.New("intent is no longer waiting") + +type Intent struct { + ID string `json:"id"` + UID string `json:"uid"` + Name string `json:"name"` + Version string `json:"version"` + BuildID string `json:"build_id"` + InstanceID string `json:"instance_id"` + Status string `json:"status"` + Error string `json:"error"` + Host string `json:"-"` + Created int64 `json:"created_at"` +} + +func (s *Store) EnsureIntents() error { + _, err := s.DB.Exec(`CREATE TABLE IF NOT EXISTS creation_intents(id TEXT PRIMARY KEY,uid TEXT NOT NULL,name TEXT NOT NULL,version TEXT NOT NULL,build_id TEXT NOT NULL DEFAULT '',instance_id TEXT NOT NULL DEFAULT '',status TEXT NOT NULL,error TEXT NOT NULL DEFAULT '',host TEXT NOT NULL,created INTEGER NOT NULL); CREATE INDEX IF NOT EXISTS intents_owner ON creation_intents(uid,status);`) + return err +} + +func reserveSlot(tx *sql.Tx, uid string) error { + var count int + if err := tx.QueryRow(`SELECT (SELECT count(*) FROM instances WHERE uid=?) + (SELECT count(*) FROM creation_intents WHERE uid=? AND status='waiting')`, uid, uid).Scan(&count); err != nil { + return err + } + if count >= 2 { + return ErrInstanceLimit + } + return nil +} + +func (s *Store) ReserveIntent(i Intent) error { + tx, err := s.DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if err = reserveSlot(tx, i.UID); err != nil { + return err + } + _, err = tx.Exec(`INSERT INTO creation_intents(id,uid,name,version,status,host,created) VALUES(?,?,?,?,'waiting',?,?)`, i.ID, i.UID, i.Name, i.Version, i.Host, time.Now().Unix()) + if err != nil { + return err + } + return tx.Commit() +} + +func (s *Store) ReserveInstance(i Instance) error { + tx, err := s.DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if err = reserveSlot(tx, i.UID); err != nil { + return err + } + if err = insertInstance(tx, i); err != nil { + return err + } + return tx.Commit() +} +func insertInstance(tx *sql.Tx, i Instance) error { + _, err := tx.Exec(`INSERT INTO instances VALUES(?,?,?,?,?,?,?,?)`, i.ID, i.UID, i.Name, i.Version, i.ImageID, i.Status, i.ContainerID, time.Now().Unix()) + return err +} +func (s *Store) ClaimIntent(id string, i Instance) error { + tx, err := s.DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + result, err := tx.Exec(`UPDATE creation_intents SET status='creating',instance_id=? WHERE id=? AND uid=? AND version=? AND status='waiting'`, i.ID, id, i.UID, i.Version) + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n != 1 { + return ErrIntentState + } + if err = insertInstance(tx, i); err != nil { + return err + } + return tx.Commit() +} +func (s *Store) CancelIntent(id, uid string) error { + r, err := s.DB.Exec(`UPDATE creation_intents SET status='cancelled' WHERE id=? AND uid=? AND status='waiting'`, id, uid) + if err != nil { + return err + } + n, err := r.RowsAffected() + if err != nil { + return err + } + if n != 1 { + return ErrIntentState + } + return nil +} +func (s *Store) Intents(uid string) ([]Intent, error) { + query := `SELECT id,uid,name,version,build_id,instance_id,status,error,host,created FROM creation_intents` + var args []any + if uid != "" { + query += ` WHERE uid=?` + args = append(args, uid) + } + query += ` ORDER BY created,id` + rows, err := s.DB.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []Intent{} + for rows.Next() { + var i Intent + if err = rows.Scan(&i.ID, &i.UID, &i.Name, &i.Version, &i.BuildID, &i.InstanceID, &i.Status, &i.Error, &i.Host, &i.Created); err != nil { + return nil, err + } + out = append(out, i) + } + return out, rows.Err() +} diff --git a/internal/store/intents_test.go b/internal/store/intents_test.go new file mode 100644 index 0000000..3a120dc --- /dev/null +++ b/internal/store/intents_test.go @@ -0,0 +1,94 @@ +package store + +import ( + "errors" + "path/filepath" + "sync" + "testing" +) + +func TestIntentReservationConcurrencyAndRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "store.db") + s, err := Open(path) + if err != nil { + t.Fatal(err) + } + if err = s.EnsureIntents(); err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + result := make(chan error, 30) + for n := 0; n < 30; n++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + if n%2 == 0 { + result <- s.ReserveIntent(Intent{ID: ID(), UID: "u", Name: "test", Version: "1.2.3"}) + } else { + result <- s.ReserveInstance(Instance{ID: ID(), UID: "u"}) + } + }(n) + } + wg.Wait() + close(result) + ok := 0 + for err := range result { + if err == nil { + ok++ + } else if !errors.Is(err, ErrInstanceLimit) { + t.Fatal(err) + } + } + if ok != 2 { + t.Fatalf("reserved %d", ok) + } + s.Close() + s, err = Open(path) + if err != nil { + t.Fatal(err) + } + defer s.Close() + if err = s.ReserveIntent(Intent{ID: ID(), UID: "u"}); !errors.Is(err, ErrInstanceLimit) { + t.Fatalf("restart lost reservation: %v", err) + } +} +func TestIntentClaimCancelAndVersion(t *testing.T) { + s, err := Open(filepath.Join(t.TempDir(), "store.db")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + if err = s.EnsureIntents(); err != nil { + t.Fatal(err) + } + in := Intent{ID: ID(), UID: "u", Version: "1.2.3"} + if err = s.ReserveIntent(in); err != nil { + t.Fatal(err) + } + i := Instance{ID: ID(), UID: "u", Version: "1.2.4"} + if err = s.ClaimIntent(in.ID, i); !errors.Is(err, ErrIntentState) { + t.Fatal(err) + } + if err = s.CancelIntent(in.ID, "other"); !errors.Is(err, ErrIntentState) { + t.Fatal(err) + } + i.Version = in.Version + if err = s.ClaimIntent(in.ID, i); err != nil { + t.Fatal(err) + } + if err = s.CancelIntent(in.ID, in.UID); !errors.Is(err, ErrIntentState) { + t.Fatal(err) + } + if err = s.ReserveIntent(Intent{ID: "second", UID: "u"}); err != nil { + t.Fatal(err) + } + if err = s.ReserveIntent(Intent{ID: "third", UID: "u"}); !errors.Is(err, ErrInstanceLimit) { + t.Fatal(err) + } + if err = s.CancelIntent("second", "u"); err != nil { + t.Fatal(err) + } + if err = s.ReserveIntent(Intent{ID: "third", UID: "u"}); err != nil { + t.Fatal(err) + } +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..e9773b7 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,131 @@ +package store + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + _ "modernc.org/sqlite" + "time" +) + +type User struct { + ID string `json:"id"` + Username string `json:"username"` + Admin bool `json:"admin"` + Quota int64 `json:"quota_bytes"` + Created time.Time `json:"created_at"` +} +type Instance struct { + ID string `json:"id"` + UID string `json:"uid"` + Name string `json:"name"` + Version string `json:"version"` + ImageID string `json:"image_id"` + Status string `json:"status"` + ContainerID string `json:"container_id"` + Created time.Time `json:"created_at"` +} +type Store struct{ DB *sql.DB } + +func Open(path string) (*Store, error) { + db, e := sql.Open("sqlite", path) + if e != nil { + return nil, e + } + db.SetMaxOpenConns(1) + if _, e = db.Exec("PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL;"); e != nil { + db.Close() + return nil, e + } + _, e = db.Exec("PRAGMA foreign_keys=ON; CREATE TABLE IF NOT EXISTS users(id TEXT PRIMARY KEY,username TEXT UNIQUE NOT NULL,pass_hash TEXT NOT NULL,admin INTEGER NOT NULL,quota INTEGER NOT NULL,created INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS sessions(id TEXT PRIMARY KEY,uid TEXT NOT NULL,csrf TEXT NOT NULL,expires INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS instances(id TEXT PRIMARY KEY,uid TEXT NOT NULL,name TEXT NOT NULL,version TEXT,image_id TEXT,status TEXT,container_id TEXT,created INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS audit(id INTEGER PRIMARY KEY,uid TEXT,action TEXT,target TEXT,detail TEXT,at INTEGER NOT NULL);") + if e != nil { + db.Close() + return nil, e + } + return &Store{db}, nil +} +func (s *Store) Close() { s.DB.Close() } +func ID() string { b := make([]byte, 16); _, _ = rand.Read(b); return hex.EncodeToString(b) } +func (s *Store) UserByName(n string) (User, error) { + var u User + var a, t int64 + e := s.DB.QueryRow("SELECT id,username,admin,quota,created FROM users WHERE username=?", n).Scan(&u.ID, &u.Username, &a, &u.Quota, &t) + u.Admin = a != 0 + u.Created = time.Unix(t, 0) + return u, e +} +func (s *Store) UserByID(id string) (User, error) { + var u User + var a, t int64 + e := s.DB.QueryRow("SELECT id,username,admin,quota,created FROM users WHERE id=?", id).Scan(&u.ID, &u.Username, &a, &u.Quota, &t) + u.Admin = a != 0 + u.Created = time.Unix(t, 0) + return u, e +} +func (s *Store) CreateUser(u User, hash string) error { + _, e := s.DB.Exec("INSERT INTO users(id,username,pass_hash,admin,quota,created) VALUES(?,?,?,?,?,?)", u.ID, u.Username, hash, boolInt(u.Admin), u.Quota, time.Now().Unix()) + return e +} +func boolInt(v bool) int { + if v { + return 1 + } + return 0 +} +func (s *Store) Password(name string) (string, error) { + var p string + e := s.DB.QueryRow("SELECT pass_hash FROM users WHERE username=?", name).Scan(&p) + return p, e +} +func (s *Store) Session(uid string) (string, string, error) { + id, csrf := ID(), ID() + _, e := s.DB.Exec("INSERT INTO sessions VALUES(?,?,?,?)", id, uid, csrf, time.Now().Add(24*time.Hour).Unix()) + return id, csrf, e +} +func (s *Store) SessionUser(id string) (User, string, error) { + var uid, csrf string + var exp int64 + e := s.DB.QueryRow("SELECT uid,csrf,expires FROM sessions WHERE id=?", id).Scan(&uid, &csrf, &exp) + if e != nil || exp < time.Now().Unix() { + return User{}, "", sql.ErrNoRows + } + u, e := s.UserByID(uid) + return u, csrf, e +} +func (s *Store) DeleteSession(id string) { _, _ = s.DB.Exec("DELETE FROM sessions WHERE id=?", id) } +func (s *Store) AddAudit(uid, a, t, d string) { + _, _ = s.DB.Exec("INSERT INTO audit(uid,action,target,detail,at) VALUES(?,?,?,?,?)", uid, a, t, d, time.Now().Unix()) +} +func (s *Store) Instances(uid string) ([]Instance, error) { + rows, e := s.DB.Query("SELECT id,uid,name,version,image_id,status,container_id,created FROM instances WHERE uid=? ORDER BY created", uid) + if e != nil { + return nil, e + } + defer rows.Close() + var out []Instance + for rows.Next() { + var i Instance + var t int64 + if e = rows.Scan(&i.ID, &i.UID, &i.Name, &i.Version, &i.ImageID, &i.Status, &i.ContainerID, &t); e != nil { + return nil, e + } + i.Created = time.Unix(t, 0) + out = append(out, i) + } + return out, rows.Err() +} +func (s *Store) AddInstance(i Instance) error { + _, e := s.DB.Exec("INSERT INTO instances VALUES(?,?,?,?,?,?,?,?)", i.ID, i.UID, i.Name, i.Version, i.ImageID, i.Status, i.ContainerID, time.Now().Unix()) + return e +} +func (s *Store) GetInstance(id string) (Instance, error) { + var i Instance + var t int64 + e := s.DB.QueryRow("SELECT id,uid,name,version,image_id,status,container_id,created FROM instances WHERE id=?", id).Scan(&i.ID, &i.UID, &i.Name, &i.Version, &i.ImageID, &i.Status, &i.ContainerID, &t) + i.Created = time.Unix(t, 0) + return i, e +} +func (s *Store) SetInstance(id, status string) { + _, _ = s.DB.Exec("UPDATE instances SET status=? WHERE id=?", status, id) +} +func (s *Store) DeleteInstance(id string) { _, _ = s.DB.Exec("DELETE FROM instances WHERE id=?", id) } diff --git a/scripts/build-image.sh b/scripts/build-image.sh new file mode 100644 index 0000000..cfc40c0 --- /dev/null +++ b/scripts/build-image.sh @@ -0,0 +1,15 @@ +#!/bin/bash +set -Eeuo pipefail +project_dir=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +version=${1:-} +if [[ -z $version ]]; then + version=$(npm view @deepseek-ai/dsh@latest version --registry=https://registry.npmjs.org) +fi +if [[ ! $version =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo 'Expected an exact DSH version, not a dist-tag or arbitrary build argument.' >&2 + exit 2 +fi +tag="dsh-web:${version}-7" +printf 'Building local image %s (no push)\n' "$tag" +docker build --build-arg "DSH_VERSION=$version" --tag "$tag" "$project_dir/image" +docker image inspect --format '{{.Id}}' "$tag" diff --git a/scripts/check-trial.sh b/scripts/check-trial.sh new file mode 100644 index 0000000..c44abad --- /dev/null +++ b/scripts/check-trial.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +trap 'status=$?; printf "Acceptance failed (exit %s): %s\n" "$status" "$BASH_COMMAND" >&2; exit "$status"' ERR + +if [[ -n ${TEST_TMPDIR:-} ]]; then + export TMPDIR + TMPDIR=$(CDPATH= cd -- "$TEST_TMPDIR" && pwd) + [[ -w $TMPDIR && -x $TMPDIR ]] +fi + +project_dir=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +cd -- "$project_dir" + +run() { + printf '\n==> %s\n' "$*" + "$@" +} + +run go test -timeout=180s ./... +run go test -race -timeout=180s ./... +run go vet ./... +run go build ./... +run npm --prefix web test +run npm --prefix web run build +run node --test image/init-profile.test.mjs +run node --check scripts/test-panel.mjs + +printf '\nAll acceptance checks passed. 所有测试与检查均通过。\n' diff --git a/scripts/check-trial.test.mjs b/scripts/check-trial.test.mjs new file mode 100644 index 0000000..0aebd3f --- /dev/null +++ b/scripts/check-trial.test.mjs @@ -0,0 +1,91 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const commands = [ + 'go test -timeout=180s ./...', + 'go test -race -timeout=180s ./...', + 'go vet ./...', + 'go build ./...', + 'npm --prefix web test', + 'npm --prefix web run build', + 'node --test image/init-profile.test.mjs', + 'node --check scripts/test-panel.mjs', +]; + +function fixture(t) { + const home = mkdtempSync(join(tmpdir(), 'check-trial-')); + t.after(() => rmSync(home, { recursive: true, force: true })); + const project = join(home, 'project with spaces'); + const bin = join(home, 'stubs'); + mkdirSync(join(project, 'scripts'), { recursive: true }); + mkdirSync(join(project, 'bin')); + mkdirSync(bin); + writeFileSync(join(project, 'bin', 'existing'), 'keep'); + const script = join(project, 'scripts', 'check-trial.sh'); + copyFileSync(new URL('./check-trial.sh', import.meta.url), script); + for (const tool of ['go', 'npm', 'node']) { + writeFileSync(join(bin, tool), `#!/bin/bash +command="${tool} $*" +printf '%s\t%s\t%s\n' "$command" "$PWD" "$TMPDIR" >> "$COMMAND_LOG" +if [[ $command == "$FAIL_COMMAND" ]]; then exit 23; fi +`, { mode: 0o755 }); + } + const log = join(home, 'commands'); + return { + home, project, + run(overrides = {}) { + const result = spawnSync('bash', [script], { + cwd: home, encoding: 'utf8', + env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, COMMAND_LOG: log, + FAIL_COMMAND: '', TEST_TMPDIR: '', TMPDIR: home, ...overrides }, + }); + assert.ifError(result.error); + const rows = existsSync(log) ? readFileSync(log, 'utf8').trim().split('\n').map(line => line.split('\t')) : []; + assert.equal(readFileSync(join(project, 'bin', 'existing'), 'utf8'), 'keep'); + return { ...result, rows }; + }, + }; +} + +test('runs all checks from the script project and preserves the default temp directory', t => { + const f = fixture(t); + const result = f.run(); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(result.rows, commands.map(command => [command, f.project, f.home])); + assert.match(result.stdout, /All acceptance checks passed/); +}); + +for (const [index, command] of commands.entries()) { + test(`stops immediately when ${command} fails`, t => { + const result = fixture(t).run({ FAIL_COMMAND: command }); + assert.equal(result.status, 23); + assert.deepEqual(result.rows.map(row => row[0]), commands.slice(0, index + 1)); + assert.match(result.stderr, /Acceptance failed/); + assert.doesNotMatch(result.stdout, /All acceptance checks passed/); + }); +} + +test('accepts an explicit relative TEST_TMPDIR without removing its contents', t => { + const f = fixture(t); + const temp = join(f.home, 'custom temp'); + mkdirSync(temp); + writeFileSync(join(temp, 'keep'), 'user data'); + const result = f.run({ TEST_TMPDIR: 'custom temp' }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(result.rows.map(row => row[2]), commands.map(() => temp)); + assert.equal(readFileSync(join(temp, 'keep'), 'utf8'), 'user data'); +}); + +test('rejects a missing TEST_TMPDIR without creating it or starting checks', t => { + const f = fixture(t); + const temp = join(f.home, 'missing'); + const result = f.run({ TEST_TMPDIR: temp }); + assert.notEqual(result.status, 0); + assert.deepEqual(result.rows, []); + assert.equal(existsSync(temp), false); + assert.doesNotMatch(result.stdout, /All acceptance checks passed/); +}); diff --git a/scripts/probe-runtime.mjs b/scripts/probe-runtime.mjs new file mode 100644 index 0000000..84b19b4 --- /dev/null +++ b/scripts/probe-runtime.mjs @@ -0,0 +1,106 @@ +import { spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { setTimeout as delay } from 'node:timers/promises'; + +const image = process.argv[2] ?? 'dsh-web:0.2.0-rc.2-1'; +let interrupted = false; +for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => { interrupted = true; }); +function docker(args, input) { + const result = spawnSync('docker', args, { input, encoding: 'utf8', timeout: 20000, maxBuffer: 16 * 1024 * 1024 }); + if (result.error || result.status !== 0) throw new Error(`Docker ${args[0]} failed (output suppressed)`); + return result.stdout + (args[0] === 'logs' ? result.stderr : ''); +} +function check(condition, message) { + if (!condition) throw new Error(message); +} + +async function probe(publicURL) { + const name = `dsh-runtime-probe-${randomUUID()}`; + let created = false; + try { + const id = docker(['image', 'inspect', '--format', '{{.Id}}', image]).trim(); + docker(['create', '--name', name, '--pull', 'never', '--network', 'none', + '--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', + '--user', '1000:1000', '--memory', '2g', '--cpus', '2', '--pids-limit', '256', + '--tmpfs', '/tmp:rw,exec,nosuid,nodev,size=256m', + '--tmpfs', '/data/dsh-home:rw,nosuid,nodev,uid=1000,gid=1000,size=512m', + '--tmpfs', '/workspace:rw,nosuid,nodev,uid=1000,gid=1000,size=64m', id, + 'web', '--no-open', '--port', '3080', + ...(publicURL ? ['--public-url', publicURL, '--trusted-host', new URL(publicURL).host] : [])]); + created = true; + docker(['start', name]); + let startupURL; + for (let attempt = 0; attempt < 60; attempt++) { + check(!interrupted, 'Interrupted'); + const logs = docker(['logs', name]); + const match = logs.match(/dsh web:\s*(https?:\/\/[^\s\x1b]+[?&]token=[^\s\x1b]+)/); + if (match) { startupURL = new URL(match[1]); break; } + if (docker(['inspect', '--format', '{{.State.Running}}', name]).trim() !== 'true') { + if (logs.includes("unknown option '--public-url'")) console.log(JSON.stringify({mode: 'public-url', supported: false, reason: "unknown option '--public-url'"})); + throw new Error('Runtime exited before readiness'); + } + await delay(1000); + } + check(startupURL?.searchParams.get('token'), 'No startup token found (logs suppressed)'); + if (publicURL) check(startupURL.origin + startupURL.pathname === publicURL, 'Public startup URL differs'); + const input = JSON.stringify({ token: startupURL.searchParams.get('token'), publicURL }); + const source = String.raw` + const fs = require('node:fs'); + const path = require('node:path'); + const { token, publicURL } = JSON.parse(fs.readFileSync(0, 'utf8')); + const headers = publicURL ? {host: new URL(publicURL).host, 'x-forwarded-proto': 'https'} : {}; + const request = (suffix, extra = {}) => fetch('http://127.0.0.1:8080' + suffix, {headers: {...headers, ...extra}, redirect: 'manual', signal: AbortSignal.timeout(10000)}); + (async () => { + const anonymous = await request('/'); + const exchange = await request('/?token=' + encodeURIComponent(token)); + const cookies = exchange.headers.getSetCookie(); + const cookie = cookies.map(value => value.split(';')[0]).join('; '); + const home = await request('/', {cookie}); + const html = await home.text(); + const scripts = [...html.matchAll(/]*src=["']([^"']+)/g)].map(match => match[1]); + const evidence = []; + function scan(dir) { + for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + const file = path.join(dir, entry.name); + if (entry.isDirectory()) scan(file); + else if (/\.(js|mjs|html)$/.test(file) && fs.statSync(file).size < 20000000) { + const text = fs.readFileSync(file, 'utf8'); + for (const needle of ['new WebSocket(', 'function remoteStreamUrl', 'REMOTE_STREAM_MUX_PATH =']) { + let index = text.indexOf(needle); + if (index >= 0) evidence.push({file, needle, line: text.slice(0,index).split('\n').length, context: text.slice(Math.max(0,index-180),index+260)}); + } + } + } + } + scan('/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-api-gateway/lib'); + const result = {anonymousStatus: anonymous.status, exchangeStatus: exchange.status, + redirect: exchange.headers.get('location'), cookies: cookies.map(value => ({name: value.split('=')[0], attributes: value.split(';').slice(1).map(v => v.trim())})), + authenticatedStatus: home.status, htmlConfig: [...html.matchAll(/.{0,120}(?:WebSocket|websocket|endpoint|base href|wsPath).{0,240}/g)].map(m => m[0]), scripts, evidence}; + // Never serialize raw credentials, even if an upstream response reflects them. + let output = JSON.stringify(result); + for (const secret of [token, ...cookies.map(value => value.split(';')[0].slice(value.indexOf('=')+1))]) if (secret) output = output.split(secret).join('[REDACTED]'); + console.log(output); + })().catch(() => {console.error('In-container probe failed (details suppressed)'); process.exit(1);}); + `; + const result = JSON.parse(docker(['exec', '-i', name, 'node', '-e', source], input)); + console.log(JSON.stringify({mode: publicURL ? 'public-url' : 'default', startupURL: startupURL.origin + startupURL.pathname + '?token=[REDACTED]', ...result}, null, 2)); + check(result.anonymousStatus === 401, 'Expected anonymous HTTP 401'); + check(result.cookies.length > 0, 'Token exchange did not set cookie'); + check(result.authenticatedStatus === 200, 'Authenticated homepage was not HTTP 200'); + check(result.evidence.some(item => item.needle === 'REMOTE_STREAM_MUX_PATH ='), 'WebSocket path constant not found'); + } finally { + if (created) { + docker(['rm', '-f', name]); + console.log(JSON.stringify({cleanup: 'removed', container: name})); + } + } +} + +try { + await probe(); + // Optional capability check: current npm rc.2 intentionally fails this mode. + if (process.argv.includes('--public-url')) await probe('https://probe.invalid/dsh/probe/'); +} catch (error) { + console.error(error.message); + process.exitCode = 1; +} diff --git a/scripts/smoke-image.sh b/scripts/smoke-image.sh new file mode 100644 index 0000000..9459feb --- /dev/null +++ b/scripts/smoke-image.sh @@ -0,0 +1,25 @@ +#!/bin/bash +set -Eeuo pipefail +image_id=$(docker image inspect --format '{{.Id}}' "$1") +expected_version=$(docker image inspect --format '{{index .Config.Labels "dsh-mgr.version"}}' "$image_id") +[[ -n "$expected_version" && "$expected_version" != '' ]] || { echo 'FAIL: missing version label.' >&2; exit 1; } +container_id=$(docker run -d --read-only --cap-drop ALL --security-opt no-new-privileges --memory 2g --cpus 2 --pids-limit 256 --tmpfs /tmp:rw,exec,nosuid,nodev,size=256m --tmpfs /data/dsh-home:rw,uid=1000,gid=1000,size=512m --tmpfs /workspace:rw,uid=1000,gid=1000,size=64m "$image_id") +cleanup() { docker rm -f "$container_id" >/dev/null; } +trap cleanup EXIT +for ((attempt=0; attempt<60; attempt++)); do + if [[ $(docker inspect --format '{{.State.Running}}' "$container_id") != true ]]; then + echo 'FAIL: runtime exited before readiness.' >&2 + exit 1 + fi + if docker logs "$container_id" 2>&1 | grep 'dsh web:.*token=' >/dev/null; then + docker exec "$container_id" node -e 'fetch("http://127.0.0.1:8080/").then(r=>{console.log("Proxy HTTP status:",r.status);process.exit(r.status>=500?1:0)}).catch(()=>process.exit(1))' + actual_version=$(docker exec "$container_id" dsh --version) + [[ "$actual_version" == "$expected_version" ]] || { echo 'FAIL: CLI version does not match image label.' >&2; exit 1; } + printf 'Verified DSH version: %s\n' "$actual_version" + echo 'PASS: readiness under read-only rootfs, non-root and dropped capabilities.' + exit 0 + fi + sleep 1 +done +echo 'FAIL: readiness timeout; credential-bearing logs were not printed.' >&2 +exit 1 diff --git a/scripts/test-intent-live.mjs b/scripts/test-intent-live.mjs new file mode 100644 index 0000000..8fcbf00 --- /dev/null +++ b/scripts/test-intent-live.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +const base=process.env.TEST_PANEL_URL; +assert.ok(base && process.env.TEST_PASSWORD_FILE); +let csrf='',cookie=''; +async function req(path,method='GET',body){ + const r=await fetch(base+path,{method,headers:{Cookie:cookie,Origin:base,'Content-Type':'application/json','X-CSRF-Token':csrf},body:body===undefined?undefined:JSON.stringify(body)}); + if(path==='/api/login')cookie=r.headers.getSetCookie().map(v=>v.split(';')[0]).join('; '); + return {status:r.status,data:await r.json()}; +} +const login=await req('/api/login','POST',{username:'admin',password:readFileSync(process.env.TEST_PASSWORD_FILE,'utf8').trim()}); +assert.equal(login.status,200);csrf=login.data.csrf_token; +const before=(await req('/api/instances')).data;assert.equal(before.length,1,'run with exactly one retained test instance'); +const intent=await req('/api/intents','POST',{name:'Design real intent',version:'0.2.0-rc.2'});assert.equal(intent.status,202); +let created; +try { + for(let n=0;n<60;n++){ + const item=(await req('/api/intents')).data.find(x=>x.id===intent.data.id); + assert.notEqual(item.status,'failed'); + if(item.instance_id){created=item.instance_id;break;} + await new Promise(r=>setTimeout(r,1000)); + } + assert.ok(created,'intent must create a real instance'); + const list=(await req('/api/instances')).data;assert.equal(list.length,2);assert.ok(list.every(i=>i.status==='running')); + const extra=await req('/api/instances','POST',{name:'must reject third',image_id:list[0].image_id});assert.equal(extra.status,409); + assert.equal((await req('/api/intents','POST',{name:'must reject extra reservation',version:'0.2.0-rc.2'})).status,409); + console.log('PASS persisted intent -> real runtime; two running instances; third create and reservation rejected'); +} finally { + if(created)assert.equal((await req('/api/instances/'+created,'DELETE')).status,200); + else await req('/api/intents/'+intent.data.id,'DELETE'); +} +assert.equal((await req('/api/instances')).data.length,1); +console.log('PASS deleted only this test runtime, retained data and prior instance'); diff --git a/scripts/test-mobile-image.mjs b/scripts/test-mobile-image.mjs new file mode 100644 index 0000000..8a267f5 --- /dev/null +++ b/scripts/test-mobile-image.mjs @@ -0,0 +1,81 @@ +import {execFileSync, spawnSync} from 'node:child_process'; +import assert from 'node:assert/strict'; +const image=process.argv[2]||'dsh-web:0.2.0-rc.2-6'; +const docker=(args)=>execFileSync('docker',args,{encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim(); +const id=docker(['run','-d','--network','none','--read-only','--cap-drop','ALL','--security-opt','no-new-privileges','--tmpfs','/tmp:rw,exec,nosuid,nodev,size=256m','--tmpfs','/data/dsh-home:rw,uid=1000,gid=1000,size=256m','--tmpfs','/workspace:rw,uid=1000,gid=1000,size=64m',image]); +try { + let ready=false; + for(let n=0;n<60;n++){ + assert.equal(docker(['inspect','--format','{{.State.Running}}',id]),'true','runtime exited'); + try {docker(['exec',id,'node','-e',"fetch('http://127.0.0.1:8080/').then(r=>process.exit(r.status<500?0:1)).catch(()=>process.exit(1))"]);ready=true;break;}catch{} + await new Promise(r=>setTimeout(r,1000)); + } + assert.ok(ready,'offline startup readiness'); + const startup = spawnSync('docker', ['logs', id], {encoding:'utf8'}); + const token = (startup.stdout + startup.stderr).match(/[?&]token=([^\s\x1b]+)/)?.[1]; + assert.ok(token, 'startup token available'); + const rpc = (method, args = {}) => { + const source = "const {token,method,args}=JSON.parse(require('node:fs').readFileSync(0,'utf8')); (async()=>{const auth=await fetch('http://127.0.0.1:3080/?token='+encodeURIComponent(token),{redirect:'manual'});const cookie=auth.headers.getSetCookie().map(v=>v.split(';')[0]).join('; ');const r=await fetch('http://127.0.0.1:3080/api/'+method,{method:'POST',headers:{'content-type':'application/json',cookie},body:JSON.stringify({type:'client-request',rpcId:'workspace-test',method,payload:{args}})});console.log(JSON.stringify((await r.json()).result));})().catch(()=>process.exit(1));"; + const result = spawnSync('docker',['exec','-i',id,'node','-e',source],{encoding:'utf8',input:JSON.stringify({token,method,args})}); + assert.equal(result.status,0,'authenticated RPC failed'); + return JSON.parse(result.stdout); + }; + const initial = rpc('workspace/initializeDefault'); + assert.equal(initial.ok, true, JSON.stringify(initial)); + assert.equal(initial.value.workspace.path, '/workspace'); + assert.deepEqual(initial.value.workspace.sessionIds, []); + const again = rpc('workspace/initializeDefault'); + assert.deepEqual(again, initial); + const registered = rpc('workspace/create', {request:{path:'/workspace'}}); + assert.equal(registered.ok,true); + assert.equal(registered.value.created,false); + assert.equal(registered.value.workspace.workspaceId, initial.value.workspace.workspaceId); + console.log('PASS real offline fresh registry: canonical /workspace, no sessions, durable default reused by controller and create'); + const manifest=JSON.parse(docker(['exec',id,'cat','/data/dsh-home/profiles/web/package.json'])); + assert.ok(manifest.dsh.profile.bundles.includes('dsh-web-mobile')); + assert.equal(manifest.dependencies['dsh-web-mobile'], 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz'); + const inventory = (home = '/data/dsh-home') => JSON.parse(docker(['exec', id, 'node', '--input-type=module', '-e', "import {readProfilePlugins} from '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-app-boot/lib/index.js'; console.log(JSON.stringify(readProfilePlugins({binName:'dsh',profileDir:" + JSON.stringify(home + '/profiles/web') + ",installAnchor:'/usr/local/lib/node_modules/@deepseek-ai/dsh/package.json'}).dependencies))"])); + assert.deepEqual(inventory().sort((a,b)=>a.name.localeCompare(b.name)), [{name:'dsh-free-search',version:'0.8.5',bundle:true,enabled:true},{name:'dsh-mgr-workspace',version:'1.0.0',bundle:true,enabled:true},{name:'dsh-web-mobile',version:'3.0.5',bundle:true,enabled:true}]); + const bundles = JSON.parse(docker(['exec',id,'node','--input-type=module','-e', "import {PluginManager} from '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-plugin-manager/lib/index.js'; const manager=Object.create(PluginManager.prototype); Object.defineProperties(manager,{profile:{value:{dir:'/data/dsh-home/profiles/web',installAnchor:'/usr/local/lib/node_modules/@deepseek-ai/dsh/package.json'}},ctx:{value:{loader:{entries:()=>[]},get:()=>undefined}},managementBundles:{value:new Set()},ownerEntryId:{value:'include:plugin-manager'}}); console.log(JSON.stringify((await manager.listBundles()).filter(b=>['dsh-web-mobile','dsh-free-search'].includes(b.name))));"])); + assert.equal(bundles.length,2); + for (const bundle of bundles) { + assert.equal(bundle.installed,true); + assert.equal(bundle.enabled,true); + assert.equal(bundle.removable,false); + assert.equal(bundle.error,undefined); + assert.equal(bundle.readOnlyReason,undefined); + } + console.log('PASS upstream listBundles: mobile and free-search installed/enabled, no error/readOnlyReason; baked host bundle not removable'); + docker(['exec',id,'node','--input-type=module','-e', "import {readdirSync,lstatSync,readlinkSync} from 'node:fs'; import {join,isAbsolute} from 'node:path'; import assert from 'node:assert/strict'; const root='/data/dsh-home/profiles/web/node_modules'; assert.deepEqual(readdirSync(root).filter(n=>!n.startsWith('.')),['@deepseek-ai','@standard-schema','dsh-free-search','dsh-mgr-workspace','dsh-web-mobile']); assert.deepEqual(readdirSync(join(root,'@deepseek-ai')).sort(),['cosmokit','schemastery']); assert.deepEqual(readdirSync(join(root,'@standard-schema')),['spec']); function scan(dir){for(const n of readdirSync(dir)){const p=join(dir,n);const s=lstatSync(p);if(s.isSymbolicLink())assert.equal(isAbsolute(readlinkSync(p)),false,p);else if(s.isDirectory())scan(p);}}scan(root);"]); + const dump=docker(['exec',id,'dsh','--profile','web','--dump-config']); + assert.match(dump,/name: ['"]?dsh-web-mobile/); + const logResult=spawnSync('docker',['logs',id],{encoding:'utf8'}); + assert.equal(logResult.status,0); + assert.ok(!/failed to import|entry did not activate|entries did not activate/.test(logResult.stdout+logResult.stderr),'runtime activation failure (logs withheld to protect startup token)'); + docker(['exec',id,'node','--input-type=module','-e', "const {apply}=await import('/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/dsh-free-search/lib/index.js'); const {default:assert}=await import('node:assert/strict'); for(const selected of [undefined,'deepseek-official','custom']){const web={searchProviderId:selected,fetchProviderId:'custom-fetch',registerSearchProvider(){},registerFetchProvider(){}}; apply({web,logger:{info(){},warn(){}},inject(){},get(){}},{}); assert.equal(web.searchProviderId,selected==='custom'?'custom':'ddg'); assert.equal(web.fetchProviderId,'custom-fetch');}"]); + const settings = JSON.parse(docker(['exec',id,'node','-e', "fetch('http://127.0.0.1:3080/api/dsh-free-search-settings/describe',{method:'POST'}).then(async r=>{if(r.status!==200)throw new Error('status '+r.status);console.log(JSON.stringify(await r.json()))})"])); + assert.equal(settings.ok,true); + assert.equal(settings.value.namespaces.find(n=>n.ns==='web-search-free').value.provider,'bing'); + console.log('PASS live free-search activation, official provider takeover and settings describe route'); + console.log('PASS offline nonroot startup, readProfilePlugins inventory, no duplicate peers or absolute symlinks'); + // Restore only the portable profile manifest/patch/lockfile, without workspace or node_modules. + docker(['exec',id,'bash','-ec', 'mkdir -p /tmp/mobile-restore/profiles/web; cp /data/dsh-home/profiles/web/package.json /data/dsh-home/profiles/web/pnpm-lock.yaml /tmp/mobile-restore/profiles/web/; if test -f /data/dsh-home/profiles/web/cordis.patch.yml; then cp /data/dsh-home/profiles/web/cordis.patch.yml /tmp/mobile-restore/profiles/web/; fi']); + docker(['exec',id,'node','-e', "const fs=require('node:fs'); const p='/tmp/mobile-restore/profiles/web/package.json'; const m=JSON.parse(fs.readFileSync(p)); m.dsh.profile.bundles=m.dsh.profile.bundles.filter(n=>n!=='dsh-web-mobile');fs.writeFileSync(p,JSON.stringify(m));"]); + docker(['exec', '-e', 'DSH_HOME=/tmp/mobile-restore', '-e', 'HOME=/tmp/mobile-restore', '-e', 'XDG_DATA_HOME=/tmp/mobile-restore/.local/share', '-e', 'npm_config_ignore_scripts=true', id, 'bash', '-ec', 'node /usr/local/lib/dsh-mgr/init-profile.mjs; dsh plugin --profile web install --ignore-scripts']); + assert.deepEqual(inventory('/tmp/mobile-restore').sort((a,b)=>a.name.localeCompare(b.name)), [{name:'dsh-free-search',version:'0.8.5',bundle:true,enabled:true},{name:'dsh-mgr-workspace',version:'1.0.0',bundle:true,enabled:true},{name:'dsh-web-mobile',version:'3.0.5',bundle:true,enabled:false}]); + const workspace = docker(['exec',id,'cat','/tmp/mobile-restore/profiles/web/pnpm-workspace.yaml']); + assert.match(workspace,/autoInstallPeers: false/); + assert.match(workspace,/nodeLinker: hoisted/); + console.log('PASS offline backup restore without workspace/store/node_modules; disabled bundle preserved'); + docker(['exec',id,'node','-e', "const fs=require('node:fs'); const dir='/tmp/mobile-legacy/profiles/web'; fs.mkdirSync(dir,{recursive:true}); fs.writeFileSync(dir+'/package.json',JSON.stringify({name:'dsh-profile-web',private:true,dependencies:{'dsh-web-mobile':'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz'},dsh:{profile:{bundles:['@deepseek-ai/dsh-base','@deepseek-ai/dsh-web-app','dsh-web-mobile']}}}));"]); + docker(['exec','-e','DSH_HOME=/tmp/mobile-legacy','-e','HOME=/tmp/mobile-legacy','-e','XDG_DATA_HOME=/tmp/mobile-legacy/.local/share','-e','npm_config_ignore_scripts=true',id,'bash','-ec','node /usr/local/lib/dsh-mgr/init-profile.mjs; dsh plugin --profile web install --ignore-scripts']); + assert.deepEqual(inventory('/tmp/mobile-legacy'),[{name:'dsh-web-mobile',version:'3.0.5',bundle:true,enabled:true}]); + console.log('PASS recipe4 mobile-only manifest restore; no free-search injection'); + docker(['exec', id, 'node', '-e', "require('node:fs').writeFileSync('/workspace/normal','preserved')"]); + assert.equal(rpc('workspace/delete', {request:{workspaceId:initial.value.workspace.workspaceId}}).ok, true); + assert.equal(rpc('workspace/initializeDefault').value, undefined); + assert.equal(docker(['exec', id, 'cat', '/workspace/normal']), 'preserved'); + assert.equal(rpc('workspace/create', {request:{path:'/workspace'}}).value.created, true); + assert.equal(rpc('workspace/initializeDefault').value, undefined); + console.log('PASS deleted default stays deleted; files preserved; explicit registration remains available'); +} finally {docker(['rm','-f',id]);console.log('Removed dedicated smoke container (tmpfs data only)');} diff --git a/scripts/test-orphan-live.mjs b/scripts/test-orphan-live.mjs new file mode 100644 index 0000000..7b543d1 --- /dev/null +++ b/scripts/test-orphan-live.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import {readFileSync,mkdirSync,existsSync} from 'node:fs'; +import {execFileSync} from 'node:child_process'; +import {randomBytes} from 'node:crypto'; +const base=process.env.TEST_PANEL_URL, root=process.env.TEST_DATA_ROOT; +assert.ok(base&&root&&process.env.TEST_PASSWORD_FILE); +let cookie='',csrf=''; +async function req(path,method='GET',body){const r=await fetch(base+path,{method,headers:{Cookie:cookie,Origin:base,'X-CSRF-Token':csrf,...(body instanceof FormData?{}:{'Content-Type':'application/json'})},body:body===undefined?undefined:body instanceof FormData?body:JSON.stringify(body)});if(path==='/api/login')cookie=r.headers.getSetCookie().map(x=>x.split(';')[0]).join('; ');return {status:r.status,data:await r.json()};} +const login=await req('/api/login','POST',{username:'admin',password:readFileSync(process.env.TEST_PASSWORD_FILE,'utf8').trim()});assert.equal(login.status,200);csrf=login.data.csrf_token; +const uid=login.data.user.id, inst=randomBytes(16).toString('hex'), home=root+'/workspaces/'+uid+'/instances/'+inst+'/home', workspace=root+'/workspaces/'+uid+'/files'; +mkdirSync(home,{recursive:true}); +const id=execFileSync('docker',['create','--name','dsh-inst-'+inst,'--label','dsh-mgr.role=dsh','--label','dsh-mgr.instance='+inst,'--label','dsh-mgr.owner='+uid,'--mount','type=bind,source='+home+',target=/data/dsh-home','--mount','type=bind,source='+workspace+',target=/workspace','--entrypoint','/bin/true','dsh-web:0.2.0-rc.2-2'],{encoding:'utf8'}).trim(); +assert.ok((await req('/api/orphans')).data.includes(id)); +assert.equal((await req('/api/orphans?id='+id+'&confirm=wrong','DELETE')).status,400); +assert.equal((await req('/api/orphans?id='+id+'&confirm='+id,'DELETE')).status,200); +assert.ok(existsSync(home));assert.ok(existsSync(workspace)); +assert.ok(!(await req('/api/orphans')).data.includes(id)); +console.log('PASS real stopped orphan reclaimed, confirmation enforced, bind directories retained'); +const denied=new FormData();denied.set('path','.');denied.set('file',new Blob(['harmless fixture']),'design-denied.exe'); +assert.equal((await req('/api/ws/upload','POST',denied)).status,415); +const name='design-allowed-'+inst+'.txt', allowed=new FormData();allowed.set('path','.');allowed.set('file',new Blob(['harmless fixture']),name); +assert.equal((await req('/api/ws/upload','POST',allowed)).status,200);assert.equal((await req('/api/ws/delete?path='+name,'DELETE')).status,200); +console.log('PASS configured upload whitelist accepts .txt and rejects .exe'); diff --git a/scripts/test-panel.mjs b/scripts/test-panel.mjs new file mode 100644 index 0000000..141d680 --- /dev/null +++ b/scripts/test-panel.mjs @@ -0,0 +1,87 @@ +// Integration test against a disposable, locally running panel. No model calls. +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +const base = process.env.TEST_PANEL_URL || 'http://127.0.0.1:18088'; +const testDirectory='integration-'+Date.now(); +let cookie = '', csrf = '', instance; +const cookies = new Map(); +function keepCookies(response) { + for (const value of response.headers.getSetCookie()) { const pair=value.split(';')[0]; const at=pair.indexOf('='); cookies.set(pair.slice(0,at),pair.slice(at+1)); } + cookie=[...cookies].map(([key,value])=>key+'='+value).join('; '); +} +async function request(path, method = 'GET', body) { + if(path.startsWith('/api/ws/') && instance) path += (path.includes('?')?'&':'?')+'instance='+encodeURIComponent(instance.id); + const response = await fetch(base + path, {method,headers:{Cookie:cookie,Origin:base,'X-CSRF-Token':csrf,...(body instanceof FormData ? {} : {'Content-Type':'application/json'})},body:body===undefined?undefined:body instanceof FormData?body:JSON.stringify(body)}); + keepCookies(response); + const text = await response.text(); let data; try {data=JSON.parse(text)}catch{data=text} + if(!response.ok)throw new Error(method+' '+path+': '+response.status+' '+String(typeof data==='object'?data.error:data).slice(0,300)); + return {response,data}; +} +try { + const login=await request('/api/login','POST',{username:process.env.TEST_USERNAME || 'testadmin',password:process.env.TEST_PASSWORD || 'Local-Test-Only-1842'}); + cookie=login.response.headers.getSetCookie().map(v=>v.split(';')[0]).join('; ');csrf=login.data.csrf_token; + assert.equal((await request('/api/me')).data.user.username,process.env.TEST_USERNAME || 'testadmin'); + await request('/api/refresh','POST'); + assert.equal((await request('/api/me')).data.user.username,process.env.TEST_USERNAME || 'testadmin'); + let images=(await request('/api/images')).data; + const build=(await request('/api/builds','POST',{version:process.env.TEST_FROM_VERSION || images[0]?.version || '0.2.0-rc.2'})).data; + let buildSucceeded=false; + for(let n=0;n<120;n++){const jobs=(await request('/api/builds')).data;const job=jobs.find(j=>j.id===build.id);if(job?.status==='failed')throw new Error('build/validation failed: '+job.error);if(job?.status==='success'){buildSucceeded=true;break;}await new Promise(r=>setTimeout(r,1000));} + assert.ok(buildSucceeded,'submitted build/validation job did not complete before timeout'); + images=(await request('/api/images')).data.filter(i=>i.status==='available');assert.ok(images.length,'validated image required'); + const source=images.find(i=>!process.env.TEST_FROM_VERSION || i.version===process.env.TEST_FROM_VERSION); + const target=images.find(i=>!process.env.TEST_TO_VERSION || i.version===process.env.TEST_TO_VERSION); + assert.ok(source,'source image required');assert.ok(target,'target image required'); + console.log('PASS local image reuse, persistent build task and runtime verification'); + instance=(await request('/api/instances','POST',{name:'Integration check',image_id:source.image_id || source.id,version:source.version})).data; + await request('/api/ws/mkdir','POST',{path:testDirectory}); + const form=new FormData();form.set('path',testDirectory);form.set('file',new Blob(['hello workspace']), 'hello.txt');await request('/api/ws/upload','POST',form); + assert.equal((await request('/api/ws/download?path='+testDirectory+'/hello.txt')).data,'hello workspace'); + await request('/api/ws/rename','POST',{path:testDirectory+'/hello.txt',new_path:testDirectory+'/renamed.txt'}); + const zip=await fetch(base+'/api/ws/download?path='+testDirectory+'&instance='+encodeURIComponent(instance.id),{headers:{Cookie:cookie}}); + assert.equal(zip.status,200);assert.match(zip.headers.get('content-type'),/zip/); + assert.equal(new Uint8Array(await zip.arrayBuffer()).slice(0,2).toString(),'80,75'); + console.log('PASS login, workspace upload/download/rename'); + let ready=false; + for(let n=0;n<45;n++){try{const r=await request('/dsh/'+instance.id+'/');if(typeof r.data==='string'&&r.data.includes('setTimeout(r,1000));} + assert.ok(ready,'authenticated DSH proxy never became ready'); + console.log('PASS real Docker create/start and authenticated DSH proxy'); + if(process.env.TEST_PLUGIN==='1'){ + const seed = `const fs=require('fs');const p=process.env.DSH_HOME+'/profiles/web/package.json';const pkg=JSON.parse(fs.readFileSync(p,'utf8'));pkg.dependencies={...(pkg.dependencies||{}),'is-number':'7.0.0'};fs.writeFileSync(p,JSON.stringify(pkg));`; + execFileSync('docker',['exec','dsh-inst-'+instance.id,'node','-e',seed]); + console.log('Seeded exact plugin dependency fixture'); + } + const root='/api/instances/'+instance.id; + await request(root+'/stop','POST'); + const profile=(await request(root+'/profile')).data;assert.equal(typeof profile.content,'string'); + await request(root+'/profile','PUT',{content:profile.content}); + const backup=(await request(root+'/backups','POST',{password:process.env.TEST_PASSWORD || 'Local-Test-Only-1842'})).data; + assert.ok(backup.id,'backup ID'); + const archive=await fetch(base+root+'/backups?download='+backup.id,{headers:{Cookie:cookie}}); + assert.equal(archive.status,200); + const importForm=new FormData();importForm.set('file',new Blob([await archive.arrayBuffer()]),'roundtrip.dshbak');importForm.set('password',process.env.TEST_PASSWORD || 'Local-Test-Only-1842'); + const imported=(await request(root+'/backups','POST',importForm)).data;assert.ok(imported.id); + await request(root+'/restore','POST',{backup_id:backup.id,password:process.env.TEST_PASSWORD || 'Local-Test-Only-1842'}); + console.log('PASS stopped profile save, encrypted backup and restore'); + await request(root+'/start','POST');await request(root+'/stop','POST'); + if(process.env.TEST_UPGRADE==='1'){instance=(await request(root+'/upgrade','POST',{image_id:target.image_id||target.id,password:process.env.TEST_PASSWORD || 'Local-Test-Only-1842'})).data;console.log('PASS image replacement upgrade '+source.version+' -> '+target.version);} + if(process.env.TEST_PLUGIN==='1'){ + execFileSync('docker',['exec',instance.container_id || 'dsh-inst-'+instance.id,'node','-e',`const fs=require('fs');const p=process.env.DSH_HOME+'/profiles/web/node_modules/is-number/package.json';if(JSON.parse(fs.readFileSync(p)).version!=='7.0.0')process.exit(1)`]); + console.log('PASS manifest dependency rebuilt across restore and upgrade'); + } + if(process.env.TEST_OFFLINE_ROLLBACK==='1'){ + const container=instance.container_id;assert.ok(container); + execFileSync('docker',['exec',container,'node','-e',`const fs=require('fs');const p=process.env.DSH_HOME+'/profiles/web/package.json';const pkg=JSON.parse(fs.readFileSync(p));pkg.dependencies['offline-design-fixture']='http://127.0.0.1:9/unavailable.tgz';fs.writeFileSync(p,JSON.stringify(pkg));fs.writeFileSync(process.env.DSH_HOME+'/rollback-marker','preserve-me');`]); + await request(root+'/stop','POST'); + await assert.rejects(request(root+'/upgrade','POST',{image_id:target.image_id||target.id,password:process.env.TEST_PASSWORD}),/upgrade readiness failed/); + const current=(await request('/api/instances')).data.find(i=>i.id===instance.id); + assert.equal(current.container_id,container);assert.ok(['stopped','exited'].includes(current.status));assert.equal(current.version,target.version); + const inspect=JSON.parse(execFileSync('docker',['inspect',container]));assert.equal(inspect[0].State.Running,false); + console.log('PASS offline dependency failure rolls back identity and original stopped state'); + } + await request('/api/ws/delete?path='+testDirectory+'/renamed.txt','DELETE'); + await request('/api/ws/delete?path='+testDirectory,'DELETE'); + console.log('PASS lifecycle and workspace cleanup'); +}finally{ + if(instance){await request('/api/instances/'+instance.id,'DELETE');console.log('Removed disposable instance; retained test data per API policy.');} +} diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..053c127 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.tsbuildinfo +.playwright-cli/ +output/ diff --git a/web/API_REQUEST.md b/web/API_REQUEST.md new file mode 100644 index 0000000..bd03bf3 --- /dev/null +++ b/web/API_REQUEST.md @@ -0,0 +1,11 @@ +# 前后端协调 + +前端 agent 当前工具没有 actor/send,无法直接发送给 general-2。请后端在根目录 API.md 记录实际接口、鉴权/CSRF、列表 envelope、实例/profile/备份/升级、文件上传和管理员字段。前端将以 API.md 校对实现,不模拟成功。 + +Vite 开发监听 127.0.0.1,代理默认 http://127.0.0.1:8088,生产构建 web/dist,同源 /api 和 /dsh。 + +已完成中文页面与首次构建,当前待确认的响应字段:images 是 id 还是 image_id;builds 日志字段 logs;ws/tree 是否直接数组及 is_dir/mod_time;usage used_bytes/quota_bytes;audit user_id/created_at;backups id/size/created_at。请明确空列表返回 [] 或 null。恢复/下载备份/升级接口尚未列出,UI 将明确提示缺口而不猜测接口。 + +11:24 检查 internal/api/api.go:创建实例入参目前 struct{ Name, ImageID, Version string } 无 json 标签,image_id 不会映射到 ImageID,需后端补标签。生命周期仍只是写 start/stop 字符串而非真实 Docker 操作,前端不会把 start 字符串映射成 running。请务必真实实现或返回明确错误,避免假成功。 + +验证:npm test 六项全部通过;npm run build 通过。Playwright 真浏览器已验证桌面/390px 移动登录界面、离线登录错误,移动 documentWidth=390 无横向溢出。8088 暂未启动,无法验证登录后页面;请父 agent 启动后端后通知可用临时验证账号,避免操作共享数据。 diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..250f3a9 --- /dev/null +++ b/web/README.md @@ -0,0 +1,26 @@ +# DSH 中文工作台 + +React 19 + TypeScript + Vite;普通 CSS 响应式布局,没有运行时演示数据。 + +## 开发与验证 + +使用 Node.js 22+: + +~~~bash +npm ci +npm run dev +npm test +npm run build +~~~ + +开发服务只监听 127.0.0.1:5173,/api 和 /dsh 代理至 127.0.0.1:8088。生产产物为 dist/,由后端同源提供;不能直接通过 file:// 使用。 + +## API 与安全 + +- 根目录 API.md 是接口契约;API 客户端集中在 src/api.ts,可注入 fetch 进行独立单元测试。 +- HttpOnly Cookie 登录;CSRF token 仅留在内存,刷新通过 /api/me 重新获取。密码、token 不写入 localStorage。 +- 操作失败保留错误,不模拟成功;删除、重启、构建等操作二次确认。 +- 备份恢复和升级等待后端契约,当前明确显示不可用说明,不发送猜测接口。 +- 单元测试里的 fetch 替身仅用于测试,不打包进生产页面。 + +当前后端仍在实现部分端点,页面会真实显示其 HTTP 错误。请不要把前端入口存在等同于后端能力已交付。 diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..0477518 --- /dev/null +++ b/web/index.html @@ -0,0 +1,2 @@ + +DSH · 工作台
diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..de320fa --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2340 @@ +{ + "name": "dsh-web-console", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dsh-web-console", + "version": "0.1.0", + "dependencies": { + "monaco-editor": "^0.52.2", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "~5.7.3", + "vite": "^6.1.0", + "vitest": "^3.0.5" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.10", + "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.10", + "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.3.tgz", + "integrity": "sha512-a31GFbL70GlGUGhn8goVjRLtdDG1urLCJFWLZ7JcxdHK9pPvCohLFtYLgJDdNbtrNZSrJl1u9bK05ISsSC2EpA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.3.tgz", + "integrity": "sha512-IRv757RLiPGlNxAX5JXU0eiUlUPvKiwFRbkqLd+gywptLK4uSEg1qkl6en6r0cXMazSnWUWOBABv+QfuLV3n5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.3.tgz", + "integrity": "sha512-UkzI35Fnz6+MJNATdnjnZ3TpdG/rXab8h7oDOD9R0zDVIFmuuYrvgUj8V8mh+A//+LR4TlEQo4ZW4RhyRtWv3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.3.tgz", + "integrity": "sha512-C3AyMtAnKQ/jSkZ0chIoWbPXRbFz8JnPLhl1ULecEhcaOGkT19xt7KmO8BulQxt9S/XideHRXvsVQUbGj4JCOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.3.tgz", + "integrity": "sha512-M8yAdBMyHh14JLeXDNGvEkljzuHdcoLBQ399O4Fp4nvX51lueuEY/5U2G9Ci0D6/bQlBp/3JLiBQH55STwwbwg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.3.tgz", + "integrity": "sha512-WQjuOKGh2FN98ZK6i0uM6M6YqnPEds85p3oRkrxiEeTQo/p7RpuU8c9MwQ88LfgwVAzohumlzbQQSikz4Zw9rw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.3.tgz", + "integrity": "sha512-5tDLZG5s9WyVKoEHgwos6FpKrgpotl61EYeHSLSVkQzMzTsaClwEr8mok9rm3y734zjiGInG4dfw/56ebWVvEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.3.tgz", + "integrity": "sha512-Mh5KbLeplM2HX6lQI18jsxSsqvPW8d+Hyk8HtY6Zl93woMitRSCZ92rBEzq6v8bfZGlpyMgj4P/sKW4KCjhH3A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.3.tgz", + "integrity": "sha512-QZrrwHnznTv6y4iiHtCqDN+ZsF4qNFQObMOLgPrbxhh9O5o4rgoUe5OnkqG/ceCObMrAjhQt+eQ6HSDzBFgVMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.3.tgz", + "integrity": "sha512-4vLehpXSwlkltnvk5ETB+Hz+d3xayZS3NamZ5Vq4bSo+IUoIALovEJF1YgWPMjHwN7R5o/R4BpP9h3q8EK1xAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.3.tgz", + "integrity": "sha512-SEN7TTTOdXJKZmsWKOnQeVfgy3Dje/08ISXWwFhTggQ3SxjuIaChvmOB5ZDZqJNlBjaXrV6nAHraqEAvdLGqcw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.3.tgz", + "integrity": "sha512-1LDsQMRMCEdGF9Dih5qyh6fF1LK3NnA+pAtE2+9AgZwUdXyd19TOBVC9crSB63iZBHR4vOG7h8gN/LcUH3J7xw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.3.tgz", + "integrity": "sha512-v3D2tAfRriqGOi+5Ac5CiYbBo2NXtLVxJlludTvUZRj502lVMFeVIuvvWbz4qCrotn3e3Uvpvh7FcMpG2QxxXQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.3.tgz", + "integrity": "sha512-db5q6dP189V2+JkMpsXBl8ym6bC0x8ydZ47fUJZ9YdM6T2OF5uqNzX9HaAZHRP/lqjA9bJeNwi0qw1/2/37dUA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.3.tgz", + "integrity": "sha512-nArAVDmrL5ix9UoipcKEsYJw4kMUQCIPYRWuYF2POOC7jd9rmN95d/48ewsYFC/OdtwEx6VYYWYxrp8FbMOU2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.3.tgz", + "integrity": "sha512-2dc7LElu7EVSvpQT8epNnBLa02wlqilNYqHN0FA/RaKqCr9U9ZFYQ0Eh4sfgSzLneLDuJsJRjcezR/l2UyYGXA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.3.tgz", + "integrity": "sha512-9PYq2IEzSwXcvQLERcT9LKXRzw8J05GFTbJHA2anJR2WmlRjA1esgXVYhanp+bmjKsDVoZeU2L7Z2pRClLcZTw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.3.tgz", + "integrity": "sha512-vMh+mt0cw1BHldd8cqaU6A5s1W4EUKl6+8MRsv86mj954L4xyruKD7qPXMUI2icImadAZARK10h0onaGTLmCVg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.3.tgz", + "integrity": "sha512-PpiKU+sWbqDGc0U0su8oXiyeyqm0UQjmaaPKHktX+DaLe6YKJwW2YVD0f2AXHdVvblZbRqpwRVClWP2F5HCqrQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.3.tgz", + "integrity": "sha512-/3enY/AmaIt8IS0fWjX4ztYVPBqkj49cQTy/hCryhBFaEzDfJwwEolq9sxvyWAM2qmsmVuPdeRg5JXSrmeqvJg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.3.tgz", + "integrity": "sha512-zsEP/QDuet52hhwvi5T1VPg0/Tkms0m3zkVv7CPsznvhdO4pL4oJTMz26EJsiCe18OeLKPgpTu3XVO3C1eq3ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.3.tgz", + "integrity": "sha512-4ESknW4Db+kqvazVOfsReYJaHxcIsOqcgwjKV2J1tG5Bn4RVf5ocTQKSGuq2HWBJcOLuyim8IWX8DuH6nKmELA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.3.tgz", + "integrity": "sha512-3B8VCQyIyv7FQxYYhb3HpR3bIpronR20txjMSffSoz/sm/DDs3/49CFpEueOsvq9qW9sWoI/6pi6mtciD9NXFg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.3.tgz", + "integrity": "sha512-YOoDkKh2oQxOxRB5D9AR8sj3qpMShpmyTkjojiBjMGigz3caV7UvCnXWWUQC0TBmrZ5zuHe2pWl8S+cMfvXzRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.3.tgz", + "integrity": "sha512-qTv4bG+eKJKifzxNJevMUh8L6CjJrz9/Kn1IbP2F5bo0tBzNIR7HMg4vni6XmDzcKUl00lHHY+J5ZgjbGiujOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.28", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz", + "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001815", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", + "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmmirror.com/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.452", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.452.tgz", + "integrity": "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmmirror.com/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/monaco-editor": { + "version": "0.52.2", + "resolved": "https://registry.npmmirror.com/monaco-editor/-/monaco-editor-0.52.2.tgz", + "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.58", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.58.tgz", + "integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.29", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmmirror.com/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.64.3", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.64.3.tgz", + "integrity": "sha512-iIMWbptLYsZdGTJMH8ngApXdsCatE6bCMv2eqGIfGE1++kwh7TCWBdWKK0FKC0yx++196vGK5VdagzRXSLMa8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.3", + "@rollup/rollup-android-arm64": "4.64.3", + "@rollup/rollup-darwin-arm64": "4.64.3", + "@rollup/rollup-darwin-x64": "4.64.3", + "@rollup/rollup-freebsd-arm64": "4.64.3", + "@rollup/rollup-freebsd-x64": "4.64.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.3", + "@rollup/rollup-linux-arm-musleabihf": "4.64.3", + "@rollup/rollup-linux-arm64-gnu": "4.64.3", + "@rollup/rollup-linux-arm64-musl": "4.64.3", + "@rollup/rollup-linux-loong64-gnu": "4.64.3", + "@rollup/rollup-linux-loong64-musl": "4.64.3", + "@rollup/rollup-linux-ppc64-gnu": "4.64.3", + "@rollup/rollup-linux-ppc64-musl": "4.64.3", + "@rollup/rollup-linux-riscv64-gnu": "4.64.3", + "@rollup/rollup-linux-riscv64-musl": "4.64.3", + "@rollup/rollup-linux-s390x-gnu": "4.64.3", + "@rollup/rollup-linux-x64-gnu": "4.64.3", + "@rollup/rollup-linux-x64-musl": "4.64.3", + "@rollup/rollup-openbsd-x64": "4.64.3", + "@rollup/rollup-openharmony-arm64": "4.64.3", + "@rollup/rollup-win32-arm64-msvc": "4.64.3", + "@rollup/rollup-win32-ia32-msvc": "4.64.3", + "@rollup/rollup-win32-x64-gnu": "4.64.3", + "@rollup/rollup-win32-x64-msvc": "4.64.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmmirror.com/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.4", + "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.4.tgz", + "integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..3a123d0 --- /dev/null +++ b/web/package.json @@ -0,0 +1,25 @@ +{ + "name": "dsh-web-console", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "tsc -b && vite build", + "preview": "vite preview", + "test": "vitest run" + }, + "dependencies": { + "monaco-editor": "^0.52.2", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "~5.7.3", + "vite": "^6.1.0", + "vitest": "^3.0.5" + } +} diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..b3feb26 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/web/src/Admin.tsx b/web/src/Admin.tsx new file mode 100644 index 0000000..f2ee224 --- /dev/null +++ b/web/src/Admin.tsx @@ -0,0 +1,17 @@ +import { useState, type FormEvent } from 'react'; +import { QuotaDialog } from './QuotaDialog'; +import { quotaBytes } from './quota'; +import { passwordResetError } from './passwordReset'; +import { Orphans } from './Orphans'; +import { ResetPassword } from './ResetPassword'; +import { api, type User, type Audit } from './api'; +import { Panel, ResourceState, Empty, bytes, date, useAction, useResource } from './ui'; +export function Admin() { + return <>; +} +function AdminAccounts() { + const users = useResource(() => api.request('/users')), audit = useResource(() => api.request('/audit')); const action = useAction(); + const [quotaUser, setQuotaUser] = useState(null), [notice, setNotice] = useState(''); + function create(e: FormEvent) {e.preventDefault(); const element = e.currentTarget, form = new FormData(element); void action.run(async () => {const password = String(form.get('password') || ''); const error = passwordResetError(password, password); if (error) throw new Error(error); const quota = quotaBytes(String(form.get('quota'))); if (quota === null) throw new Error('请输入有效配额'); await api.request('/users','POST',{username:form.get('username'),password:form.get('password'),admin:form.get('admin') === 'on',quota_bytes:quota}); element.reset(); await Promise.all([users.refresh(),audit.refresh()]);}, '账号已创建');} + return <>{action.feedback}{notice &&
{notice}
}{quotaUser && setQuotaUser(null)} onSaved={() => {setNotice(quotaUser.username + ' 的配额已更新'); void users.refresh(); void audit.refresh();}}/>}
刷新}>{users.data?.length === 0 && 暂无用户}{!!users.data?.length &&
{users.data.map(u => )}
用户角色配额创建时间账号安全
{u.username}{u.id}{u.admin ? '管理员' : '成员'}
{bytes(u.quota_bytes)}
{date(u.created_at)}
}
刷新}>{audit.data?.length === 0 && 暂无审计记录}{!!audit.data?.length &&
{audit.data.map(a => )}
时间用户操作目标详情
{date(a.created_at)}{a.user_id}{a.action}{a.target}{a.detail}
}
; +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..c74432e --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,59 @@ +import { useEffect, useState, type FormEvent } from 'react'; +import { api, ApiError, type User } from './api'; +import { Alert, message, Panel, useAction } from './ui'; +import { Instances } from './Instances'; +import { Images } from './Images'; +import { RuntimeSelector } from './RuntimeSelector'; +import { Workspace } from './Workspace'; +import { Admin } from './Admin'; +import { ChangePassword } from './ChangePassword'; +import { RuntimeWorkspace, useRuntimeInstances } from './RuntimeWorkspace'; +const pages = [{id:'dsh',label:'DSH',icon:'◈',description:'你的 DeepSeek Harness 工作台'}, {id:'overview',label:'实例',icon:'◫',description:'管理你的 DeepSeek Harness 运行环境'}, {id:'workspace',label:'文件',icon:'▤',description:'管理当前实例的文件与目录'}, {id:'settings',label:'设置',icon:'⚙',description:'选择默认实例与管理工作台'}]; +const secondaryPages = [{id:'images',label:'镜像与构建',description:'本地版本管理 · 手动构建 · 任务日志'}, {id:'admin',label:'用户与审计',description:'账号管理与操作追踪'}]; +export default function App() { + const [user, setUser] = useState(null), [loading, setLoading] = useState(true), [error, setError] = useState(''); + useEffect(() => { let active = true; api.me().then(s => {if(active) setUser(s.user);}).catch(e => {if(active && (!(e instanceof ApiError) || e.status !== 401)) setError(message(e));}).finally(() => {if(active) setLoading(false);}); const expired = (event: Event) => {setUser(null); setError(event instanceof CustomEvent && typeof event.detail === 'string' ? event.detail : '会话已过期,请重新登录。');}; window.addEventListener('session-expired', expired); return () => {active = false; window.removeEventListener('session-expired', expired);}; }, []); + if (loading) return
正在连接工作台…
; + if (!user) return {setUser(u); setError('');}} />; + return setUser(null)} onPasswordChanged={() => {setUser(null); setError('密码已修改,请使用新密码重新登录。');}}/>; +} +function Console({user, onLogout, onPasswordChanged}: {user: User; onLogout: () => void; onPasswordChanged: () => void}) { + const [page, setPage] = useState('dsh'); + const logout = useAction(); + const runtime = useRuntimeInstances(user.id, page); + const current = [...pages, ...secondaryPages].find(p => p.id === page)!; + const primaryPage = secondaryPages.some(p => p.id === page) ? 'settings' : page; + const exit = () => void logout.run(async () => {await api.logout(); onLogout();}); + return
+ +
控制台 / {current.label}本地镜像 · 独立实例
+ {logout.feedback} + setPage('overview')} settings={() => setPage('settings')}/> + {page !== 'dsh' &&

DEEPSEEK HARNESS

{current.label}

{current.description}

+ {page === 'overview' && } + {page === 'images' && <>} + {page === 'workspace' && } + {page === 'admin' && user.admin && <>} + {page === 'settings' && <> + void runtime.refresh()}>刷新}> + {runtime.error && {runtime.error}} + {runtime.loading &&

正在刷新实例…

} + +

仅在此浏览器按账号记住实例 ID,不保存密钥。已选择的停止实例不会自动切换。

+
+
+
{user.admin && }
+

{user.username} · {user.admin ? '管理员' : '成员'}

修改密码会使所有设备退出登录。

仅退出当前浏览器会话,不影响运行中的实例。

+ } +
DSH 工作台 数据与操作以服务器实际结果为准
} +
+
; +} +function Login({error, onLogin}: {error: string; onLogin: (user: User) => void}) { + const action = useAction(); + function submit(e: FormEvent) {e.preventDefault(); const form = new FormData(e.currentTarget); void action.run(async () => {const session = await api.login(String(form.get('username')), String(form.get('password'))); onLogin(session.user);});} + return
DSH●HARNESS CONSOLE

你的 AI 开发空间

专注创造,
让环境管理更简单。

独立实例、本地镜像、实例数据根。
在一个工作台中,掌控你的 DeepSeek Harness。

安全会话 · 资源隔离 · 操作审计

WELCOME BACK

登录工作台

使用管理员分配的账号继续

{error && {error}}{action.feedback}

没有账号?请联系平台管理员。
登录凭据不会存储在浏览器本地存储中。

; +} diff --git a/web/src/BackupPanel.tsx b/web/src/BackupPanel.tsx new file mode 100644 index 0000000..044328a --- /dev/null +++ b/web/src/BackupPanel.tsx @@ -0,0 +1,26 @@ +import { useState } from 'react'; +import { api, instancePath, type Instance, type Backup } from './api'; +import { ResourceState, Empty, bytes, date, useAction, useResource } from './ui'; + +export function BackupPanel({ instance, refresh }: { instance: Instance; refresh: () => Promise }) { + const base = instancePath(instance.id); + const backups = useResource(() => api.request(base + '/backups')); + const action = useAction(); + const [password, setPassword] = useState(''), [originalPassword, setOriginalPassword] = useState(''), [file, setFile] = useState(); + return
+

操作会按需停止实例并恢复原运行状态。备份使用创建时的登录密码加密;修改登录密码不改变旧包密码。恢复包含插件重建、重启验证和失败回滚。

+ {action.feedback} +
{ e.preventDefault(); void action.run(async () => { try { await api.request(base + '/backups', 'POST', { password }); await backups.refresh(); } finally { setPassword(''); } }, '加密备份已创建'); }}> + + +
+ +
{ e.preventDefault(); if (!file) return; void action.run(async () => { try { if (file.size > 44743364) throw new Error('备份包超过大小限制'); const form = new FormData(); form.set('file', file); form.set('password', originalPassword); await api.request(base + '/backups', 'POST', form); await backups.refresh(); } finally { setOriginalPassword(''); } }, '备份已验证并导入;尚未覆盖当前配置'); }}> + + +
+ + {backups.data?.length === 0 && 暂无配置备份} + {backups.data?.map(b =>
{b.id}{date(b.created_at)}{bytes(b.size)}恢复
)} +
; +} diff --git a/web/src/ChangePassword.tsx b/web/src/ChangePassword.tsx new file mode 100644 index 0000000..f6d182e --- /dev/null +++ b/web/src/ChangePassword.tsx @@ -0,0 +1,8 @@ +import { useState } from 'react'; +import { api } from './api'; +import { PasswordDialog } from './PasswordDialog'; + +export function ChangePassword({onChanged}: {onChanged: () => void}) { + const [open, setOpen] = useState(false); + return <>{open && setOpen(false)} onSubmit={async (password, current) => {await api.request('/me/password', 'POST', {current_password:current, password}); onChanged();}}/>}; +} diff --git a/web/src/CreateInstance.tsx b/web/src/CreateInstance.tsx new file mode 100644 index 0000000..5240f75 --- /dev/null +++ b/web/src/CreateInstance.tsx @@ -0,0 +1,40 @@ +import { useEffect, useRef, useState, type FormEvent } from 'react'; +import { api, type Image, type Instance } from './api'; +import { ResourceState, useAction, useResource } from './ui'; + +export function CreateInstance({close, refresh}: {close: () => void; refresh: () => Promise}) { + const images = useResource(api.images), action = useAction(); + const [mode, setMode] = useState('local'), [progress, setProgress] = useState(''); + const [version, setVersion] = useState(''); + const [intents, setIntents] = useState<{id:string; name:string; version:string; status:string; error:string}[]>([]); + useEffect(() => {let live=true; const load=()=>api.request('/intents').then(v=>{if(live)setIntents(v);}).catch(e=>{if(live)setProgress(String(e));}); void load(); const timer=window.setInterval(load,2000); return ()=>{live=false;window.clearInterval(timer);};}, []); + const active = useRef(true); + useEffect(() => {active.current = true; return () => {active.current = false;};}, []); + const available = images.data?.filter(i => !i.status || i.status === 'available') || []; + async function submit(e: FormEvent) { + e.preventDefault(); + const form = new FormData(e.currentTarget), name = String(form.get('name')); + await action.run(async () => { + let image: Image | undefined = available.find(i => (i.image_id || i.id) === form.get('image')); + if (mode === 'latest') { + if (!version) { + const latest = await api.request<{version:string}>('/latest'); + if (!latest.version) throw new Error('服务器未返回精确版本。'); + setVersion(latest.version); return; + } + const intent = await api.request<{id:string}>('/intents', 'POST', {name, version}); + if (!intent.id) throw new Error('未收到意图确认,请刷新核实,勿重复提交。'); + setIntents(await api.request('/intents')); + await refresh(); return; + } + if (!active.current) return; + if (!image) throw new Error('请选择可用的本地镜像。'); + setProgress('正在创建实例,请勿重复提交…'); + const created = await api.request('/instances', 'POST', {name, image_id:image.image_id || image.id, version:image.version}); + if (!created?.id) throw new Error('未收到实例确认,请刷新实例列表核实,勿重复提交。'); + if (active.current) {await refresh(); close();} + }); + if (active.current) setProgress(''); + } + return

创建独立实例

{action.feedback}

确认后服务器持久预留一个名额(每用户最多两个)。刷新或关闭不会取消自动创建;失败释放名额,不回退其他版本。取消等待不会取消共享构建,创建开始后不可撤回。

{mode === 'local' && <>{!images.loading && !available.length &&

暂无可用镜像,可选择构建最新版并创建。

}}{mode === 'latest' && version &&

将锁定精确版本 {version},确认后预留名额并构建创建。

}
{progress &&

{progress}

}
    {intents.map(i =>
  • {i.name} · {i.version} · {i.status} {i.error}{i.status === 'waiting' && }{i.status === 'success' && }
  • )}
; +} diff --git a/web/src/Dialog.tsx b/web/src/Dialog.tsx new file mode 100644 index 0000000..72f1d95 --- /dev/null +++ b/web/src/Dialog.tsx @@ -0,0 +1,14 @@ +import { useEffect, useId, useRef, type ReactNode } from 'react'; + +export function Dialog({title, busy, onClose, children}: {title: string; busy: boolean; onClose: () => void; children: ReactNode}) { + const ref = useRef(null), titleId = useId(); + useEffect(() => { + const trigger = document.activeElement as HTMLElement | null, dialog = ref.current!; + dialog.showModal(); + dialog.querySelector('input')?.focus(); + return () => {dialog.close(); if (trigger?.isConnected) trigger.focus();}; + }, []); + return {event.preventDefault(); if (!busy) onClose();}}> +

{title}

{children} +
; +} diff --git a/web/src/Images.tsx b/web/src/Images.tsx new file mode 100644 index 0000000..985de0b --- /dev/null +++ b/web/src/Images.tsx @@ -0,0 +1,10 @@ +import { useState, type FormEvent } from 'react'; +import { api } from './api'; +import { Panel, ResourceState, Empty, Status, date, useAction, useResource } from './ui'; +export function Images({admin = false}: {admin?:boolean}) { + const images = useResource(api.images), builds = useResource(api.builds, '', 5000); const action = useAction(); + const [latest, setLatest] = useState<{version:string; checked_at?:string}>(), [version,setVersion] = useState(''), [selected,setSelected] = useState(); + function build(e: FormEvent) {e.preventDefault(); void action.run(async () => {await api.request('/builds','POST',{version}); await builds.refresh();}, '构建任务已提交,请查看任务的实际状态。', '确认在本机手动构建精确版本 ' + version + '?构建会消耗磁盘、网络与 CPU,不会自动升级实例。');} + const current = builds.data?.find(b => b.id === selected); + return <>{action.feedback}

从 npm 查询 dist-tags.latest,仅查询,不自动构建。

{latest &&

{latest.version} 查询时间 {date(latest.checked_at)}

}

镜像仅保存在本机。构建成功不等于实例升级;创建或升级前请检查可用状态。

刷新}>{images.data?.length === 0 && 本机暂无托管镜像,请先手动构建。}{!!images.data?.length &&
{images.data.map(i => )}
DSH 版本镜像标识标签状态使用/操作
{i.version}{i.image_id || i.id}{i.tag || '—'}{i.usage_count ?? 0} 个实例 {admin && }
}
每 5 秒自动刷新}>{builds.data?.length === 0 && 没有构建任务记录}{!!builds.data?.length &&
{builds.data.map(b => )}
任务版本状态提交时间日志
{b.id}{b.version}{date(b.created_at)}
}{current &&

{current.version} · 构建日志

{current.error &&

{current.error}

}
{current.logs || '任务尚未输出日志'}
}
; +} diff --git a/web/src/Instances.tsx b/web/src/Instances.tsx new file mode 100644 index 0000000..e019ec2 --- /dev/null +++ b/web/src/Instances.tsx @@ -0,0 +1,25 @@ +import { useState } from 'react'; +import { ProfileEditor } from './ProfileEditor'; +import { BackupPanel } from './BackupPanel'; +import { CreateInstance } from './CreateInstance'; +import { api, instancePath, type Instance } from './api'; +import { Panel, ResourceState, Empty, Status, bytes, date, useAction, useResource } from './ui'; +export function Instances() { + const instances = useResource(api.instances, '', 10000), usage = useResource(api.usage); + const action = useAction(); const [creating, setCreating] = useState(false), [selected, setSelected] = useState(); + function operate(i: Instance, operation: string) { void action.run(async () => {await api.request(instancePath(i.id) + (operation === 'delete' ? '' : '/' + operation), operation === 'delete' ? 'DELETE' : 'POST'); if (selected?.id === i.id && operation === 'delete') setSelected(undefined); await instances.refresh();}, '服务器已完成操作', operation === 'delete' ? '确认删除实例「' + i.name + '」?此操作不可撤销。' : operation !== 'start' ? '确认停止或重启「' + i.name + '」?当前连接将中断。' : undefined); } + return <>
我的实例{instances.data ? instances.data.length : '—'} / 2

每个账号最多两个实例

运行中{instances.data ? instances.data.filter(i => i.status === 'running').length : '—'}

独立的 DSH 运行环境

工作区用量{bytes(usage.data?.used_bytes)}

配额 {bytes(usage.data?.quota_bytes)}

{action.feedback}}>{creating && setCreating(false)} refresh={instances.refresh}/>}{instances.data?.length === 0 && 还没有实例。选择本地镜像,创建你的第一个开发环境。}{!!instances.data?.length &&
{instances.data.map(i => )}
实例版本状态创建时间操作
{i.name}{i.id}{i.version}{date(i.created_at)}
{i.status === 'running' ? <>打开 DSH ↗ : }
}
{selected && i.id === selected.id) || selected} close={() => setSelected(undefined)} refresh={instances.refresh} />}; +} +function InstanceDetail({instance, close, refresh}: {instance: Instance; close: () => void; refresh: () => Promise}) { + const [tab, setTab] = useState('profile'); const [content, setContent] = useState(); const action = useAction(); + const base = instancePath(instance.id); + const stopped = ['stopped', 'exited', 'created'].includes(instance.status); + const images = useResource(api.images); + const available = images.data?.filter(i => !i.status || i.status === 'available') || []; + const [upgradeImage, setUpgradeImage] = useState(''); + const [upgradePassword, setUpgradePassword] = useState(''); + const profile = useResource(() => api.request<{content:string}>(base + '/profile')); + const preview = useResource(() => api.request<{bundles:unknown;config:string;preview_error?:string}>(base + '/profile-preview')); + const logs = useResource(() => api.request<{logs:string}>(base + '/logs')); + return 关闭}>
{[['profile','Profile 配置'],['preview','配置预览'],['backups','备份与恢复'],['upgrade','版本升级'],['logs','运行日志']].map(([id,label]) => )}
{action.feedback}{tab === 'profile' &&
Profile 写入前需先停止实例。当前状态:{!stopped && }
}{tab === 'profile' && <>

profiles/web/cordis.patch.yml · 停止实例后保存,保存后自动启动验证。密钥原文不在此展示。

{profile.data && <>
}}{tab === 'preview' && <>{preview.data && <>

Bundle 清单

{JSON.stringify(preview.data.bundles,null,2)}
{preview.data.preview_error &&

{preview.data.preview_error}

}}}{tab === 'backups' && {await profile.refresh(); setContent(undefined); await refresh();}}/>}{tab === 'upgrade' &&
{e.preventDefault(); if (!available.some(i => (i.image_id || i.id) === upgradeImage)) return; void action.run(async () => {try {await api.request(base + '/upgrade','POST',{image_id:upgradeImage,password:upgradePassword}); await refresh();} finally {setUpgradePassword('');}}, '实例升级请求已完成。', '确认使用所选本地镜像升级?建议先备份配置。');}}>

当前版本:{instance.version}

}{tab === 'logs' && <>
{logs.data?.logs || '暂无日志'}
}
; +} diff --git a/web/src/Orphans.tsx b/web/src/Orphans.tsx new file mode 100644 index 0000000..0096ea6 --- /dev/null +++ b/web/src/Orphans.tsx @@ -0,0 +1,11 @@ +import { api } from './api'; +import { Panel, ResourceState, useAction, useResource } from './ui'; +export function Orphans() { + const list=useResource(()=>api.request('/orphans')), action=useAction(); + return 核验并刷新}> +

仅列出已停止、归属和挂载核验通过且无实例或升级引用的容器。回收保留所有挂载数据,不删除网络。运行中及来源不明的容器不会列入。

+ {action.feedback} + {list.data?.length===0 &&

没有可安全回收的容器。

} + {list.data?.map(id=>
{id}
)} +
; +} diff --git a/web/src/PasswordDialog.tsx b/web/src/PasswordDialog.tsx new file mode 100644 index 0000000..1cfefb6 --- /dev/null +++ b/web/src/PasswordDialog.tsx @@ -0,0 +1,25 @@ +import { useId, useState, type FormEvent } from 'react'; +import { Dialog } from './Dialog'; +import { PasswordField } from './PasswordField'; +import { passwordResetError } from './passwordReset'; +import { useAction } from './ui'; + +export function PasswordDialog({title, currentRequired = false, onClose, onSubmit}: {title: string; currentRequired?: boolean; onClose: () => void; onSubmit: (password: string, current: string) => Promise}) { + const [current, setCurrent] = useState(''), [password, setPassword] = useState(''), [confirmation, setConfirmation] = useState(''); + const action = useAction(), hintId = useId(), error = passwordResetError(password, confirmation); + const currentError = currentRequired && new TextEncoder().encode(current).length > 72 ? '当前密码不能超过 72 UTF-8 字节。' : ''; + const valid = !error && !currentError && (!currentRequired || !!current); + function submit(event: FormEvent) { + event.preventDefault(); + if (!valid || action.busy) return; + void action.run(async () => {await onSubmit(password, current); onClose();}); + } + return
+

{currentRequired ? '修改后所有设备的登录会话将失效,你将退出登录并需要使用新密码重新登录。忘记当前密码请联系管理员。' : '重设后旧密码及该账号所有登录会话失效;若为当前账号,你也将退出登录。请通过安全渠道告知用户新密码。'}

+ {currentRequired && } + + +
新密码须为 12–72 UTF-8 字节,中文等字符占多个字节。当前 {new TextEncoder().encode(password).length} 字节。

{password || confirmation ? error || '密码校验通过' : '请输入并确认新密码'}

+ {currentError &&

{currentError}

}{action.feedback}
+
; +} diff --git a/web/src/PasswordField.tsx b/web/src/PasswordField.tsx new file mode 100644 index 0000000..a3a3530 --- /dev/null +++ b/web/src/PasswordField.tsx @@ -0,0 +1,6 @@ +import { useId, useState } from 'react'; + +export function PasswordField({label, name, value, onChange, disabled, autoComplete = 'new-password', describedBy}: {label: string; name: string; value: string; onChange: (value: string) => void; disabled: boolean; autoComplete?: string; describedBy?: string}) { + const [visible, setVisible] = useState(false), id = useId(); + return
onChange(event.target.value)} required disabled={disabled} autoComplete={autoComplete} aria-describedby={describedBy}/>
; +} diff --git a/web/src/ProfileEditor.tsx b/web/src/ProfileEditor.tsx new file mode 100644 index 0000000..1db4ed7 --- /dev/null +++ b/web/src/ProfileEditor.tsx @@ -0,0 +1,48 @@ +/// +import { useEffect, useRef } from 'react'; +import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; +import 'monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution'; +import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'; + +// Vite emits this worker locally; no AMD loader or CDN is involved. +self.MonacoEnvironment = { getWorker: () => new EditorWorker() }; + +export interface ProfileEditorProps { + value: string; + onChange?: (value: string) => void; + readOnly?: boolean; + label?: string; +} + +export function ProfileEditor({ value, onChange, readOnly = false, label = 'YAML 配置' }: ProfileEditorProps) { + const host = useRef(null); + const editor = useRef(null); + const change = useRef(onChange); + change.current = onChange; + const initialValue = useRef(value); + const updating = useRef(false); + useEffect(() => { + if (!host.current) return; + const model = monaco.editor.createModel(initialValue.current, 'yaml'); + const instance = monaco.editor.create(host.current, { + model, automaticLayout: true, minimap: { enabled: false }, + scrollBeyondLastLine: false, tabSize: 2, insertSpaces: true, + wordWrap: 'on', ariaLabel: label, readOnly, + }); + editor.current = instance; + const listener = instance.onDidChangeModelContent(() => { + if (!updating.current) change.current?.(instance.getValue()); + }); + return () => { listener.dispose(); instance.dispose(); model.dispose(); editor.current = null; }; + }, []); + useEffect(() => { + const instance = editor.current; + if (instance && instance.getValue() !== value) { + updating.current = true; + instance.setValue(value); + updating.current = false; + } + }, [value]); + useEffect(() => { editor.current?.updateOptions({ readOnly, ariaLabel: label }); }, [readOnly, label]); + return
; +} diff --git a/web/src/QuotaDialog.tsx b/web/src/QuotaDialog.tsx new file mode 100644 index 0000000..188efd1 --- /dev/null +++ b/web/src/QuotaDialog.tsx @@ -0,0 +1,22 @@ +import { useState, type FormEvent } from 'react'; +import { api, type User } from './api'; +import { Dialog } from './Dialog'; +import { bytes, useAction } from './ui'; +import { GiB, quotaBytes } from './quota'; + +export function QuotaDialog({user, onClose, onSaved}: {user: User; onClose: () => void; onSaved: () => void}) { + const [value, setValue] = useState(String(user.quota_bytes / GiB)); + const action = useAction(), next = quotaBytes(value), valid = next !== null && next !== user.quota_bytes; + function submit(event: FormEvent) { + event.preventDefault(); + if (!valid || action.busy) return; + void action.run(async () => {await api.request('/users', 'PATCH', {id:user.id, quota_bytes:next}); onSaved(); onClose();}); + } + return
+

当前工作区配额:{bytes(user.quota_bytes)}

+ +
{[5,10,20,50].map(n => )}
+

{next === null ? '请输入至少 0.1 GiB 且在安全整数范围内的有效配额。' : !valid ? '配额未改变,请输入新额度或选择预设。' : '将更新为 ' + bytes(next) + ',已有文件不会被删除。'}

+ {action.feedback}
+
; +} diff --git a/web/src/ResetPassword.tsx b/web/src/ResetPassword.tsx new file mode 100644 index 0000000..50913cd --- /dev/null +++ b/web/src/ResetPassword.tsx @@ -0,0 +1,13 @@ +import { useState } from 'react'; +import { api, type User } from './api'; +import { PasswordDialog } from './PasswordDialog'; + +export function ResetPassword({user, onReset}: {user: User; onReset: () => Promise}) { + const [open, setOpen] = useState(false), [notice, setNotice] = useState(''); + return <>{notice &&

{notice}

}{open && setOpen(false)} onSubmit={async password => { + const result = await api.request<{ok: boolean; reauthenticate: boolean}>('/users/reset-password', 'POST', {id:user.id, password}); + if (result.reauthenticate) {window.dispatchEvent(new CustomEvent('session-expired', {detail:'密码已重设,请使用新密码重新登录。'})); return;} + setNotice('密码已重设,该用户需要重新登录。'); + void onReset(); + }}/> }; +} diff --git a/web/src/RuntimeRenew.test.ts b/web/src/RuntimeRenew.test.ts new file mode 100644 index 0000000..23752cd --- /dev/null +++ b/web/src/RuntimeRenew.test.ts @@ -0,0 +1,10 @@ +import { expect, it } from 'vitest'; +import { runtimeReturnTarget } from './RuntimeRenew'; +const hash = (target: string) => '#runtime-renew?return=' + encodeURIComponent(target); +it('only permits local runtime return paths', () => { + expect(runtimeReturnTarget(hash('/dsh/demo/'))).toBe('/dsh/demo/'); + expect(runtimeReturnTarget(hash('/dsh/demo?x=1&y=2'))).toBe('/dsh/demo?x=1&y=2'); + for (const target of ['https://evil.test', '//evil.test/dsh/demo', '/api/logout', '/dsh/demo/../../api/logout', '/dsh/demo/%2e%2e/other', '/dsh/demo/\\evil', '/dsh/']) { + expect(runtimeReturnTarget(hash(target))).toBeNull(); + } +}); diff --git a/web/src/RuntimeRenew.tsx b/web/src/RuntimeRenew.tsx new file mode 100644 index 0000000..dd06fd0 --- /dev/null +++ b/web/src/RuntimeRenew.tsx @@ -0,0 +1,26 @@ +import { useEffect, useState } from 'react'; +import { api } from './api'; + +export function runtimeReturnTarget(hash: string): string | null { + const target = new URLSearchParams(hash.slice('#runtime-renew?'.length)).get('return'); + if (!target || !/^\/dsh\/[a-zA-Z0-9_-]+(?:\/|\?|$)/.test(target)) return null; + const path = target.split('?')[0]; + if (/[\\%\x00-\x20\x7f]/.test(path) || path.split('/').some(p => p === '.' || p === '..')) return null; + return target; +} + +export function RuntimeRenew() { + const [error, setError] = useState(''); + const [attempt, setAttempt] = useState(0); + const target = runtimeReturnTarget(window.location.hash); + useEffect(() => { + if (!target) return; + let active = true; + setError(''); + api.me().then(() => { if (active) window.location.replace(target); }).catch(() => { + if (active) setError('无法续期。请重试,或返回面板登录后重新打开实例。'); + }); + return () => { active = false; }; + }, [target, attempt]); + return
; +} diff --git a/web/src/RuntimeSelector.tsx b/web/src/RuntimeSelector.tsx new file mode 100644 index 0000000..fba2f09 --- /dev/null +++ b/web/src/RuntimeSelector.tsx @@ -0,0 +1,8 @@ +import type { useRuntimeInstances } from './RuntimeWorkspace'; + +export function RuntimeSelector({runtime, disabled = false}: {runtime: ReturnType; disabled?: boolean}) { + return ; +} diff --git a/web/src/RuntimeWorkspace.tsx b/web/src/RuntimeWorkspace.tsx new file mode 100644 index 0000000..7b70807 --- /dev/null +++ b/web/src/RuntimeWorkspace.tsx @@ -0,0 +1,94 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { api, type Instance } from './api'; +import { Alert, message, Status } from './ui'; +import { RuntimeSelector } from './RuntimeSelector'; +import { runtimeSelectionKey, selectRuntime } from './runtimeSelection'; + +export function useRuntimeInstances(userId: string, page: string) { + const [instances, setInstances] = useState(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [selectedId, setSelectedId] = useState(() => { + try { return localStorage.getItem(runtimeSelectionKey(userId)); } catch { return null; } + }); + const selection = useRef(selectedId); + const refreshRef = useRef<() => Promise>(async () => {}); + const select = useCallback((id: string | null) => { + selection.current = id; + setSelectedId(id); + try { + if (id) localStorage.setItem(runtimeSelectionKey(userId), id); + else localStorage.removeItem(runtimeSelectionKey(userId)); + } catch { /* Selection remains usable when browser storage is unavailable. */ } + }, [userId]); + const refresh = useCallback(() => refreshRef.current(), []); + useEffect(() => { + let active = true; + let pending: Promise | undefined; + let timer: ReturnType; + const load = (): Promise => { + if (!active) return Promise.resolve(); + if (pending) return pending; + clearTimeout(timer); + setLoading(true); + pending = api.instances().then(data => { + if (!active) return; + const list = data ?? []; + setInstances(list); + select(selectRuntime(list, selection.current)); + setError(''); + }).catch(e => { if (active) setError(message(e)); }).finally(() => { + pending = undefined; + if (active) { + setLoading(false); + timer = setTimeout(() => { void load(); }, 15000); + } + }); + return pending; + }; + refreshRef.current = load; + void load(); + return () => { active = false; clearTimeout(timer); }; + }, [select]); + useEffect(() => { + if (page !== 'dsh' && page !== 'settings' && page !== 'workspace') return; + void refresh(); + const focused = () => { if (document.visibilityState === 'visible') void refresh(); }; + window.addEventListener('focus', focused); + document.addEventListener('visibilitychange', focused); + return () => { + window.removeEventListener('focus', focused); + document.removeEventListener('visibilitychange', focused); + }; + }, [page, refresh]); + return { instances, loading, error, selectedId, select, refresh }; +} + +export function RuntimeWorkspace({ runtime, visible, manage, settings }: { + runtime: ReturnType; visible: boolean; manage: () => void; settings: () => void; +}) { + const [revision, setRevision] = useState(0); + const [frameError, setFrameError] = useState(false); + const selected = runtime.instances?.find(instance => instance.id === runtime.selectedId); + const url = selected ? '/dsh/' + encodeURIComponent(selected.id) + '/' : ''; + useEffect(() => { setFrameError(false); }, [selected?.id, revision]); + return