Initial commit: DSH Web Manager

This commit is contained in:
2026-10-13 09:34:47 +08:00
commit 85332bbc56
135 changed files with 19712 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
.mimocode
.git
**/node_modules
web/dist
data
bin
.env
output
**/output
**/*.tsbuildinfo
.playwright-cli
**/*.sqlite*
+15
View File
@@ -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=
+9
View File
@@ -0,0 +1,9 @@
.mimocode/
node_modules/
web/dist/
data/
bin/
.env
output/
.playwright-cli/
*.sqlite*
+188
View File
@@ -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固定内置 `[email protected]`,校验发布包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 验证不替代浏览器验收。
+88
View File
@@ -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=<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。
+411
View File
@@ -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/<profile>/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 <url/mount/>` + `--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:<v> 容器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/<uid>/files/...` | `/var/lib/dsh-mgr/workspaces/<uid>/files` |
| 实例 DSH_HOME | `/data/workspaces/<uid>/instances/<id>/home/...` | `/var/lib/dsh-mgr/workspaces/<uid>/instances/<id>/home` |
| 备份包 | `/data/backups/<uid>/<id>/...` | (不入容器,仅面板读写) |
面板配置:
```
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/<uid>/
files/ # 用户工作区(面板文件 API + 各 dsh 容器共享挂载)
└─ <用户自由目录结构>
instances/<instId>/
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/<uid>/<instId>/
<timestamp>.tar.gz # 配置备份包
# 镜像层由本机 Docker daemon 管理,不作为此目录下的文件保存;DB 记录镜像索引
```
**配额作用域**:`workspaces/<uid>/` 整棵树(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 <url> --trusted-host <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:<dshver>-<recipeRev>`,其中 `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/<uid>/instances/<instId>/home` → `/data/dsh-home`
- `workspaces/<uid>/files` → `/workspace`
- Network = `dsh-net`(不发布端口)
- Cmd = `web --no-open --port 3080 --public-url https://<panel>/dsh/<instId>/ --trusted-host <panel>`
- 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/<instId>/` → `http://<container-name>.dsh-net:8080`,容器名 = instId。
- **访问方式(待定,见 §18)**:默认推荐"路径前缀反代 + token 桥接"(§8),面板天然为 ingress。备选:子域名(需泛 DNS+TLS)/ 直发端口(MVP,隔离弱)。
---
## 8. 访问与认证(token 桥接 + cookie 改写)
> 此为最复杂点。推荐方案:路径前缀反代 + token 桥接。
- 面板路由 `/dsh/<instId>/*` → 反代到 `http://<instId>.dsh-net:8080`,**剥掉 `/dsh/<instId>` 前缀**,**保留浏览器 Host**,加 `X-Forwarded-*`。
- **token 桥接**:容器启动后,面板从 `docker logs` 流式抓 dsh 打印的 `http://127.0.0.1:3080/?token=xxx` 里的 token;用户首次打开 `/dsh/<instId>/` 时,面板服务端用该 token 打一次容器换 cookie,再把 `Set-Cookie` 的 `Path=/` 改写为 `Path=/dsh/<instId>`、加 `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/<uid> && zfs set refquota=10G`,再 bind 该 dataset 到 `/data/workspaces/<uid>`。
- 或在宿主跑**极小特权配额 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/<uid>/<instId>/` + 索引入库 → 重启容器。支持立即下载。
**还原流程**:校验+解密包 → 停容器 → 备份当前 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 <old>(不 remove,保留作回滚)
4. docker create <new>:同名 instId、同 binds(home + files)、同 labels、新 image、
Cmd 按新版本要求重算 --public-url/--trusted-host
5. entrypoint 首启:若 profiles/web/node_modules 缺失/与 package.json 不一致 → pnpm install 重建插件
6. docker start <new> → 面板抓 token、健康检查(轮询 caddy :8080)
7. 成功:docker rm <old>;失败:stop <new>、start <old>、标记回滚、提示用户
```
关键:**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/<id> 反代 + 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/<id>/` 可用(含 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
+25
View File
@@ -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"]
+64
View File
@@ -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 镜像。不要对生产账号运行。
+196
View File
@@ -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,同时内置 `[email protected]` 与 `[email protected]`(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默认内置 `[email protected]`(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:<version>-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快照回滚;旧共享目录不随回滚恢复;中断及回滚失败仍可能需要管理员恢复。
+137
View File
@@ -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
}
+88
View File
@@ -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)
}
}
+39
View File
@@ -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'
+20
View File
@@ -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
)
+48
View File
@@ -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=
+7
View File
@@ -0,0 +1,7 @@
{
admin off
auto_https off
}
:8080 {
reverse_proxy 127.0.0.1:3080
}
+30
View File
@@ -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 [email protected] && 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"]
+6
View File
@@ -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
+44
View File
@@ -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"
+35
View File
@@ -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 [email protected] and [email protected]');
}
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 });
}
}
+64
View File
@@ -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});}
});
+40
View File
@@ -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 [email protected] and pinned offline dependency archives (SHA-512 verified; no lifecycle scripts)');
+44
View File
@@ -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 [email protected] (SHA-512 verified; no lifecycle scripts)');
+12
View File
@@ -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']);
+22
View File
@@ -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.
+212
View File
@@ -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)
}
+303
View File
@@ -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)
}
})
}
}
+33
View File
@@ -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")
}
}
+269
View File
@@ -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
}
+28
View File
@@ -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)})
}
}
+166
View File
@@ -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
}
+271
View File
@@ -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://[email protected]"} {
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)
}
}
+93
View File
@@ -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)
}
}
}
+200
View File
@@ -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
}
+215
View File
@@ -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)
}
}
+656
View File
@@ -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
}
+205
View File
@@ -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()
})
}
}
+80
View File
@@ -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
}
+404
View File
@@ -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)
}
})
}
}
}
+97
View File
@@ -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{"[email protected]", `test "$(pnpm --version)" = "10.11.0"`, "USER node", `"/usr/bin/tini", "-g"`} {
if !strings.Contains(string(data), required) {
t.Errorf("missing %s", required)
}
}
}
+820
View File
@@ -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
}
+542
View File
@@ -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)
}
}
+512
View File
@@ -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
}
+140
View File
@@ -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"})
}
+173
View File
@@ -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)
}
})
}
}
+252
View File
@@ -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
}
+381
View File
@@ -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)
}
}
+498
View File
@@ -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:[email protected]/")
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}}})
}
+212
View File
@@ -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", "")
}
}
}
+158
View File
@@ -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)
}
}
+143
View File
@@ -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")
}
}
+65
View File
@@ -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())
}
})
}
}
+94
View File
@@ -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})
}
+365
View File
@@ -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)
}
}
+18
View File
@@ -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
}
+15
View File
@@ -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)
}
}
}
+226
View File
@@ -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
}
}
+188
View File
@@ -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:[email protected]/?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")
}
}
+63
View File
@@ -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
}
}
+31
View File
@@ -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)
}
}
+63
View File
@@ -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
}
+94
View File
@@ -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)
}
}
+128
View File
@@ -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
}
+141
View File
@@ -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)
}
})
}
}
+39
View File
@@ -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 }
+116
View File
@@ -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)
}
}
+965
View File
@@ -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("<head>"), []byte(`<head><script src="/api/runtime-session.js"></script>`), 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") },
}
}
+115
View File
@@ -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("<html><head></head><body>runtime</body></html>"))}
if err := proxy.ModifyResponse(response); err != nil {
t.Fatal(err)
}
data, _ := io.ReadAll(response.Body)
if !strings.Contains(string(data), `<head><script src="/api/runtime-session.js"></script>`) || 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())
}
}
+141
View File
@@ -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)
}
}
+195
View File
@@ -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)
}
}
+52
View File
@@ -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(); });
})();`
+581
View File
@@ -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())
}
}
}
+629
View File
@@ -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:
}
}
}
+528
View File
@@ -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)
}
})
}
}
+219
View File
@@ -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")
}
}
+70
View File
@@ -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)
}
}
+515
View File
@@ -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)
}
+521
View File
@@ -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")
}
}
+102
View File
@@ -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
}
+77
View File
@@ -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")
}
}
+244
View File
@@ -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
}
+242
View File
@@ -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)
}
}
+133
View File
@@ -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()
}
+94
View File
@@ -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)
}
}
+131
View File
@@ -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) }
+15
View File
@@ -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"
+29
View File
@@ -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'
+91
View File
@@ -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/);
});
+106
View File
@@ -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(/<script[^>]*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;
}
+25
View File
@@ -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" != '<no value>' ]] || { 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
+33
View File
@@ -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');
+81
View File
@@ -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)');}
+23
View File
@@ -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');
+87
View File
@@ -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('<html')){ready=true;break}}catch{}await new Promise(r=>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.');}
}
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
*.tsbuildinfo
.playwright-cli/
output/
+11
View File
@@ -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 启动后端后通知可用临时验证账号,避免操作共享数据。
+26
View File
@@ -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 错误。请不要把前端入口存在等同于后端能力已交付。
+2
View File
@@ -0,0 +1,2 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/><meta name="theme-color" content="#152b36"/><link rel="icon" type="image/svg+xml" href="/favicon.svg"/><title>DSH · 工作台</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
+2340
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -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"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#172e39"/><path d="M17 16h13c24 0 24 32 0 32H17zm9 8v16h5c12 0 12-16 0-16z" fill="#b8e6dc"/><circle cx="49" cy="16" r="5" fill="#54c7ae"/></svg>

After

Width:  |  Height:  |  Size: 248 B

Some files were not shown because too many files have changed in this diff Show More