diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c419835..87e8bc4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,4 +36,17 @@ jobs: run: | pnpm run build TARBALL=$(pnpm pack --pack-destination "$RUNNER_TEMP" | tail -n 1) - npm publish "$TARBALL" --access public + VERSION=$(node -p "require('./package.json').version") + + # A prerelease must name its dist-tag explicitly; without one npm 11 + # refuses to publish. Using `beta` also keeps `latest` on the stable + # line so a plain install does not pick up a beta. + if [[ "$VERSION" == *-* ]]; then + DIST_TAG="${VERSION#*-}" + DIST_TAG="${DIST_TAG%%.*}" + echo "publishing $VERSION under dist-tag $DIST_TAG" + npm publish "$TARBALL" --access public --tag "$DIST_TAG" + else + echo "publishing $VERSION as latest" + npm publish "$TARBALL" --access public + fi diff --git a/README.md b/README.md index cd18c97..08e022a 100644 --- a/README.md +++ b/README.md @@ -314,15 +314,39 @@ Please check [caddy path](https://github.com/zcf0508/unplugin-https-reverse-prox ## Usage ```bash -# macOS -sudo -E npm run dev # your dev script -# ^ `-E` is important - -# windows -# use powershell as administrator +# macOS / Linux / Windows npm run dev # your dev script ``` +Run it as your normal user. The plugin starts a small privileged helper that +runs as administrator, and only that helper touches `/etc/hosts` and ports +80/443. Your dev server, its caches and `node_modules` stay owned by you. + +On macOS and Linux the plugin asks for your password once when the dev server +starts, then reuses the sudo credential for the rest of the session. If you +already ran `sudo -v` in the same terminal, no prompt is shown. + +The helper restores the hosts entries by itself when the dev server exits, +including when it is killed or crashes. + +### Granting the permission once (optional) + +To skip the password prompt entirely, allow the plugin's helper in +`/etc/sudoers.d/` (edit with `sudo visudo -f /etc/sudoers.d/uhrp`): + +``` +yourname ALL=(root) NOPASSWD: /usr/bin/killall -HUP mDNSResponder +yourname ALL=(root) NOPASSWD: /usr/bin/install -m 644 -o root -g wheel /tmp/uhrp-hosts.* /etc/hosts +``` + +The second rule only matters when the helper rewrites `/etc/hosts` through +`install`; adjust the paths to your platform. + +### Windows + +Run `npm run dev` normally. Windows raises a UAC prompt once, when the helper +is started through `Start-Process -Verb RunAs`. + If your broswer shows a not trusted certificate warning, please check the keychain. ![keychain](https://github.com/zcf0508/unplugin-https-reverse-proxy/blob/main/images/Snipaste_2023-11-16_16-59-55.png) diff --git a/package.json b/package.json index 8670d4d..8272926 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "unplugin-https-reverse-proxy", "type": "module", - "version": "4.0.4", + "version": "4.1.0-beta.10", "description": "", "license": "MIT", "homepage": "https://github.com/zcf0508/unplugin-https-reverse-proxy#readme", @@ -135,7 +135,6 @@ "console-table-printer": "^2.12.1", "dotenv-flow": "^4.1.0", "got-cjs": "^12.5.4", - "hostile": "^1.4.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "ora": "5", @@ -149,8 +148,6 @@ "@nuxt/schema": "^3.16.1", "@rsbuild/core": "^0.4.15", "@rspack/core": "^0.5.9", - "@types/hostile": "^1.3.5", - "@types/kill-port": "^2.0.3", "@types/node": "^20.11.19", "@types/proper-lockfile": "^4.1.4", "@types/qrcode-terminal": "^0.12.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff4d42e..e1e9b7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,9 +23,6 @@ importers: got-cjs: specifier: ^12.5.4 version: 12.5.4 - hostile: - specifier: ^1.4.0 - version: 1.4.0 http-proxy-agent: specifier: ^7.0.2 version: 7.0.2 @@ -60,12 +57,6 @@ importers: '@rspack/core': specifier: ^0.5.9 version: 0.5.9(@swc/helpers@0.5.3) - '@types/hostile': - specifier: ^1.3.5 - version: 1.3.5 - '@types/kill-port': - specifier: ^2.0.3 - version: 2.0.3 '@types/node': specifier: ^20.11.19 version: 20.11.19 @@ -137,7 +128,7 @@ importers: devDependencies: nuxt: specifier: ^3.10.2 - version: 3.10.2(@parcel/watcher@2.3.0)(@types/node@20.11.19)(encoding@0.1.13)(eslint@9.22.0(jiti@2.4.2))(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)) + version: 3.10.2(@parcel/watcher@2.3.0)(@types/node@20.11.19)(encoding@0.1.13)(eslint@9.22.0(jiti@2.4.2))(magicast@0.3.2)(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)) vue: specifier: ^3.4.19 version: 3.4.19(typescript@5.8.2) @@ -1889,10 +1880,6 @@ packages: '@leichtgewicht/ip-codec@2.0.4': resolution: {integrity: sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==} - '@ljharb/through@2.3.13': - resolution: {integrity: sha512-/gKJun8NNiWGZJkGzI/Ragc53cOdcLNdzjLaIa+GEjguQs0ulsurx8WN0jijdK9yPqDvziX995sMRLyLt1uZMQ==} - engines: {node: '>= 0.4'} - '@mapbox/node-pre-gyp@1.0.11': resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} hasBin: true @@ -2055,30 +2042,35 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm64-glibc@2.3.0': resolution: {integrity: sha512-mQ0gBSQEiq1k/MMkgcSB0Ic47UORZBmWoAWlMrTW6nbAGoLZP+h7AtUM7H3oDu34TBFFvjy4JCGP43JlylkTQA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.3.0': resolution: {integrity: sha512-LXZAExpepJew0Gp8ZkJ+xDZaTQjLHv48h0p0Vw2VMFQ8A+RKrAvpFuPVCVwKJCr5SE+zvaG+Etg56qXvTDIedw==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [musl] '@parcel/watcher-linux-x64-glibc@2.3.0': resolution: {integrity: sha512-P7Wo91lKSeSgMTtG7CnBS6WrA5otr1K7shhSjKHNePVmfBHDoAOHYRXgUmhiNfbcGk0uMCHVcdbfxtuiZCHVow==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-x64-musl@2.3.0': resolution: {integrity: sha512-+kiRE1JIq8QdxzwoYY+wzBs9YbJ34guBweTK8nlzLKimn5EQ2b2FSC+tAOpq302BuIMjyuUGvBiUhEcLIGMQ5g==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [musl] '@parcel/watcher-wasm@2.3.0': resolution: {integrity: sha512-ejBAX8H0ZGsD8lSICDNyMbSEtPMWgDL0WFCt/0z7hyf5v8Imz4rAM8xY379mBsECkq/Wdqa5WEDLqtjZ+6NxfA==} @@ -2266,26 +2258,31 @@ packages: resolution: {integrity: sha512-TVYVWD/SYwWzGGnbfTkrNpdE4HON46orgMNHCivlXmlsSGQOx/OHHYiQcMIOx38/GWgwr/po2LBn7wypkWw/Mg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.9.4': resolution: {integrity: sha512-XcKvuendwizYYhFxpvQ3xVpzje2HHImzg33wL9zvxtj77HvPStbSGI9czrdbfrf8DGMcNNReH9pVZv8qejAQ5A==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.9.4': resolution: {integrity: sha512-LFHS/8Q+I9YA0yVETyjonMJ3UA+DczeBd/MqNEzsGSTdNvSJa1OJZcSH8GiXLvcizgp9AlHs2walqRcqzjOi3A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.9.4': resolution: {integrity: sha512-dIYgo+j1+yfy81i0YVU5KnQrIJZE8ERomx17ReU4GREjGtDW4X+nvkBak2xAUpyqLs4eleDSj3RrV72fQos7zw==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.9.4': resolution: {integrity: sha512-RoaYxjdHQ5TPjaPrLsfKqR3pakMr3JGqZ+jZM0zP2IkDtsGa4CqYaWSfQmZVgFUCgLrTnzX+cnHS3nfl+kB6ZQ==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.9.4': resolution: {integrity: sha512-T8Q3XHV+Jjf5e49B4EAaLKV74BbX7/qYBRQ8Wop/+TyyU0k+vSjiLVSHNWdVd1goMjZcbhDmYZUYW5RFqkBNHQ==} @@ -2357,61 +2354,73 @@ packages: resolution: {integrity: sha512-mXtRKCblBT+H1KPWUfeJt6gQFGoMt+lnhk2POcoCeS1AxnxcTFpnci4BC4Ro5zKS2QWSdGdUMtc5GKlBmgwxvg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rspack/binding-linux-arm64-gnu@0.5.7': resolution: {integrity: sha512-JB9FAYWjYAeNCPFh0mQu3SZdFHiA+EY37z1AktLDl789SoEec2HPGkvvOs+OIET1pKWgjUGD4Z4Uq4P/r5JFNA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rspack/binding-linux-arm64-gnu@0.5.9': resolution: {integrity: sha512-sc0fp4CEt3sgafrDUV5hTLDErlarmsazPGkxdJPb5TGVYjiKaYxM6AtH1ZTEDP1DmDtCvBUutfbdO6pzXFDGSA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rspack/binding-linux-arm64-musl@0.5.4': resolution: {integrity: sha512-P96R8yLT4BKtwYCtomIJE4uIGAh+5I8qLbrTrGamj/6N1D79GgwORW6CllCEnVU9l/Tjkdd+yMJkT9zoACa9gQ==} cpu: [arm64] os: [linux] + libc: [musl] '@rspack/binding-linux-arm64-musl@0.5.7': resolution: {integrity: sha512-3fNhPvA9Kj/L7rwr2Pj1bvxWBLBgqfkqSvt91iUxPbxgfTiSBQh0Tfb9+hkHv2VCTyNQI/vytkOH+4i4DNXCBw==} cpu: [arm64] os: [linux] + libc: [musl] '@rspack/binding-linux-arm64-musl@0.5.9': resolution: {integrity: sha512-clhkNsNi76pTYtZz1U95R36AdZZH2eXbBWb0g17okCt4aXfJoHWIViDvHWvVmU9318repxwww3rR0ImbLskZiw==} cpu: [arm64] os: [linux] + libc: [musl] '@rspack/binding-linux-x64-gnu@0.5.4': resolution: {integrity: sha512-/EjM7CkALS7uUF0laVp+wtOICrX2sR5gy4liIYVHKDLu+b4PGRtEQvubrDxikkzPpOYRvF38R7OBMUOJBuBW7A==} cpu: [x64] os: [linux] + libc: [glibc] '@rspack/binding-linux-x64-gnu@0.5.7': resolution: {integrity: sha512-y/GnXt1hhbKSqzBSy+ALWwievlejQhIIF8FPXL1kKFh60zl7DE+iYHSJ128jIJiph9dQkBnHw0ABJ5D+vbSqdA==} cpu: [x64] os: [linux] + libc: [glibc] '@rspack/binding-linux-x64-gnu@0.5.9': resolution: {integrity: sha512-bC69vwrG9R/kdpEuDXP/IleedCWR97d0GRLJFAapc2rPoFRI3vnuQVzp8+nX7CA1k2n6tfOJlOvaWLIik3hzXw==} cpu: [x64] os: [linux] + libc: [glibc] '@rspack/binding-linux-x64-musl@0.5.4': resolution: {integrity: sha512-dMT9QW4IZ7IGzczsOmzdpGf84IzIecvitSwj7DnulRkxj3++IWLAo80+HDtgn+nPm+1gNVFb11wg5L9x+VjFXw==} cpu: [x64] os: [linux] + libc: [musl] '@rspack/binding-linux-x64-musl@0.5.7': resolution: {integrity: sha512-US/FUv6cvbxbe4nymINwer/EQTvGEgCaAIrvKuAP0yAfK0eyqIHYZj/zCBM2qOS69Mpc2FWVMC/ftRyCvAz/xw==} cpu: [x64] os: [linux] + libc: [musl] '@rspack/binding-linux-x64-musl@0.5.9': resolution: {integrity: sha512-QGDcWKlLCunBW1/zIvPq6HkIt7920WVhY+ONFGd12owPUEveZTVxWYr64ROJqtIFueWFZYY4FFX6uwCpmH2hrQ==} cpu: [x64] os: [linux] + libc: [musl] '@rspack/binding-win32-arm64-msvc@0.5.4': resolution: {integrity: sha512-SsnOqWRw5VQnbz/63wtKsoyj6lfUpQQZyFWfQAMsNt8suIauWI/kf3QLWL/vmBX5Q24Sq16Kl5cMIjxAIJQfiQ==} @@ -2619,9 +2628,6 @@ packages: '@types/glob@7.2.0': resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==} - '@types/hostile@1.3.5': - resolution: {integrity: sha512-NvElcxcjEEBZdJ2Xfl8GEFx+c79PTCk5q1mlkCBv8ED+I6n3EfCQzQ4x1IbiOM2nFHMqBOe/MTZwLMtn30WYaQ==} - '@types/html-minifier-terser@6.1.0': resolution: {integrity: sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==} @@ -2643,9 +2649,6 @@ packages: '@types/keyv@3.1.4': resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} - '@types/kill-port@2.0.3': - resolution: {integrity: sha512-ZHs59e5FBjDLQLOxM48+814LSyNf5sgpi0odtJ0FH6xrIAZXb4yksYG+4mZCbidX3fBOfHytAKAVMgkWvv/Piw==} - '@types/long@4.0.2': resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} @@ -2833,21 +2836,25 @@ packages: resolution: {integrity: sha512-fp4Azi8kHz6TX8SFmKfyScZrMLfp++uRm2srpqRjsRZIIBzH74NtSkdEUHImR4G7f7XJ+sVZjCc6KDDK04YEpQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/rspack-resolver-binding-linux-arm64-musl@1.2.2': resolution: {integrity: sha512-gMiG3DCFioJxdGBzhlL86KcFgt9HGz0iDhw0YVYPsShItpN5pqIkNrI+L/Q/0gfDiGrfcE0X3VANSYIPmqEAlQ==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/rspack-resolver-binding-linux-x64-gnu@1.2.2': resolution: {integrity: sha512-n/4n2CxaUF9tcaJxEaZm+lqvaw2gflfWQ1R9I7WQgYkKEKbRKbpG/R3hopYdUmLSRI4xaW1Cy0Bz40eS2Yi4Sw==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/rspack-resolver-binding-linux-x64-musl@1.2.2': resolution: {integrity: sha512-cHyhAr6rlYYbon1L2Ag449YCj3p6XMfcYTP0AQX+KkQo025d1y/VFtPWvjMhuEsE2lLvtHm7GdJozj6BOMtzVg==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/rspack-resolver-binding-wasm32-wasi@1.2.2': resolution: {integrity: sha512-eogDKuICghDLGc32FtP+WniG38IB1RcGOGz0G3z8406dUdjJvxfHGuGs/dSlM9YEp/v0lEqhJ4mBu6X2nL9pog==} @@ -3946,10 +3953,6 @@ packages: call-bind@1.0.5: resolution: {integrity: sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==} - call-bind@1.0.7: - resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} - engines: {node: '>= 0.4'} - call-me-maybe@1.0.2: resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==} @@ -4905,10 +4908,6 @@ packages: resolution: {integrity: sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==} engines: {node: '>= 0.4'} - define-data-property@1.1.4: - resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} - engines: {node: '>= 0.4'} - define-lazy-prop@2.0.0: resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} engines: {node: '>=8'} @@ -5238,14 +5237,6 @@ packages: es-array-method-boxes-properly@1.0.0: resolution: {integrity: sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==} - es-define-property@1.0.0: - resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - es-module-lexer@1.4.1: resolution: {integrity: sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==} @@ -5915,10 +5906,6 @@ packages: get-intrinsic@1.2.2: resolution: {integrity: sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==} - get-intrinsic@1.2.4: - resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} - engines: {node: '>= 0.4'} - get-port-please@3.1.2: resolution: {integrity: sha512-Gxc29eLs1fbn6LQ4jSU4vXjlwyZhF5HsGuMAa7gqBP4Rw4yxxltyDUuF5MBclFzDTXO+ACchGQoeela4DSfzdQ==} @@ -6160,9 +6147,6 @@ packages: has-property-descriptors@1.0.1: resolution: {integrity: sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==} - has-property-descriptors@1.0.2: - resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} - has-proto@1.0.1: resolution: {integrity: sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==} engines: {node: '>= 0.4'} @@ -6248,10 +6232,6 @@ packages: resolution: {integrity: sha512-+K84LB1DYwMHoHSgaOY/Jfhw3ucPmSET5v98Ke/HdNSw4a0UktWzyW1mjhjpuxxTqOOsfWT/7iVshHmVZ4IpOA==} engines: {node: ^16.14.0 || >=18.0.0} - hostile@1.4.0: - resolution: {integrity: sha512-q5eniv6NnjeQ2S1Wh3/knyl4UE2FAQ9xz7yT0f6y5FK2j3fFHBI2jyaUVwAiAU20G6LQMAkRGM1WbTMXoeUwMg==} - hasBin: true - hpack.js@2.1.6: resolution: {integrity: sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==} @@ -7197,7 +7177,7 @@ packages: resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + resolution: {integrity: sha1-Z5WRxWTDv/quhFTPCz3zcMPWkRw=} log-symbols@2.2.0: resolution: {integrity: sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==} @@ -9510,10 +9490,6 @@ packages: resolution: {integrity: sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==} engines: {node: '>= 0.4'} - set-function-length@1.2.2: - resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} - engines: {node: '>= 0.4'} - set-function-name@2.0.1: resolution: {integrity: sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==} engines: {node: '>= 0.4'} @@ -9555,10 +9531,6 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-exec@1.1.2: - resolution: {integrity: sha512-v2NWVDP0ws+S7miKy2oSpJ/OuL6NKuMosPNUZLDWFBlMnBtuoZxZOwxpQJwhsFZgMb+r7frpDTT8p4OSnhkpsg==} - engines: {node: '>=12'} - shell-quote@1.8.1: resolution: {integrity: sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==} @@ -9744,9 +9716,6 @@ packages: resolution: {integrity: sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==} engines: {node: '>=0.10.0'} - split@1.0.1: - resolution: {integrity: sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==} - sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -10906,7 +10875,7 @@ packages: optional: true vue-loader@16.8.3: - resolution: {integrity: sha512-7vKN45IxsKxe5GcVCbc2qFU5aWzyiLrYJyUuMz4BQLKctCj/fmCa0w6fGiiQ2cLFetNcek1ppGJQDCup0c1hpA==} + resolution: {integrity: sha1-1D5nXe9bqTRdbH8FkUwT2GGZcIc=} peerDependencies: '@vue/compiler-sfc': ^3.0.8 vue: ^3.2.13 @@ -10965,7 +10934,7 @@ packages: optional: true watchpack-chokidar2@2.0.1: - resolution: {integrity: sha512-nCFfBIPKr5Sh61s4LPpy1Wtfi0HE8isJ3d2Yb5/Ppw2P2B/3eVSEBjKfN0fmHJSK14+31KwMKmcrzs2GM4P0Ww==} + resolution: {integrity: sha1-OFAAcu5uzmbzdpk2lQ6hdxvhyVc=} watchpack@1.7.5: resolution: {integrity: sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ==} @@ -12967,10 +12936,6 @@ snapshots: '@leichtgewicht/ip-codec@2.0.4': {} - '@ljharb/through@2.3.13': - dependencies: - call-bind: 1.0.7 - '@mapbox/node-pre-gyp@1.0.11(encoding@0.1.13)': dependencies: detect-libc: 2.0.2 @@ -13094,12 +13059,12 @@ snapshots: '@nuxt/devalue@2.0.2': {} - '@nuxt/devtools-kit@1.0.8(magicast@0.3.2)(nuxt@3.10.2(@parcel/watcher@2.3.0)(@types/node@20.11.19)(encoding@0.1.13)(eslint@9.22.0(jiti@2.4.2))(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)))(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2))': + '@nuxt/devtools-kit@1.0.8(magicast@0.3.2)(nuxt@3.10.2(@parcel/watcher@2.3.0)(@types/node@20.11.19)(encoding@0.1.13)(eslint@9.22.0(jiti@2.4.2))(magicast@0.3.2)(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)))(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2))': dependencies: '@nuxt/kit': 3.16.1(magicast@0.3.2) '@nuxt/schema': 3.16.1 execa: 7.2.0 - nuxt: 3.10.2(@parcel/watcher@2.3.0)(@types/node@20.11.19)(encoding@0.1.13)(eslint@9.22.0(jiti@2.4.2))(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)) + nuxt: 3.10.2(@parcel/watcher@2.3.0)(@types/node@20.11.19)(encoding@0.1.13)(eslint@9.22.0(jiti@2.4.2))(magicast@0.3.2)(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)) vite: 5.1.1(@types/node@20.11.19)(terser@5.27.2) transitivePeerDependencies: - magicast @@ -13117,10 +13082,10 @@ snapshots: rc9: 2.1.1 semver: 7.6.0 - '@nuxt/devtools@1.0.8(nuxt@3.10.2(@parcel/watcher@2.3.0)(@types/node@20.11.19)(encoding@0.1.13)(eslint@9.22.0(jiti@2.4.2))(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)))(rollup@4.9.4)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2))': + '@nuxt/devtools@1.0.8(nuxt@3.10.2(@parcel/watcher@2.3.0)(@types/node@20.11.19)(encoding@0.1.13)(eslint@9.22.0(jiti@2.4.2))(magicast@0.3.2)(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)))(rollup@4.9.4)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2))': dependencies: '@antfu/utils': 0.7.7 - '@nuxt/devtools-kit': 1.0.8(magicast@0.3.2)(nuxt@3.10.2(@parcel/watcher@2.3.0)(@types/node@20.11.19)(encoding@0.1.13)(eslint@9.22.0(jiti@2.4.2))(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)))(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)) + '@nuxt/devtools-kit': 1.0.8(magicast@0.3.2)(nuxt@3.10.2(@parcel/watcher@2.3.0)(@types/node@20.11.19)(encoding@0.1.13)(eslint@9.22.0(jiti@2.4.2))(magicast@0.3.2)(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)))(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)) '@nuxt/devtools-wizard': 1.0.8 '@nuxt/kit': 3.16.1(magicast@0.3.2) birpc: 0.2.14 @@ -13137,7 +13102,7 @@ snapshots: launch-editor: 2.6.1 local-pkg: 0.5.0 magicast: 0.3.2 - nuxt: 3.10.2(@parcel/watcher@2.3.0)(@types/node@20.11.19)(encoding@0.1.13)(eslint@9.22.0(jiti@2.4.2))(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)) + nuxt: 3.10.2(@parcel/watcher@2.3.0)(@types/node@20.11.19)(encoding@0.1.13)(eslint@9.22.0(jiti@2.4.2))(magicast@0.3.2)(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)) nypm: 0.3.6 ohash: 1.1.3 pacote: 17.0.5 @@ -13237,7 +13202,7 @@ snapshots: pathe: 2.0.3 std-env: 3.8.1 - '@nuxt/telemetry@2.5.3': + '@nuxt/telemetry@2.5.3(magicast@0.3.2)': dependencies: '@nuxt/kit': 3.16.1(magicast@0.3.2) ci-info: 4.0.0 @@ -13902,8 +13867,6 @@ snapshots: '@types/minimatch': 5.1.2 '@types/node': 20.11.19 - '@types/hostile@1.3.5': {} - '@types/html-minifier-terser@6.1.0': {} '@types/http-errors@2.0.4': {} @@ -13928,11 +13891,6 @@ snapshots: dependencies: '@types/node': 20.11.19 - '@types/kill-port@2.0.3': - dependencies: - '@types/node': 20.11.19 - shell-exec: 1.1.2 - '@types/long@4.0.2': {} '@types/mdast@4.0.4': @@ -15968,14 +15926,6 @@ snapshots: get-intrinsic: 1.2.2 set-function-length: 1.1.1 - call-bind@1.0.7: - dependencies: - es-define-property: 1.0.0 - es-errors: 1.3.0 - function-bind: 1.1.2 - get-intrinsic: 1.2.4 - set-function-length: 1.2.2 - call-me-maybe@1.0.2: {} caller-callsite@2.0.0: @@ -16984,12 +16934,6 @@ snapshots: gopd: 1.0.1 has-property-descriptors: 1.0.1 - define-data-property@1.1.4: - dependencies: - es-define-property: 1.0.0 - es-errors: 1.3.0 - gopd: 1.0.1 - define-lazy-prop@2.0.0: {} define-lazy-prop@3.0.0: {} @@ -17353,12 +17297,6 @@ snapshots: es-array-method-boxes-properly@1.0.0: {} - es-define-property@1.0.0: - dependencies: - get-intrinsic: 1.2.4 - - es-errors@1.3.0: {} - es-module-lexer@1.4.1: {} es-module-lexer@1.6.0: {} @@ -18303,14 +18241,6 @@ snapshots: has-symbols: 1.0.3 hasown: 2.0.0 - get-intrinsic@1.2.4: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - has-proto: 1.0.1 - has-symbols: 1.0.3 - hasown: 2.0.0 - get-port-please@3.1.2: {} get-proxy@2.1.0: @@ -18631,10 +18561,6 @@ snapshots: dependencies: get-intrinsic: 1.2.2 - has-property-descriptors@1.0.2: - dependencies: - es-define-property: 1.0.0 - has-proto@1.0.1: {} has-symbol-support-x@1.4.2: {} @@ -18713,14 +18639,6 @@ snapshots: dependencies: lru-cache: 10.0.2 - hostile@1.4.0: - dependencies: - '@ljharb/through': 2.3.13 - chalk: 4.1.2 - minimist: 1.2.8 - once: 1.4.0 - split: 1.0.1 - hpack.js@2.1.6: dependencies: inherits: 2.0.4 @@ -20682,13 +20600,13 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - nuxt@3.10.2(@parcel/watcher@2.3.0)(@types/node@20.11.19)(encoding@0.1.13)(eslint@9.22.0(jiti@2.4.2))(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)): + nuxt@3.10.2(@parcel/watcher@2.3.0)(@types/node@20.11.19)(encoding@0.1.13)(eslint@9.22.0(jiti@2.4.2))(magicast@0.3.2)(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)): dependencies: '@nuxt/devalue': 2.0.2 - '@nuxt/devtools': 1.0.8(nuxt@3.10.2(@parcel/watcher@2.3.0)(@types/node@20.11.19)(encoding@0.1.13)(eslint@9.22.0(jiti@2.4.2))(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)))(rollup@4.9.4)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)) + '@nuxt/devtools': 1.0.8(nuxt@3.10.2(@parcel/watcher@2.3.0)(@types/node@20.11.19)(encoding@0.1.13)(eslint@9.22.0(jiti@2.4.2))(magicast@0.3.2)(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)))(rollup@4.9.4)(vite@5.1.1(@types/node@20.11.19)(terser@5.27.2)) '@nuxt/kit': 3.10.2(rollup@4.9.4) '@nuxt/schema': 3.10.2(rollup@4.9.4) - '@nuxt/telemetry': 2.5.3 + '@nuxt/telemetry': 2.5.3(magicast@0.3.2) '@nuxt/ui-templates': 1.3.1 '@nuxt/vite-builder': 3.10.2(@types/node@20.11.19)(eslint@9.22.0(jiti@2.4.2))(optionator@0.9.3)(rollup@4.9.4)(terser@5.27.2)(typescript@5.8.2)(vue@3.4.19(typescript@5.8.2)) '@unhead/dom': 1.8.10 @@ -22664,15 +22582,6 @@ snapshots: gopd: 1.0.1 has-property-descriptors: 1.0.1 - set-function-length@1.2.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - function-bind: 1.1.2 - get-intrinsic: 1.2.4 - gopd: 1.0.1 - has-property-descriptors: 1.0.2 - set-function-name@2.0.1: dependencies: define-data-property: 1.1.1 @@ -22713,8 +22622,6 @@ snapshots: shebang-regex@3.0.0: {} - shell-exec@1.1.2: {} - shell-quote@1.8.1: {} shellwords@0.1.1: {} @@ -22931,10 +22838,6 @@ snapshots: dependencies: extend-shallow: 3.0.2 - split@1.0.1: - dependencies: - through: 2.3.8 - sprintf-js@1.0.3: {} sshpk@1.18.0: diff --git a/src/caddy/caddyfile.ts b/src/caddy/caddyfile.ts index 7693edf..2ef4a01 100644 --- a/src/caddy/caddyfile.ts +++ b/src/caddy/caddyfile.ts @@ -12,6 +12,7 @@ export const ProxyType = type({ const CaddyContext = type({ caddy_root: 'string', + admin_socket: 'string', proxies: ProxyType.array(), }) @@ -26,6 +27,8 @@ export function validateTemplateContext(ctx: unknown): typeof CaddyContext.infer export const caddyTemplate = `{ debug auto_https disable_redirects ignore_loaded_certs + skip_install_trust + admin unix/{{ admin_socket }} } :7601 { diff --git a/src/caddy/config.ts b/src/caddy/config.ts index b57ce86..68a99ef 100644 --- a/src/caddy/config.ts +++ b/src/caddy/config.ts @@ -1,72 +1,214 @@ import type { ProxyType } from './caddyfile' -import { existsSync, readFileSync, watch, writeFileSync } from 'node:fs' -import { unlink } from 'node:fs/promises' -import { computed, signal } from 'alien-signals' +import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' +import process from 'node:process' +import * as lockfile from 'proper-lockfile' import { caddyConfigPath } from './constants' -export function useCaddyConfig(): { - configJsonRef: () => (Record | undefined) - writeCaddyConfig: (proxies: Array) => void +/** One project's route, plus the pid that owns it. */ +type ProxyEntry = typeof ProxyType.infer & { ownerPid?: number } + +type ProxyMap = Record + +/** + * A file lock held around every read-modify-write of the shared config. + * + * Several dev servers run as independent processes, so a read-then-write from + * two of them can interleave and drop an entry. Synchronous calls are only + * atomic within one event loop, which is why the lock has to be a real file + * lock rather than an in-process queue. + */ +interface ConfigLock { + acquire: () => Promise<() => void> +} + +export interface CaddyConfigApi { + configJsonRef: () => ProxyMap | undefined + /** Adds or replaces this project's routes, keeping everyone else's. */ + writeCaddyConfig: (proxies: Array, key: string) => Promise + /** Drops one project's routes; the file goes away when nothing is left. */ + removeProxy: (key: string) => Promise + /** Reads the routes currently on disk, for regenerating the Caddyfile. */ + readProxies: () => ProxyMap cleanUp: () => Promise -} { - const configContentRef = signal('{}') +} - const configJsonRef = computed(() => { - try { - return JSON.parse(configContentRef()) as Record - } - catch { - return undefined - } - }) +/** Owner pids that have exited can never clean up after themselves. */ +function isAlive(pid: number | undefined): boolean { + if (pid === undefined) + return true + try { + process.kill(pid, 0) + return true + } + catch { + return false + } +} - function setConfigContent(): void { - if (!existsSync(caddyConfigPath)) { - writeFileSync(caddyConfigPath, '{}') - } +/** + * Reads the shared config, dropping entries whose owner process is gone. + * + * A SIGKILLed dev server cannot remove its own route, so without this sweep its + * domain would keep resolving to a proxy with no upstream behind it. + */ +function readConfig(path: string = caddyConfigPath): ProxyMap { + if (!existsSync(path)) + return {} + + let parsed: ProxyMap + try { + parsed = JSON.parse(readFileSync(path, 'utf-8')) as ProxyMap + } + catch { + // A half-written file is not worth preserving: it has no usable routes. + return {} + } + + const alive: ProxyMap = {} + for (const [key, entry] of Object.entries(parsed)) { + if (isAlive(entry?.ownerPid)) + alive[key] = entry + } + return alive +} + +/** + * Writes through a temporary file so a concurrent reader never sees a partial + * document. `rename` is atomic within a filesystem, which keeps the shared + * config readable at all times. + */ +function writeConfig(proxies: ProxyMap, path: string = caddyConfigPath): void { + const content = JSON.stringify(proxies, null, 2) + const temp = `${path}.${process.pid}.tmp` + writeFileSync(temp, content) + renameSync(temp, path) +} + +export interface CaddyConfigOptions { + /** Injected in tests so they do not need the real lock. */ + lock?: ConfigLock + /** + * Config file to operate on; defaults to the shared runtime path. + * + * Injectable so a test can own a file without racing every other test that + * happens to run against the same module-level constant. + */ + configPath?: string + /** + * Renders the Caddyfile from the routes, called inside the config lock. + * + * Rendering here rather than in the caller is what keeps the file in step + * with the JSON: two projects writing concurrently would otherwise render + * from their own stale view and the later write would win. + */ + renderCaddyfile?: (proxies: ProxyMap) => Promise +} + +export function useCaddyConfig(options: CaddyConfigOptions = {}): CaddyConfigApi { + const lock = options.lock ?? createFileLock() + const configPath = options.configPath ?? caddyConfigPath + + /** + * Applies the change, then re-renders while still holding the lock. + * + * The Caddyfile has to follow every change: leaving a stopped project's site + * in it would keep proxying to an upstream that is gone. Doing it inside the + * lock also picks up routes whose owner process died, since `readConfig` + * drops those. + */ + async function mutate( + apply: (proxies: ProxyMap) => void, + ): Promise { + const release = await lock.acquire() try { - configContentRef(readFileSync(caddyConfigPath, 'utf-8')) + const proxies = readConfig(configPath) + apply(proxies) + writeConfig(proxies, configPath) + await options.renderCaddyfile?.(proxies) + return proxies } - catch { - configContentRef('') + finally { + release() } } - if (!existsSync(caddyConfigPath)) { - writeFileSync(caddyConfigPath, '{}') - } + return { + configJsonRef: () => (existsSync(configPath) ? readConfig(configPath) : undefined), + + async writeCaddyConfig(_proxies, key) { + await mutate((proxies) => { + for (const p of _proxies) + proxies[routeKey(p, key)] = { ...p, base: normalizeBase(p.base), ownerPid: process.pid } + }) + }, - const watchCaddyConfig = watch(caddyConfigPath, () => { - setConfigContent() - }) - - function writeCaddyConfig(_proxies: Array): void { - const proxies = configJsonRef() || {} - - _proxies.forEach((p) => { - let base = p.base || '/' - if (!base.startsWith('/')) - base = `/${base}` - if (base !== '/' && !base.endsWith('/')) - base = `${base}/` - const index = `${p.target}${p.portSuffix}${base}` - proxies[index] = { ...p, base } - }) - - const content = JSON.stringify(proxies, null, 2) - configContentRef(content) - return writeFileSync(caddyConfigPath, content) + async removeProxy(key) { + // The empty config is written inside the lock rather than unlinked after + // it: between the release and the unlink another project can add its + // route, and the unlink would then drop a route that is in use. + await mutate((current) => { + for (const route of Object.keys(current)) { + if (route.endsWith(`#${key}`)) + delete current[route] + } + }) + }, + + readProxies: () => readConfig(configPath), + + async cleanUp() { + removeConfigFile(configPath) + }, } +} - async function cleanUp(): Promise { - await unlink(caddyConfigPath).catch(() => { }) - configContentRef('{}') - watchCaddyConfig.close() +function removeConfigFile(path: string = caddyConfigPath): void { + try { + if (existsSync(path)) + unlinkSync(path) } + catch {} +} +function normalizeBase(base: string | undefined): string { + let out = base || '/' + if (!out.startsWith('/')) + out = `/${out}` + if (out !== '/' && !out.endsWith('/')) + out = `${out}/` + return out +} + +/** + * Route key: the domain it answers on, plus the owning project. + * + * Two projects can legitimately serve the same domain on different bases, so + * the key has to carry the base as well as the owner. + */ +export function routeKey(proxy: typeof ProxyType.infer, ownerKey: string): string { + return `${proxy.target}${proxy.portSuffix}${normalizeBase(proxy.base)}#${ownerKey}` +} + +/** + * Builds the on-disk lock. + * + * `proper-lockfile` retries because the winner may hold the lock for a moment; + * `realpath: false` keeps it working when the temp directory is a symlink. + */ +function createFileLock(): ConfigLock { return { - configJsonRef, - writeCaddyConfig, - cleanUp, + async acquire() { + if (!existsSync(caddyConfigPath)) + writeFileSync(caddyConfigPath, '{}') + + const release = await lockfile.lock(caddyConfigPath, { + realpath: false, + stale: 10_000, + retries: { retries: 20, minTimeout: 10, maxTimeout: 100 }, + }) + return () => { + void release().catch(() => {}) + } + }, } } diff --git a/src/caddy/constants.ts b/src/caddy/constants.ts index f62cf4c..9021b16 100644 --- a/src/caddy/constants.ts +++ b/src/caddy/constants.ts @@ -13,13 +13,42 @@ function resolvePath(path: string): string { return path } -export const TEMP_DIR = resolvePath(process.env.UHRP_CADDY_PATH || process.env.TEMP || process.env.TMPDIR || '/tmp') +/** + * Directory for the Caddy binary, Caddyfile and the admin socket. + * + * The Caddy binary is executed as root, so this must not be a directory other + * users can write to. macOS gives every user a private `TMPDIR`, and `TEMP` on + * Windows is per-user too. On Linux `TMPDIR` is usually unset and `/tmp` is + * world-writable (sticky bit only prevents deleting other users' files, not + * creating `/tmp/caddy` first), so the fallback goes under the user's own cache + * directory instead. + */ +function defaultTempDir(): string { + const fromEnv = process.env.TEMP || process.env.TMPDIR + if (fromEnv) + return resolvePath(fromEnv) + if (process.platform === 'linux') + return join(process.env.XDG_CACHE_HOME || join(os.homedir(), '.cache'), 'uhrp') + return '/tmp' +} + +export const TEMP_DIR = resolvePath(process.env.UHRP_CADDY_PATH || defaultTempDir()) export const caddyPath = join(TEMP_DIR, `caddy${process.platform === 'win32' ? '.exe' : ''}`) export const caddyFilePath = join(TEMP_DIR, 'CADDYFILE') export const caddyConfigPath = join(TEMP_DIR, '.CADDYFILE.config.json') export const caddyLockFilePath = join(TEMP_DIR, 'caddylock') +/** + * Admin API socket, shared by every project using this helper. + * + * Caddy runs as root, so its admin API must not listen on a TCP port any local + * user can reach: `POST /load` there reconfigures the proxy with no further + * check. A unix socket inside the runtime directory is reachable only by whoever + * can open that directory, and the directory is private on macOS. + */ +export const caddyAdminSocketPath = join(TEMP_DIR, 'caddy-admin.sock') + export const supportList = [ { platform: 'dragonfly', diff --git a/src/caddy/index.ts b/src/caddy/index.ts index 8e7eb9c..c99bf76 100644 --- a/src/caddy/index.ts +++ b/src/caddy/index.ts @@ -1,33 +1,31 @@ -import { spawn } from 'node:child_process' -import { chmodSync, createWriteStream, existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs' -import { readFile, unlink, writeFile } from 'node:fs/promises' +import type { Buffer } from 'node:buffer' +import type { OpenedPrivilegedSession } from './privileged' +import { execFile } from 'node:child_process' +import { chmodSync, createWriteStream, existsSync, mkdirSync, unlinkSync } from 'node:fs' +import { readFile, rename, unlink, writeFile } from 'node:fs/promises' import { platform } from 'node:os' import { join } from 'node:path' import process from 'node:process' -import { effect } from 'alien-signals' import { got } from 'got-cjs' import { HttpProxyAgent } from 'http-proxy-agent' import { HttpsProxyAgent } from 'https-proxy-agent' -import * as lockfile from 'proper-lockfile' -import { addHost, removeHost } from '../host' -import { chmodRecursive, consola, once } from '../utils' +import { consola, once } from '../utils' import { genCaddyfile, validateTemplateContext } from './caddyfile' import { useCaddyConfig } from './config' -import { caddyFilePath, caddyLockFilePath, caddyPath, supportList, TEMP_DIR } from './constants' +import { caddyAdminSocketPath, caddyFilePath, caddyPath, supportList, TEMP_DIR } from './constants' import { isCaddyReloadCancellation } from './log' +import { HelperCaddyProcess, openPrivilegedSession } from './privileged' import { formatTlsHealthFailure, waitForLocalTls } from './tls-health' -import { kill, logProgress, logProgressOver, tryPort } from './utils' +import { logProgress, logProgressOver } from './utils' export async function download(): Promise { if (await testCaddy()) return caddyPath return new Promise((resolve, reject) => { - // https://caddyserver.com/api/download?os=darwin&arch=amd64 - if (!existsSync(TEMP_DIR)) { mkdirSync(TEMP_DIR, { recursive: true }) - chmodSync(TEMP_DIR, 0o777) + chmodSync(TEMP_DIR, 0o700) } const file = createWriteStream(caddyPath) @@ -55,8 +53,7 @@ export async function download(): Promise { const httpsAgent = httpsProxy ? new HttpsProxyAgent(httpsProxy) : undefined const chmodCaddyOnce = once(() => { - // chmod +x - chmodSync(caddyPath, 0o777) + chmodSync(caddyPath, 0o755) }) got.stream(dowmloadLink, { @@ -71,30 +68,32 @@ export async function download(): Promise { logProgressOver() if (process.platform === 'win32') return resolve(caddyPath) - // chmod +x - chmodSync(caddyPath, 0o777) + chmodSync(caddyPath, 0o755) resolve(caddyPath) }).on('error', (err) => { - // consola.error(err) reject(err) }) }) } -function testCaddy(): Promise { - return new Promise((resolve, reject) => { +/** + * Checks for a usable binary by running it with no arguments: Caddy prints its + * usage and exits, so this neither binds a port nor needs privileges. + */ +export function testCaddy(): Promise { + return new Promise((resolve) => { if (!existsSync(caddyPath)) return resolve(false) - chmodSync(caddyPath, 0o777) - const child = process.platform === 'win32' ? spawn(caddyPath, []) : spawn('sudo', ['-E', caddyPath]) - child.on('close', () => { - return resolve(false) - }) - child.on('error', (err) => { - return reject(err) - }) - child.stdout.on('data', (_data) => { - return resolve(true) + + try { + chmodSync(caddyPath, 0o755) + } + catch {} + + execFile(caddyPath, [], { timeout: 10_000 }, (error, stdout) => { + if (error && !stdout) + return resolve(false) + resolve(stdout.length > 0) }) }) } @@ -107,11 +106,44 @@ interface RunOptions { healthCheck?: boolean } +/** Where Caddy keeps the local CA; the template serves it for device installs. */ +function caddyRootPath(): string { + return join( + process.platform === 'win32' + ? `${process.env.AppData!}/Caddy` + : `${process.env.HOME!}/Library/Application Support/Caddy`, + './pki/authorities/local', + ) +} + +/** + * Renders the Caddyfile from the routes currently on disk and writes it. + * + * Called from inside the config lock, so two projects writing at once cannot + * render from their own stale view of the routes. + */ +async function renderCaddyfile(proxies: Record): Promise { + const ctx = validateTemplateContext({ + caddy_root: caddyRootPath(), + admin_socket: caddyAdminSocketPath, + proxies: Object.values(proxies) as never, + }) + const content = await genCaddyfile(ctx) + + // Written through a temporary file: Caddy watches this path, and a reload + // triggered by a half-written file fails to parse. The starter's stderr + // handler exits the dev server on a parse error, so a partial read would take + // down an unrelated project. + const temp = `${caddyFilePath}.${process.pid}.tmp` + await writeFile(temp, content) + await rename(temp, caddyFilePath) +} + let caddyConfig: ReturnType | undefined function getCaddyConfig(): ReturnType { if (!caddyConfig) { - caddyConfig = useCaddyConfig() + caddyConfig = useCaddyConfig({ renderCaddyfile: renderCaddyfile as never }) } return caddyConfig } @@ -129,32 +161,60 @@ function splitHostPort(addr: string): { host: string, port: string } { return { host: addr.slice(0, lastColon), port: addr.slice(lastColon + 1) } } +/** Injection point for tests: opens the privileged helper without sudo. */ +export interface CaddyDependencies { + openSession: (runtimeDir: string, reason?: { target: string, https: boolean }) => Promise + /** Shared route config; tests inject an in-memory one. */ + config: () => ReturnType +} + +const defaultDependencies: CaddyDependencies = { + openSession: (runtimeDir, reason) => openPrivilegedSession(runtimeDir, { + log: message => consola.debug(message), + // Both paths are fixed here, before any socket traffic: the privileged + // helper must never execute something named in a request. + caddyBin: caddyPath, + caddyConfigPath: caddyFilePath, + caddyAdminSocket: caddyAdminSocketPath, + reason, + }), + config: () => getCaddyConfig(), +} + export class CaddyInstant { private inited = false private stoped = false + private readonly deps: CaddyDependencies - /** has locked when start */ - private locked = false private caddyfile: string | undefined - private _caddyChild: ReturnType | undefined + private _caddyChild: HelperCaddyProcess | undefined + private _session: OpenedPrivilegedSession | undefined private _source: string | undefined private _target: string | undefined - private _stopEffects: Array< (() => void)> = [] + private _stopEffects: Array<(() => void)> = [] + /** Identifies this instance's routes in the shared config. */ + private readonly proxyKey = `${process.pid}-${Math.random().toString(36).slice(2, 8)}` - constructor() { - testCaddy().then(async () => { - this.inited = true - }) - - this.locked = this.checkLock() + constructor(deps: Partial = {}) { + this.deps = { ...defaultDependencies, ...deps } } - checkLock(): boolean { - if (!existsSync(caddyLockFilePath)) - writeFileSync(caddyLockFilePath, '') - return lockfile.checkSync(caddyLockFilePath) + /** + * Connects to the privileged helper, starting it once if needed. It is + * reused across instances so a second dev server does not ask for a + * password again. + */ + private async session(reason?: { target: string, https: boolean }): Promise { + // A crashed helper leaves the cached client permanently disconnected; drop + // it so the next call starts a fresh helper instead of failing forever. + if (this._session && !this._session.client.connected) + this._session = undefined + + if (!this._session) + this._session = await this.deps.openSession(TEMP_DIR, reason) + return this._session } async getCaddyfile(): Promise { @@ -168,12 +228,7 @@ export class CaddyInstant { } get caddyRoot(): string { - return join( - process.platform === 'win32' - ? `${process.env.AppData!}/Caddy` - : `${process.env.HOME!}/Library/Application Support/Caddy`, - './pki/authorities/local', - ) + return caddyRootPath() } async writeCaddyfile(): Promise { @@ -189,43 +244,40 @@ export class CaddyInstant { this.inited = true } + /** + * Hands the Caddy storage directory back to the developer. Local CA data + * created by an earlier root-owned run must stay readable for `tls internal` + * to keep serving the same certificate the browser already trusts. + */ + /** + * Installs Caddy's local root CA so the browser stops warning about + * `tls internal` certificates. Generating the CA and trusting it are separate + * steps: a new CA appears whenever the previous one expires or the storage is + * replaced, so this runs on every start instead of once. + * + * The helper owns both the binary path and the command, so nothing here can + * be turned into an arbitrary root execution. + */ + private async trustLocalCa(session: OpenedPrivilegedSession): Promise { + try { + const result = await session.client.call('ca.trust') + if (result.trusted) + consola.debug(`Caddy local CA is trusted\n${result.output}`) + } + catch (error) { + consola.warn( + `could not install the Caddy root certificate automatically: ${(error as Error).message}\n` + + `Run \`${caddyPath} trust\` manually if the browser warns about the certificate.`, + ) + } + } + private async ensureTlsReady(targetHost: string): Promise { const health = await waitForLocalTls(targetHost) if (!health.healthy) throw new Error(formatTlsHealthFailure(targetHost, health)) } - private async updateCaddyfile(): Promise { - const { configJsonRef } = getCaddyConfig() - const proxies = Object.values(configJsonRef() || {}) - const ctx = validateTemplateContext({ - caddy_root: this.caddyRoot, - proxies, - }) - - this.caddyfile = await genCaddyfile(ctx) - await this.writeCaddyfile() - } - - private async stopCaddy(): Promise { - try { - await got.post('http://127.0.0.1:2019/stop', { - headers: { - Origin: 'http://localhost:2019', - }, - retry: { - limit: 0, - }, - timeout: { - request: 2000, - }, - }) - } - catch {} - - this._caddyChild?.kill() - } - /** * @param source like `127.0.0.1:8080` * @param target like `test.abc.com`. And if you provide a port ,it will be ignore. @@ -251,9 +303,11 @@ export class CaddyInstant { const sourceHost = splitHostPort(source).host const targetHost = splitHostPort(target).host - if (!await addHost(sourceHost, targetHost)) - throw new Error('update host failed') + + const session = await this.session({ target: targetHost, https }) + await session.client.call('hosts.add', { ip: sourceHost, host: targetHost }) consola.success('update host success\n') + const normalizeBase = (b: string): string => { let out = b || '/' if (!out.startsWith('/')) @@ -275,32 +329,18 @@ export class CaddyInstant { }, ] - const { writeCaddyConfig } = getCaddyConfig() - writeCaddyConfig(proxies) - - await this.updateCaddyfile() + // This also renders the Caddyfile, inside the same lock: rendering here + // afterwards would read the routes outside the lock and could overwrite a + // newer Caddyfile written by another project in between. + const { writeCaddyConfig } = this.deps.config() + await writeCaddyConfig(proxies, this.proxyKey) - let skipInitialCaddyfileEffect = true - this._stopEffects.push( - effect(() => { - getCaddyConfig().configJsonRef() - if (skipInitialCaddyfileEffect) { - skipInitialCaddyfileEffect = false - return - } - void this.updateCaddyfile() - }), - ) - - if (!existsSync(caddyLockFilePath)) - writeFileSync(caddyLockFilePath, '') - - if (!this.locked) { - if (await tryPort(443)) - await kill(443) - if ((process.platform === 'win32' || !https) && await tryPort(80)) - await kill(80) - } + // Ports 80/443 may still be held by a previous, uncleanly stopped run. + // The helper skips its own Caddy, so this never kills a proxy another + // project is already using. + await session.client.call('port.free', { port: 443 }) + if (process.platform === 'win32' || !https) + await session.client.call('port.free', { port: 80 }) return new Promise((resolve, reject) => { let startupCheckStarted = false @@ -311,13 +351,17 @@ export class CaddyInstant { startupCheckStarted = true try { - if (https) + if (https) { + // `caddy trust` reads the CA through the admin API, so it can only + // run once Caddy is serving. The helper does it as root because the + // system trust store is not writable by the developer account. + await this.trustLocalCa(session) await this.ensureTlsReady(targetHost) + } resolve() } catch (error) { - if (!this.locked) - await this.baseCleanup(restore) + await this.baseCleanup(restore) reject(error) } } @@ -344,25 +388,6 @@ export class CaddyInstant { // @ts-expect-error override process.exit = async (code?: number) => { - const port = Number(splitHostPort(source).port) - if (!Number.isNaN(port) && await tryPort(port)) - await kill(port) - - // fix `Error: EPERM: operation not permitted` - const pwd = process.cwd() - const viteCacheDir = `${pwd}/node_modules/.vite` - if (existsSync(viteCacheDir)) - await chmodRecursive(viteCacheDir, 0o777).catch(() => { }) - const viteTempCacheDir = `${pwd}/node_modules/.vite-temp` - if (existsSync(viteTempCacheDir)) - await chmodRecursive(viteTempCacheDir, 0o777).catch(() => { }) - const nuxtCacheDir = `${pwd}/.nuxt` - if (existsSync(nuxtCacheDir)) - await chmodRecursive(nuxtCacheDir, 0o777).catch(() => { }) - const webpackCacheDir = `${pwd}/node_modules/.cache` - if (existsSync(webpackCacheDir)) - await chmodRecursive(webpackCacheDir, 0o777).catch(() => { }) - if (this.stoped) return originalExit(code) @@ -381,101 +406,109 @@ export class CaddyInstant { } } - if (!this.locked) { - this._caddyChild = process.platform !== 'win32' - ? spawn('sudo', ['-E', caddyPath, 'run', '--config', caddyFilePath, '--watch']) - : spawn(caddyPath, ['run', '--config', caddyFilePath, '--watch']) + setupCleanup() - lockfile.lockSync(caddyLockFilePath) - - this._caddyChild!.on('error', (err) => { - return reject(err) - }) + void (async () => { + try { + const child = new HelperCaddyProcess(session.client) + this._caddyChild = child + + const started = await session.client.call('caddy.start') + + child.on('exit', (code: number | null) => { + if (code !== null && code !== 0) + reject(new Error(`Caddy exited with code ${code}`)) + }) + + // Caddy was already serving: its "serving initial" line was emitted + // when the first project started it, so waiting for it again would + // hang this instance forever. + if (started?.started === false) { + consola.info('Caddy is already running; added this route to it.') + void resolveWhenReady() + return + } - this._caddyChild!.stderr?.on('data', async (data) => { - const lines = (data.toString() as string).split('\n').map(line => line.trim()) - for (const line of lines) { - // caddy log - // eslint-disable-next-line no-console - showCaddyLog && line && console.info(line) - if (isCaddyReloadCancellation(line)) { - consola.info('Caddy restarted before TLS issuance completed; retrying.\n') - continue - } - try { - if (line.includes('Error:') || (line && JSON.parse(line).level === 'error')) { - if (line.includes('parsing caddyfile tokens')) { - consola.error('Caddyfile syntax error, please contact the author:\n', `${this.caddyfile}\n\n${line}\n`) - await this.baseCleanup() - process.exit() - } - else { - consola.error(`${line}\n`) - // child.kill() - return reject(line) + child.stderr.on('data', async (data: Buffer) => { + const lines = data.toString().split('\n').map((line: string) => line.trim()) + for (const line of lines) { + // caddy log + // eslint-disable-next-line no-console + showCaddyLog && line && console.info(line) + if (isCaddyReloadCancellation(line)) { + consola.info('Caddy restarted before TLS issuance completed; retrying.\n') + continue + } + try { + if (line.includes('Error:') || (line && JSON.parse(line).level === 'error')) { + if (line.includes('parsing caddyfile tokens')) { + consola.error('Caddyfile syntax error, please contact the author:\n', `${this.caddyfile}\n\n${line}\n`) + await this.baseCleanup() + process.exit() + } + else { + consola.error(`${line}\n`) + return reject(line) + } } } - } - catch {} - - if (line.includes('config file changed')) - consola.success('Detect added a new reverse proxy config, restarted.\n') + catch {} - if (line.includes('caddy proxying') || line.includes('serving initial')) - void resolveWhenReady() - } - }) + if (line.includes('config file changed')) + consola.success('Detect added a new reverse proxy config, restarted.\n') - this._caddyChild!.stdout?.on('data', (_data) => { - consola.info(_data.toString()) - void resolveWhenReady() - }) - - setupCleanup() - } - else { - consola.info('Caddy is running in another process.') + if (line.includes('caddy proxying') || line.includes('serving initial')) + void resolveWhenReady() + } + }) - void (async () => { - try { - if (https) - await this.ensureTlsReady(targetHost) - setupCleanup() - resolve() - } - catch (error) { - reject(error) - } - })() - } + child.stdout.on('data', (data: Buffer) => { + consola.info(data.toString()) + void resolveWhenReady() + }) + } + catch (error) { + // The hosts entry was written before Caddy started; a failed start + // must not leave it behind while the dev server keeps running. + await this.baseCleanup(restore) + reject(error) + } + })() }) } async baseCleanup(restore: boolean = true): Promise { this._stopEffects.forEach(fn => fn()) + this._stopEffects.length = 0 try { - if (restore && this._source && this._target) { - if (await removeHost(splitHostPort(this._source).host, splitHostPort(this._target).host)) - consola.success('Restore host success.\n') - - else - consola.fail('Restore host failed.\n') + if (restore && this._source && this._target && this._session) { + const sourceHost = splitHostPort(this._source).host + const targetHost = splitHostPort(this._target).host + await this._session.client.call('hosts.remove', { ip: sourceHost, host: targetHost }) + if (sourceHost === 'localhost') + await this._session.client.call('hosts.remove', { ip: '127.0.0.1', host: targetHost }) + consola.success('Restore host success.\n') } } catch (e) { - consola.error(e) - consola.fail('Restore host failed.\n') + // The helper restores its own entries when the connection drops, so a + // closed session here is not a failed cleanup. + if (this._session?.client.connected === false) { + consola.debug('hosts entries are restored by the privileged helper on disconnect') + } + else { + consola.error(e) + consola.fail('Restore host failed.\n') + } } - if (!this.locked && !this.stoped) { - await this.stopCaddy() + + // Caddy and the shared Caddyfile belong to every project using this + // helper. Only this project's route is removed here; the helper stops + // Caddy when its last client disconnects. + if (!this.stoped) { try { - await lockfile.unlock(caddyLockFilePath).catch(() => { }) - await unlink(caddyFilePath).catch(() => { }) - await unlink(caddyLockFilePath).catch(() => { }) - if (caddyConfig) { - await caddyConfig.cleanUp() - } + await this.deps.config().removeProxy(this.proxyKey) } catch (e) { consola.error(e) diff --git a/src/caddy/privileged.ts b/src/caddy/privileged.ts new file mode 100644 index 0000000..f0f9d8c --- /dev/null +++ b/src/caddy/privileged.ts @@ -0,0 +1,124 @@ +import type { PrivilegedClient } from '../privileged/client' +import type { HelperEvent } from '../privileged/protocol' +import { Buffer } from 'node:buffer' +import { EventEmitter } from 'node:events' +import { dirname, join } from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { resolveHelperEntry } from '../privileged/entry' +import { openHelperSession } from '../privileged/session' + +export interface PrivilegedEnv { + /** Executable started as root, resolved from the running bundle. */ + helperEntry: string + helperArgs: string[] +} + +/** + * Resolves what the helper needs to be started from the current bundle. + * `__dirname` exists in both the CJS and ESM outputs (tsup injects a shim). + */ +export function resolvePrivilegedEnv(): PrivilegedEnv { + const moduleDir = typeof __dirname === 'string' + ? __dirname + : dirname(fileURLToPath(import.meta.url)) + const resolved = resolveHelperEntry({ moduleDir }) + return { helperEntry: resolved.entry, helperArgs: resolved.args } +} + +/** Re-emits helper events as the `stdout`/`stderr` events the log parsing expects. */ +export class HelperCaddyProcess extends EventEmitter { + readonly stdout = new EventEmitter() + readonly stderr = new EventEmitter() + private finished = false + private readonly unsubscribe: () => void + + constructor(private readonly client: PrivilegedClient) { + super() + this.unsubscribe = client.onEvent(event => this.handleEvent(event)) + } + + private handleEvent(event: HelperEvent): void { + if (event.type !== 'event') + return + + if (event.kind === 'caddy.stdout') { + this.stdout.emit('data', Buffer.from(event.data)) + return + } + + if (event.kind === 'caddy.stderr') { + this.stderr.emit('data', Buffer.from(event.data)) + return + } + + if (event.kind === 'caddy.exit') { + this.finished = true + this.emit('exit', event.code, event.signal) + } + } + + /** Asks the helper to stop Caddy; the helper also stops it on disconnect. */ + kill(): void { + this.unsubscribe() + if (this.finished) + return + this.finished = true + void this.client.call('caddy.stop').catch(() => {}) + } +} + +export interface OpenedPrivilegedSession { + client: PrivilegedClient + reused: boolean + close: () => Promise +} + +export interface PrivilegedSessionOptions { + log?: (message: string) => void + /** Runs the helper as the current user; tests use this to avoid sudo. */ + direct?: boolean + /** Caddy binary the helper may run; fixed before the session opens. */ + caddyBin: string + /** Caddyfile the helper may load. */ + caddyConfigPath: string + /** Admin API socket; `ca.trust` talks to it instead of the TCP default. */ + caddyAdminSocket?: string + /** Shown before the password prompt so the request is not a surprise. */ + reason?: { target: string, https: boolean } +} + +/** Where Caddy keeps its PKI, per platform. */ +export function caddyStoragePath(): string { + return process.platform === 'win32' + ? join(process.env.AppData ?? '', 'Caddy') + : join(process.env.HOME ?? '', 'Library/Application Support/Caddy') +} + +/** + * Opens (or reuses) the privileged helper for this runtime directory. + * Only the helper runs as root: the plugin process, its caches and its + * `node_modules` keep the developer's identity. + */ +export function openPrivilegedSession( + runtimeDir: string, + options: PrivilegedSessionOptions, +): Promise { + const env = resolvePrivilegedEnv() + return openHelperSession({ + runtimeDir, + helperEntry: env.helperEntry, + helperArgs: env.helperArgs, + direct: options.direct, + // Caddy keeps its PKI under the user's home; the helper runs it as root, so + // it must know which tree to hand back afterwards. + caddyStoragePath: caddyStoragePath(), + // The binary and Caddyfile are fixed here: the privileged helper must never + // execute a path supplied over the socket. + caddyBin: options.caddyBin, + caddyConfigPath: options.caddyConfigPath, + caddyAdminSocket: options.caddyAdminSocket, + reason: options.reason, + log: options.log, + }) +} diff --git a/src/caddy/utils.ts b/src/caddy/utils.ts index 33e3abd..7c27cb0 100644 --- a/src/caddy/utils.ts +++ b/src/caddy/utils.ts @@ -1,51 +1,8 @@ -import { exec } from 'node:child_process' -import { createServer } from 'node:net' import ora from 'ora' import { consola } from '../utils' let spinner: ora.Ora -/** - * @returns whether the port is in use - */ -export function tryPort(port: number): Promise { - return new Promise((resolve, _reject) => { - try { - const server = createServer().listen(port) - server.on('listening', () => { - server.close() - resolve(false) - }) - server.on('error', (err) => { - // @ts-expect-error err.code - if (err.code === 'EADDRINUSE') - resolve(true) - server.close() - }) - } - catch { - resolve(true) - } - }) -} - -/** - * npx free-port :port - */ -export async function kill(port: number): Promise { - return new Promise((resolve, reject) => { - exec(`npx @maxbo/free-port ${port} -s`, (err, stdout, _stderr) => { - if (err) { - reject(err) - } - else { - consola.log(stdout) - resolve() - } - }) - }) -} - function getNumber(number: number): string { if (number <= 0) return '0%' diff --git a/src/host/index.ts b/src/host/index.ts deleted file mode 100644 index 2c78e1f..0000000 --- a/src/host/index.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { exec } from 'node:child_process' -import { chmodSync } from 'node:fs' -import process from 'node:process' -import { promisify } from 'node:util' - -import hostile from 'hostile' -import { consola } from '../utils' - -const _setHost = promisify(hostile.set) -const _removeHost = promisify(hostile.remove) - -function getPathOfSystemHostsPath(): string { - return process.platform === 'win32' - ? `${process.env.windir || 'C:\\WINDOWS'}\\system32\\drivers\\etc\\hosts` - : '/etc/hosts' -} - -async function syncHost(): Promise { - return new Promise((resolve, _reject) => { - exec( - process.platform === 'win32' - ? 'ipconfig /flushdns' - : 'sudo -E killall -HUP mDNSResponder', - (error, _stdout, _stderr) => { - if (error) { - consola.error(`exec error: ${error}`) - return resolve(false) - } - return resolve(true) - }, - ) - }) -} - -export async function addHost(ip: string, host: string): Promise { - if (process.platform !== 'win32') - chmodSync(getPathOfSystemHostsPath(), 0o777) - await _setHost('127.0.0.1', 'localhost') - await _setHost(ip, host) - if (ip === 'localhost') - await _setHost('127.0.0.1', host) - return await syncHost() -} - -export async function removeHost(ip: string, host: string): Promise { - if (process.platform !== 'win32') - chmodSync(getPathOfSystemHostsPath(), 0o777) - await _removeHost(ip, host) - if (ip === 'localhost') - await _removeHost('127.0.0.1', host) - return await syncHost() -} diff --git a/src/index.ts b/src/index.ts index 47c250f..287618e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,9 @@ import c from 'picocolors' import { createUnplugin } from 'unplugin' import { CaddyInstant } from './caddy' import { hmrSseBridgeClientScript, setupHmrSseBridge } from './hmr-sse-bridge' -import { consola, generateQrcode, getIpv4List, isAdmin, once } from './utils' +import { isPrivilegeDenied, markPrivilegeDenied } from './privilege-state' +import { PrivilegeDeniedError } from './privileged/launch' +import { consola, generateQrcode, getIpv4List, once } from './utils' let config: ResolvedConfig @@ -62,10 +64,15 @@ export const vitePrintUrls = once(( ) => { _printUrls?.() + // Declined earlier in this process; a hot restart must not ask again. + if (isPrivilegeDenied()) + return + if (caddy) return - caddy = new CaddyInstant() - caddy.run(source, target, { + const instance = new CaddyInstant() + caddy = instance + instance.run(source, target, { base, ...options, }).then(async () => { @@ -73,6 +80,16 @@ export const vitePrintUrls = once(( consola.success(` ${c.green('➜')} ${c.bold('run caddy reverse proxy success')}: ${colorUrl(`${options.https ? 'https' : 'http'}://${target}${base}`)}`) }).catch((e) => { + // Declining the password is a choice, not a crash: drop the half-started + // instance so a later attempt can prompt again. + if (caddy === instance) + caddy = undefined as unknown as CaddyInstant + if (e instanceof PrivilegeDeniedError) { + markPrivilegeDenied() + consola.warn('reverse proxy skipped: administrator permission was not granted.') + consola.info('Restart the dev server to enable it again.') + return + } throw e }) }) @@ -96,10 +113,6 @@ export const unpluginFactory: UnpluginFactory = options => ({ if (!enable) return - if (!isAdmin()) { - consola.warn('please run as administrator') - return - } if (!target) { consola.fail('please provide target') return @@ -153,11 +166,6 @@ export const unpluginFactory: UnpluginFactory = options => ({ if (!enable) return - if (!isAdmin()) { - consola.warn('please run as administrator') - return - } - if (!target) { consola.fail('please provide target') return @@ -177,6 +185,9 @@ export const unpluginFactory: UnpluginFactory = options => ({ } const source = `${devServer.host}:${devServer.port}` + if (isPrivilegeDenied()) + return + if (caddy) return @@ -204,9 +215,10 @@ export const unpluginFactory: UnpluginFactory = options => ({ options.https = false } - caddy = new CaddyInstant() + const instance = new CaddyInstant() + caddy = instance - caddy.run(source, target, { + instance.run(source, target, { base, ...options, }).then(() => { @@ -216,6 +228,14 @@ export const unpluginFactory: UnpluginFactory = options => ({ consola.success(` ${c.green('➜')} ${c.bold('run caddy reverse proxy success')}: ${colorUrl(`${options.https ? 'https' : 'http'}://${target}${base}`)}`) }) }).catch((e) => { + if (caddy === instance) + caddy = undefined as unknown as CaddyInstant + if (e instanceof PrivilegeDeniedError) { + markPrivilegeDenied() + consola.warn('reverse proxy skipped: administrator permission was not granted.') + consola.info('Restart the dev server to enable it again.') + return + } throw e }) }, @@ -231,11 +251,6 @@ export const unpluginFactory: UnpluginFactory = options => ({ if (!enable) return - if (!isAdmin()) { - consola.warn('please run as administrator') - return - } - if (!target) { consola.fail('please provide target') return @@ -257,10 +272,11 @@ export const unpluginFactory: UnpluginFactory = options => ({ const source = `${devServer.host}:${devServer.port}` - if (caddy) + if (isPrivilegeDenied()) return - caddy = new CaddyInstant() + if (caddy) + return let base = '/' const publicPath = compiler.options.output?.publicPath as unknown @@ -272,7 +288,10 @@ export const unpluginFactory: UnpluginFactory = options => ({ if (base !== '/' && !base.endsWith('/')) base = `${base}/` - caddy.run(source, target, { + const instance = new CaddyInstant() + caddy = instance + + instance.run(source, target, { base, ...options, }).then(async () => { @@ -280,6 +299,14 @@ export const unpluginFactory: UnpluginFactory = options => ({ consola.success(` ${c.green('➜')} ${c.bold('run caddy reverse proxy success')}: ${colorUrl(`${options.https ? 'https' : 'http'}://${target}${base}`)}`) }).catch((e) => { + if (caddy === instance) + caddy = undefined as unknown as CaddyInstant + if (e instanceof PrivilegeDeniedError) { + markPrivilegeDenied() + consola.warn('reverse proxy skipped: administrator permission was not granted.') + consola.info('Restart the dev server to enable it again.') + return + } throw e }) }, diff --git a/src/nuxt.ts b/src/nuxt.ts index 9685d94..a6d16c5 100644 --- a/src/nuxt.ts +++ b/src/nuxt.ts @@ -2,7 +2,7 @@ import type { Options } from './types' import process from 'node:process' import { defineNuxtModule } from '@nuxt/kit' import { vitePrintUrls } from '.' -import { consola, isAdmin } from './utils' +import { consola } from './utils' import '@nuxt/schema' export interface ModuleOptions extends Options { @@ -26,11 +26,6 @@ export default defineNuxtModule({ if (!enable) return - if (!isAdmin()) { - consola.warn('please run as administrator') - return - } - if (!target) { consola.fail('please provide target') return diff --git a/src/privilege-state.ts b/src/privilege-state.ts new file mode 100644 index 0000000..8ae8f86 --- /dev/null +++ b/src/privilege-state.ts @@ -0,0 +1,23 @@ +/** + * Whether the user dismissed the password prompt in this process. + * + * Kept in its own module so the flag outlives a hot restart. Vite rebuilds the + * dev server in place (`Object.assign(server, newServer)`) rather than starting + * a new process, so module scope is the only place that distinguishes "saved + * the config again" from "started the dev server again". + */ +let denied = false + +/** Records that the user declined; every entry point checks this first. */ +export function markPrivilegeDenied(): void { + denied = true +} + +export function isPrivilegeDenied(): boolean { + return denied +} + +/** Test hook: restores the initial state between cases. */ +export function resetPrivilegeDenied(): void { + denied = false +} diff --git a/src/privileged-helper.ts b/src/privileged-helper.ts new file mode 100644 index 0000000..a035d92 --- /dev/null +++ b/src/privileged-helper.ts @@ -0,0 +1,123 @@ +import process from 'node:process' +import { startHelper } from './privileged/bootstrap' + +interface ParsedArgs { + socket: string + token: string + hosts?: string + caddyBin: string + caddyConfig: string + ownerUid?: number + ownerGid?: number + caddyStorage?: string + caddyAdmin?: string +} + +function parseArgs(argv: string[]): ParsedArgs { + let socket = '' + let token = '' + let hosts: string | undefined + let caddyBin = '' + let caddyConfig = '' + let ownerUid: number | undefined + let ownerGid: number | undefined + let caddyStorage: string | undefined + let caddyAdmin: string | undefined + + for (let index = 0; index < argv.length; index++) { + const current = argv[index] + if (current === '--socket') + socket = argv[++index] ?? '' + else if (current === '--token') + token = argv[++index] ?? '' + else if (current === '--hosts') + hosts = argv[++index] ?? undefined + else if (current === '--caddy') + caddyBin = argv[++index] ?? '' + else if (current === '--caddy-config') + caddyConfig = argv[++index] ?? '' + else if (current === '--owner-uid') + ownerUid = Number(argv[++index]) + else if (current === '--owner-gid') + ownerGid = Number(argv[++index]) + else if (current === '--caddy-storage') + caddyStorage = argv[++index] ?? undefined + else if (current === '--caddy-admin') + caddyAdmin = argv[++index] ?? undefined + } + + if (!socket || !token) + throw new Error('usage: privileged-helper --socket --token --caddy --caddy-config ') + if (!caddyBin || !caddyConfig) + throw new Error('privileged-helper requires --caddy and --caddy-config') + + return { + socket, + token, + hosts, + caddyBin, + caddyConfig, + ownerUid: Number.isInteger(ownerUid) ? ownerUid : undefined, + ownerGid: Number.isInteger(ownerGid) ? ownerGid : undefined, + caddyStorage, + caddyAdmin, + } +} + +/** + * Entry point of the process that runs with elevated privileges. + * + * It owns no product logic. The Caddy binary and Caddyfile are fixed on the + * command line, so requests on the socket can only say *when* to act, never + * what* to execute. + */ +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)) + + const helper = await startHelper({ + socketPath: args.socket, + token: args.token, + hostsPath: args.hosts, + caddyBin: args.caddyBin, + caddyConfigPath: args.caddyConfig, + ownerUid: args.ownerUid, + ownerGid: args.ownerGid, + caddyStoragePath: args.caddyStorage, + caddyAdminSocket: args.caddyAdmin, + caddyEnv: { + HOME: process.env.HOME ?? '', + // Keep Caddy's storage next to the invoking user's home even though the + // process runs as root. + XDG_DATA_HOME: process.env.XDG_DATA_HOME ?? '', + }, + }) + + // Terminal signals reach the whole foreground process group, so they also hit + // this helper when one of several dev servers stops. Acting on them would tear + // down the shared Caddy and hosts entries the remaining projects still use. + // The socket closing is the only signal this helper acts on. + // + // Ctrl+C, Ctrl+\, Ctrl+Z and a terminal closing all arrive this way: SIGINT, + // SIGQUIT, SIGTSTP and SIGHUP. Ignoring SIGTSTP also keeps the helper from + // being suspended, which would stall every attached project's RPC. + for (const signal of ['SIGINT', 'SIGQUIT', 'SIGTSTP', 'SIGHUP'] as const) + process.on(signal, () => {}) + + // SIGTERM is different: IDE and process-tree runners send it to shut the tool + // down for real. It still must not drop a live session, so it only tears down + // when no dev server is attached. + process.on('SIGTERM', () => { + if (helper.runtime.clientCount() > 0) + return + void helper.runtime.dispose().finally(() => process.exit(0)) + }) + + // A closed stdout (the parent died) must not interrupt the cleanup path. + process.stdout.on('error', () => {}) + process.stderr.on('error', () => {}) +} + +main().catch((error: Error) => { + process.stderr.write(`[uhrp-helper] fatal: ${error.message}\n`) + process.exit(1) +}) diff --git a/src/privileged/bootstrap.ts b/src/privileged/bootstrap.ts new file mode 100644 index 0000000..9651437 --- /dev/null +++ b/src/privileged/bootstrap.ts @@ -0,0 +1,289 @@ +import type { Socket } from 'node:net' +import type { ClientHandshake } from './protocol' +import type { HelperDeps, HelperRuntime } from './server' +import { Buffer } from 'node:buffer' +import { chmodSync, chownSync, existsSync, readFileSync, rmSync } from 'node:fs' +import { createServer } from 'node:net' +import { dirname, join } from 'node:path' +import process from 'node:process' +import { checkPrivilegedPath, describeRejection } from './path-guard' +import { attachSocket, createHelperRuntime, createNodeHelperDeps } from './server' + +export interface HelperBootstrapOptions { + socketPath: string + token: string + hostsPath?: string + /** Caddy binary; fixed here so the client never names an executable. */ + caddyBin: string + /** Caddyfile to load; fixed for the same reason. */ + caddyConfigPath: string + /** Admin API socket; used by `ca.trust` instead of the TCP default. */ + caddyAdminSocket?: string + caddyCwd?: string + caddyEnv?: Record + /** + * Owner of the socket file. The helper runs as root while its client runs as + * the developer, so the socket must be handed back or the client cannot + * connect to it. + */ + ownerUid?: number + ownerGid?: number + /** Caddy storage directory, handed back to the owner when Caddy stops. */ + caddyStoragePath?: string + /** + * How long the helper lingers with no clients before exiting. + * + * Kept short so a helper nobody is using does not stay on the machine as an + * idle root process. Within the same terminal a restart usually happens + * sooner than this and still reuses the running helper. + */ + idleTimeoutMs?: number + /** + * Whether the idle timer may end this process. + * + * True for the real helper, which owns its process. False when `startHelper` + * is called in-process (tests): exiting there would take the caller down + * with it. + */ + exitOnIdle?: boolean + deps?: HelperDeps + log?: (message: string) => void +} + +export interface RunningHelper { + runtime: HelperRuntime + close: () => Promise +} + +const HANDSHAKE_TIMEOUT_MS = 120_000 + +/** Default idle lifetime after the last client leaves. */ +export const DEFAULT_IDLE_TIMEOUT_MS = 30_000 + +/** + * Runs the privileged side of the plugin: listens on a local socket, accepts + * only a client presenting the shared token, then serves until that client + * disconnects. Losing the client is the cleanup trigger, so a crashed or + * `SIGKILL`ed plugin still leaves hosts and Caddy restored. + */ +export async function startHelper(options: HelperBootstrapOptions): Promise { + const { socketPath, token } = options + const log = options.log ?? ((message: string) => process.stdout.write(`[uhrp-helper] ${message}\n`)) + + // The binary and the config travel in as arguments from a process running as + // the developer. Refuse them when they can be rewritten by someone else: + // running as root, a swapped binary or Caddyfile is a local privilege gain. + for (const check of [ + checkPrivilegedPath(options.caddyBin, { label: 'Caddy binary', ownerUid: options.ownerUid }), + checkPrivilegedPath(options.caddyConfigPath, { label: 'Caddyfile', ownerUid: options.ownerUid }), + ]) { + if (check) { + log(describeRejection(check)) + throw new Error(describeRejection(check)) + } + } + const deps = options.deps ?? createNodeHelperDeps({ + hostsPath: options.hostsPath, + log, + caddyBin: options.caddyBin, + caddyConfigPath: options.caddyConfigPath, + caddyAdminSocket: options.caddyAdminSocket, + caddyCwd: options.caddyCwd, + caddyEnv: options.caddyEnv, + caddyStoragePath: options.caddyStoragePath, + storageOwner: typeof options.ownerUid === 'number' + ? { uid: options.ownerUid, gid: options.ownerGid ?? options.ownerUid } + : undefined, + }) + const runtime = createHelperRuntime(deps) + + // A previous session that was killed cannot have cleaned up its entries; do + // it now, before any client attaches. + await runtime.sweepStaleEntries().catch(() => {}) + + try { + rmSync(socketPath, { force: true }) + } + catch {} + + const connections = new Set() + const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS + // The real helper is a process of its own and may end itself. When this runs + // inside a host process (tests), only the socket is released. + const exitOnIdle = options.exitOnIdle ?? true + let idleTimer: ReturnType | undefined + + const stopIdleTimer = (): void => { + if (idleTimer) { + clearTimeout(idleTimer) + idleTimer = undefined + } + } + + const server = createServer((socket) => { + let buffer = '' + let authenticated = false + stopIdleTimer() + connections.add(socket) + socket.on('close', () => { + connections.delete(socket) + if (connections.size === 0) + scheduleIdleExit() + }) + + const timer = setTimeout(() => { + if (!authenticated) + socket.destroy() + }, HANDSHAKE_TIMEOUT_MS) + timer.unref?.() + + const onData = (chunk: Buffer): void => { + buffer += chunk.toString('utf8') + const index = buffer.indexOf('\n') + if (index < 0) + return + + const line = buffer.slice(0, index) + const rest = buffer.slice(index + 1) + + let parsed: Partial + try { + parsed = JSON.parse(line) + } + catch { + socket.destroy() + return + } + + if (parsed.type !== 'handshake' || typeof parsed.token !== 'string') { + socket.destroy() + return + } + + clearTimeout(timer) + socket.removeListener('data', onData) + + if (parsed.token !== token) { + socket.end(`${JSON.stringify({ type: 'handshake', ok: false, reason: 'invalid token' })}\n`) + return + } + + authenticated = true + socket.write(`${JSON.stringify({ type: 'handshake', ok: true, pid: process.pid })}\n`) + attachSocket(socket, runtime, log) + + // Requests pipelined behind the handshake belong to the attached client. + if (rest.trim() !== '') + socket.emit('data', Buffer.from(rest)) + } + + socket.on('data', onData) + socket.on('error', () => {}) + }) + + /** + * Exits once nobody needs the helper. + * + * Without this, every dev server that ever ran leaves a root process behind. + * The socket and token are removed too, but only while they still carry this + * process's identity: a newer helper may already own them. + */ + function scheduleIdleExit(): void { + if (idleTimer || connections.size > 0) + return + + idleTimer = setTimeout(() => { + idleTimer = undefined + if (connections.size > 0) + return + + log('no clients left; shutting down') + removeOwnSocket(socketPath, token) + + if (!exitOnIdle) { + server.close() + return + } + + server.close(() => process.exit(0)) + // A listener with no clients closes synchronously, but keep a backstop so + // the process cannot linger if a close callback never arrives. + setTimeout(() => process.exit(0), 1000).unref?.() + }, idleTimeoutMs) + idleTimer.unref?.() + } + + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(socketPath, () => resolve()) + }) + + // A helper nobody ever connects to is as useless as one whose clients left: + // start the timer now so a failed handshake does not strand it. + scheduleIdleExit() + + try { + // The client is not root: hand the socket back to the developer and keep it + // private to them. Without the chown, `connect()` fails with EACCES and the + // client only sees a silent retry loop. + if (typeof options.ownerUid === 'number') { + chownSync(socketPath, options.ownerUid, options.ownerGid ?? options.ownerUid) + chmodSync(socketPath, 0o600) + } + else { + chmodSync(socketPath, 0o600) + } + } + catch (error) { + log(`could not set socket ownership: ${(error as Error).message}`) + } + + return { + runtime, + async close() { + // Closing the listener alone would wait for clients to hang up first; + // shutdown has to drop the open connections as well. + for (const socket of connections) + socket.destroy() + connections.clear() + await new Promise((resolve) => { + server.close(() => resolve()) + }) + }, + } +} + +/** + * Removes this helper's socket and token, but only if they are still its own. + * + * A newer helper may have taken both over; deleting them then would break a + * session this process never served. + */ +function removeOwnSocket(socketPath: string, token: string): void { + if (process.platform === 'win32') + return + + try { + const tokensDir = dirname(socketPath) + const tokenFile = join(tokensDir, 'helper.token') + const current = existsSync(tokenFile) ? readFileSync(tokenFile, 'utf8').trim() : undefined + if (current === token) + rmSync(tokenFile, { force: true }) + } + catch {} + + try { + rmSync(socketPath, { force: true }) + } + catch {} +} + +/** Sockets left behind by a killed helper cannot be reused; remove them first. */ +export function removeStaleSocket(socketPath: string): void { + if (process.platform === 'win32') + return + try { + rmSync(socketPath, { force: true }) + } + catch {} +} diff --git a/src/privileged/client.ts b/src/privileged/client.ts new file mode 100644 index 0000000..3da2d79 --- /dev/null +++ b/src/privileged/client.ts @@ -0,0 +1,288 @@ +import type { Buffer } from 'node:buffer' +import type { Socket } from 'node:net' +import type { + ClientHandshake, + HelperEvent, + HelperMessage, + HelperRequest, + RpcId, + ServerHandshake, +} from './protocol' +import { connect } from 'node:net' +import process from 'node:process' +import { decodeMessage, serializeMessage, splitLines } from './protocol' + +/** + * Environment variables the privileged helper needs so Caddy behaves like the + * user's own process (PKI location, proxy settings) without `sudo -E`. + */ +export const FORWARDED_ENV_KEYS = [ + 'HOME', + 'USER', + 'LOGNAME', + 'TMPDIR', + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'http_proxy', + 'https_proxy', + 'NO_PROXY', + 'no_proxy', +] as const + +export function collectForwardedEnv(source: NodeJS.ProcessEnv = process.env): Record { + const result: Record = {} + for (const key of FORWARDED_ENV_KEYS) { + const value = source[key] + if (typeof value === 'string' && value !== '') + result[key] = value + } + return result +} + +export interface HelperResults { + 'ping': { pid: number } + 'hosts.add': void + 'hosts.remove': void + 'port.free': { killed: number[] } + /** `started: false` means Caddy was already running and was reused. */ + 'caddy.start': { pid?: number, started?: boolean } + 'caddy.stop': void + 'ca.trust': { trusted: boolean, output: string } +} + +export class HelperConnectionError extends Error { + constructor(message: string) { + super(message) + this.name = 'HelperConnectionError' + } +} + +export interface PrivilegedClient { + readonly connected: boolean + call: (method: M, ...params: HelperParams) => Promise + onEvent: (listener: (event: HelperEvent) => void) => () => void + close: () => Promise +} + +type HelperParams + = M extends 'hosts.add' | 'hosts.remove' ? [params: { ip: string, host: string }] + : M extends 'port.free' ? [params: { port: number }] + : [] + +interface PendingCall { + resolve: (value: unknown) => void + reject: (error: Error) => void + timer: ReturnType +} + +export interface ConnectOptions { + socketPath: string + token: string + /** Total time to wait for the helper to accept the connection. */ + timeoutMs?: number + retryIntervalMs?: number + callTimeoutMs?: number +} + +function openSocket(socketPath: string): Promise { + return new Promise((resolve, reject) => { + const socket = connect(socketPath) + + function onConnect(): void { + socket.removeListener('error', onError) + resolve(socket) + } + + function onError(error: Error): void { + socket.removeListener('connect', onConnect) + reject(error) + } + + socket.once('error', onError) + socket.once('connect', onConnect) + }) +} + +/** + * Connects to an already-running helper and performs the token handshake. + * Retries because the helper may still be starting while sudo asks for a password. + */ +export async function connectClient(options: ConnectOptions): Promise { + const { + socketPath, + token, + timeoutMs = 120_000, + retryIntervalMs = 100, + callTimeoutMs = 30_000, + } = options + + const deadline = Date.now() + timeoutMs + let lastError: Error | undefined + + while (Date.now() < deadline) { + try { + return await handshake(socketPath, token, callTimeoutMs) + } + catch (error) { + lastError = error as Error + + // A permission or protocol error will not resolve by waiting, and + // retrying hides the real cause behind a silent hang. + const code = (lastError as NodeJS.ErrnoException).code + if (code === 'EACCES' || lastError instanceof HelperConnectionError) + throw lastError + + await new Promise(resolve => setTimeout(resolve, retryIntervalMs)) + } + } + + throw new HelperConnectionError( + `privileged helper is not reachable at ${socketPath}: ${lastError?.message ?? 'timed out'}`, + ) +} + +function handshake(socketPath: string, token: string, callTimeoutMs: number): Promise { + return new Promise((resolve, reject) => { + void openSocket(socketPath).then((socket) => { + let buffer = '' + let settled = false + + const fail = (error: Error): void => { + if (settled) + return + settled = true + socket.destroy() + reject(error) + } + + const onData = (chunk: Buffer): void => { + if (settled) + return + + const split = splitLines(buffer, chunk.toString('utf8')) + buffer = split.rest + const line = split.lines[0] + if (line === undefined) + return + + let hello: ServerHandshake + try { + hello = JSON.parse(line) as ServerHandshake + } + catch (error) { + return fail(new HelperConnectionError(`invalid handshake reply: ${(error as Error).message}`)) + } + + if (hello.type !== 'handshake') + return fail(new HelperConnectionError('helper did not answer the handshake')) + + if (!hello.ok) + return fail(new HelperConnectionError(hello.reason ?? 'helper rejected the handshake')) + + settled = true + socket.removeListener('data', onData) + socket.removeListener('error', onError) + const remaining = split.lines.slice(1) + resolve(createClient(socket, [split.rest, ...remaining].filter(Boolean).join('\n'), callTimeoutMs)) + } + + function onError(error: Error): void { + fail(error) + } + + socket.on('data', onData) + socket.on('error', onError) + + const greeting: ClientHandshake = { type: 'handshake', token } + socket.write(serializeMessage(greeting)) + }, reject) + }) +} + +function createClient(socket: Socket, initialBuffer: string, callTimeoutMs: number): PrivilegedClient { + let buffer = initialBuffer + let connected = true + let nextId: RpcId = 1 + + const pending = new Map() + const listeners = new Set<(event: HelperEvent) => void>() + + socket.on('data', (chunk: Buffer) => { + const split = splitLines(buffer, chunk.toString('utf8')) + buffer = split.rest + + for (const line of split.lines) { + const decoded = decodeMessage(line) + if (!decoded.ok) + continue + + const message: HelperMessage = decoded.message + if (!('id' in message)) { + for (const listener of listeners) + listener(message) + continue + } + + const entry = pending.get(message.id) + if (!entry) + continue + + pending.delete(message.id) + clearTimeout(entry.timer) + + if (message.ok) + entry.resolve(message.result) + else + entry.reject(new Error(message.error.message)) + } + }) + + const disconnect = (): void => { + if (!connected) + return + connected = false + for (const [id, entry] of pending) { + clearTimeout(entry.timer) + pending.delete(id) + entry.reject(new HelperConnectionError('privileged helper connection closed')) + } + } + + socket.on('close', disconnect) + socket.on('error', disconnect) + + function call(method: keyof HelperResults, params?: unknown): Promise { + if (!connected) + return Promise.reject(new HelperConnectionError('privileged helper is not connected')) + + const id = nextId++ + const request = { id, method, params } as unknown as HelperRequest + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(id) + reject(new HelperConnectionError(`privileged helper did not answer "${method}" in time`)) + }, callTimeoutMs) + + pending.set(id, { resolve, reject, timer }) + socket.write(serializeMessage(request)) + }) + } + + return { + get connected() { + return connected + }, + call: call as PrivilegedClient['call'], + onEvent(listener) { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + }, + async close() { + disconnect() + socket.destroy() + }, + } +} diff --git a/src/privileged/entry.ts b/src/privileged/entry.ts new file mode 100644 index 0000000..9da1011 --- /dev/null +++ b/src/privileged/entry.ts @@ -0,0 +1,73 @@ +import { existsSync } from 'node:fs' +import { dirname, join } from 'node:path' +import process from 'node:process' + +export interface HelperEntry { + /** + * Executable started through sudo. This is the node runtime, so the caller + * only has to append `args` and the helper's own flags. + */ + entry: string + /** Script (and loader) arguments that follow the runtime. */ + args: string[] +} + +export interface ResolveHelperEntryOptions { + /** Directory of the running bundle: `dist` in published builds, `src` from source. */ + moduleDir: string + /** Node executable used to run a compiled helper. */ + nodeBin?: string + exists?: (path: string) => boolean +} + +/** + * Locates the helper entry next to the running module. + * + * Published builds ship a compiled `privileged-helper.js` and start it with + * node; running from the repository uses `tsx` so the TypeScript source works + * without a build step. + * + * The running module sits in a subdirectory (`src/caddy`, `src/privileged`), + * while the helper lives one level up in `src`, so both lookups walk up instead + * of assuming a fixed depth. + */ +export function resolveHelperEntry(options: ResolveHelperEntryOptions): HelperEntry { + const { moduleDir, nodeBin = process.execPath, exists = existsSync } = options + + for (const name of ['privileged-helper.js', 'privileged-helper.cjs', 'privileged-helper.mjs']) { + const candidate = findUp(moduleDir, name, exists) + if (candidate) + return { entry: nodeBin, args: [candidate] } + } + + const source = findUp(moduleDir, 'privileged-helper.ts', exists) + const tsxCli = findUp(moduleDir, 'node_modules/tsx/dist/cli.mjs', exists) + if (source && tsxCli) + return { entry: nodeBin, args: [tsxCli, source] } + + throw new Error(`privileged helper entry not found in ${moduleDir}. Build the package first.`) +} + +/** + * Walks up from `from` looking for a relative path. + * + * Stops at the filesystem root so a helper outside the project is never picked + * up by accident. + */ +function findUp( + from: string, + relative: string, + exists: (path: string) => boolean, +): string | undefined { + let current = from + while (true) { + const candidate = join(current, relative) + if (exists(candidate)) + return candidate + + const parent = dirname(current) + if (parent === current) + return undefined + current = parent + } +} diff --git a/src/privileged/hosts-file.ts b/src/privileged/hosts-file.ts new file mode 100644 index 0000000..d391a27 --- /dev/null +++ b/src/privileged/hosts-file.ts @@ -0,0 +1,142 @@ +import { isIP } from 'node:net' + +/** + * `/etc/hosts` edits are modelled as pure string transformations so the helper + * can be tested without touching the real file. + * + * `/etc/hosts` is user data: a line may map several names at once + * (`127.0.0.1 a.test b.test`). Editing one name must never drop the others, and + * entries this plugin did not write must survive every operation. + */ + +export const HOSTS_MARKER = '# uhrp' +export const HOSTS_MARKER_PATTERN = /#\s*uhrp\b/ + +export interface HostsEdit { + content: string + changed: boolean +} + +interface ParsedLine { + ip: string + hosts: string[] +} + +function detectEol(content: string): string { + return content.includes('\r\n') ? '\r\n' : '\n' +} + +function toBodyLines(content: string): string[] { + const lines = content.split(/\r?\n/) + if (lines.length > 0 && lines[lines.length - 1] === '') + lines.pop() + return lines +} + +function parseLine(line: string): ParsedLine | undefined { + const withoutComment = line.replace(/#.*/, '').trim() + if (!withoutComment) + return undefined + const [ip, ...hosts] = withoutComment.split(/\s+/) + if (!ip || hosts.length === 0) + return undefined + return { ip, hosts } +} + +function isOwned(line: string): boolean { + return HOSTS_MARKER_PATTERN.test(line) +} + +function mentionsHost(entry: ParsedLine, host: string): boolean { + return entry.hosts.some(candidate => candidate.toLowerCase() === host.toLowerCase()) +} + +function sameIpFamily(left: string, right: string): boolean { + const leftVersion = isIP(left) + return leftVersion !== 0 && leftVersion === isIP(right) +} + +function rebuild(lines: string[], original: string, eol: string): HostsEdit { + const content = lines.length > 0 ? lines.join(eol) + eol : '' + return { content, changed: content !== original } +} + +/** + * Drops `host` from its mapping and appends a marked entry for it. + * + * A user-written line that also maps other names is rewritten without `host` + * instead of being deleted, so `127.0.0.1 a.test b.test` keeps `b.test`. + */ +export function addHost(content: string, ip: string, host: string): HostsEdit { + const eol = detectEol(content) + const lines: string[] = [] + + for (const line of toBodyLines(content)) { + const entry = parseLine(line) + if (!entry || !mentionsHost(entry, host) || !sameIpFamily(entry.ip, ip)) { + lines.push(line) + continue + } + + const remaining = entry.hosts.filter(candidate => candidate.toLowerCase() !== host.toLowerCase()) + if (remaining.length === 0) + continue + + // Preserve the original comment when one exists so a user's annotation is + // not silently dropped by our edit. + const comment = line.match(/#.*/)?.[0] ?? '' + const rebuilt = `${entry.ip} ${remaining.join(' ')}${comment ? ` ${comment}` : ''}` + lines.push(rebuilt) + } + + lines.push(`${ip} ${host} ${HOSTS_MARKER}`) + return rebuild(lines, content, eol) +} + +/** + * Removes `host` from the mapping to `ip`. Only lines this plugin wrote are + * removed outright; a user's line keeps its other names. + */ +export function removeHost(content: string, ip: string, host: string): HostsEdit { + const eol = detectEol(content) + const lines: string[] = [] + + for (const line of toBodyLines(content)) { + const entry = parseLine(line) + if (!entry || entry.ip !== ip || !mentionsHost(entry, host)) { + lines.push(line) + continue + } + + const remaining = entry.hosts.filter(candidate => candidate.toLowerCase() !== host.toLowerCase()) + if (remaining.length === 0) + continue + + const comment = line.match(/#.*/)?.[0] ?? '' + lines.push(`${entry.ip} ${remaining.join(' ')}${comment ? ` ${comment}` : ''}`) + } + + return rebuild(lines, content, eol) +} + +/** Drops every entry this plugin wrote, used to clean up after a crashed session. */ +export function removeMarkedHosts(content: string): HostsEdit { + const eol = detectEol(content) + const lines = toBodyLines(content).filter(line => !isOwned(line)) + return rebuild(lines, content, eol) +} + +/** Hosts entries this plugin owns, without the marker. */ +export function listMarkedHosts(content: string): Array<{ ip: string, host: string }> { + const result: Array<{ ip: string, host: string }> = [] + for (const line of toBodyLines(content)) { + if (!isOwned(line)) + continue + const entry = parseLine(line) + if (!entry) + continue + for (const host of entry.hosts) + result.push({ ip: entry.ip, host }) + } + return result +} diff --git a/src/privileged/launch.ts b/src/privileged/launch.ts new file mode 100644 index 0000000..7fd3dea --- /dev/null +++ b/src/privileged/launch.ts @@ -0,0 +1,332 @@ +import type { Buffer } from 'node:buffer' +import type { PrivilegedClient } from './client' +import { spawn, spawnSync } from 'node:child_process' +import { randomBytes } from 'node:crypto' +import { platform } from 'node:os' +import process from 'node:process' +import { collectForwardedEnv, connectClient } from './client' +import { spawnElevatedOnWindows } from './windows-elevation' + +export function createHelperToken(): string { + return randomBytes(24).toString('hex') +} + +export interface LaunchOptions { + socketPath: string + token: string + /** Executable started as root (node, or tsx when running from source). */ + helperEntry: string + /** Arguments placed before the helper's own `--socket/--token` flags. */ + helperArgs?: string[] + sudoCommand?: string + /** Skips the interactive credential check; used by tests and Windows. */ + skipAuth?: boolean + /** + * Starts the helper as the current user instead of going through sudo. + * Used by tests that need the helper protocol but not real privileges. + */ + direct?: boolean + env?: NodeJS.ProcessEnv + log?: (message: string) => void + /** How long to wait for the helper socket to accept the connection. */ + connectTimeoutMs?: number + /** + * Caddy's storage directory. Caddy runs as root in the helper, so the helper + * hands this tree back to the developer when it stops Caddy. + */ + caddyStoragePath?: string + /** Caddy binary the helper is allowed to run, fixed for the whole session. */ + caddyBin: string + /** Caddyfile the helper is allowed to load. */ + caddyConfigPath: string + /** Admin API socket the helper talks to when installing the CA. */ + caddyAdminSocket?: string + /** Shown before the password prompt so the request is not a surprise. */ + reason?: { target: string, https: boolean } +} + +/** + * Explains why a password is about to be requested. Without this the prompt + * appears out of nowhere in the middle of `npm run dev` and looks like something + * went wrong. + */ +export function describePasswordReason(target: string, https: boolean): string[] { + const port = https ? '443' : '80' + return [ + `reverse proxy needs administrator permission for ${target}`, + ` · bind port ${port}${https ? ` for https://${target}` : ''}`, + ` · add ${target} to the hosts file`, + '', + 'This is the only prompt; the rest of the dev server keeps running as you.', + 'Press Ctrl+C at the prompt to skip the proxy and keep the dev server.', + ] +} + +/** + * Asks for the administrator password once, up front, on the dev terminal. + * + * The credential cache is keyed by terminal, so a later `sudo -n` only finds + * this entry when the child stays in the same session. That is why the helper + * is spawned without `detached`. + * + * Returns `denied` when the user dismissed the prompt, so the caller can keep + * the dev server running instead of treating it as a crash. + */ +export async function authenticateSudo( + sudoCommand = 'sudo', + options: { target?: string, https?: boolean, quiet?: boolean } = {}, +): Promise<{ ok: boolean, denied?: boolean, message?: string }> { + if (platform() === 'win32') + return { ok: true } + + if (spawnSync(sudoCommand, ['-n', 'true'], { stdio: 'ignore' }).status === 0) + return { ok: true } + + // Tests drive this without a terminal; writing the notice there would print + // password instructions into the middle of the test output. + if (options.target && !options.quiet) { + for (const line of describePasswordReason(options.target, options.https ?? false)) + process.stderr.write(`${line}\n`) + process.stderr.write('\n') + } + + // `sudo -v` inherits the terminal, so Ctrl+C reaches this process too. Without + // a handler the default action kills the dev server, which reads as "the + // proxy crashed my dev server" when the user only meant to skip it. + let interrupted = false + const onSigint = (): void => { + interrupted = true + } + process.on('SIGINT', onSigint) + + try { + const { status, signal } = await runPrompt(sudoCommand) + if (status === 0) + return { ok: true } + + // `sudo` exits 1 on a wrong password and dies by the signal on Ctrl+C. + // Both are checked: a real Ctrl+C reaches this process and the child + // together, while a signal aimed at the child alone only shows up here. + const cancelled = interrupted || signal === 'SIGINT' + return { + ok: false, + denied: true, + message: cancelled + ? 'administrator permission was cancelled' + : 'administrator permission is required to edit the hosts file and bind ports 80/443', + } + } + finally { + process.off('SIGINT', onSigint) + } +} + +/** + * Runs `sudo -v` with the terminal attached. + * + * Spawned asynchronously rather than with `spawnSync`: the synchronous form + * blocks the event loop, so the SIGINT handler above never runs and the exit + * looks like a plain failure instead of a cancellation. + * + * The terminal is put back into cooked mode first. Vite turns on raw mode for + * its `h + enter` shortcuts, and in that mode Enter no longer terminates the + * line sudo is reading, so the password appears to need a second Enter. + */ +function runPrompt(sudoCommand: string): Promise<{ status: number | null, signal: NodeJS.Signals | null }> { + return new Promise((resolve) => { + const restore = takeBackTerminal() + const child = spawn(sudoCommand, ['-v'], { stdio: 'inherit' }) + + const finish = (result: { status: number | null, signal: NodeJS.Signals | null }): void => { + restore() + resolve(result) + } + + child.on('error', () => finish({ status: 1, signal: null })) + child.on('exit', (status, signal) => finish({ status, signal })) + }) +} + +/** + * Gives the terminal to `sudo` for the duration of the prompt. + * + * Vite keeps stdin in raw mode with a `data` listener attached, so that every + * keystroke reaches its shortcut handler. Two things break the password read: + * + * 1. Raw mode does no line editing, so Enter never terminates the line sudo is + * waiting for. + * 2. The `data` listener is resumed, so it consumes the characters before sudo + * can see them. sudo then reads nothing and reports a wrong password. + * + * Both are undone here and restored afterwards, so Vite's shortcuts keep + * working once the prompt is over. + */ +function takeBackTerminal(): () => void { + const stdin = process.stdin + if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') + return () => {} + + const wasRaw = stdin.isRaw === true + + // `pause` detaches the stream from the tty without removing Vite's listener, + // so the same handler still works when the stream is resumed later. + stdin.pause() + + try { + stdin.setRawMode(false) + } + catch { + stdin.resume() + return () => {} + } + + return () => { + if (wasRaw) { + try { + stdin.setRawMode(true) + } + catch {} + } + // Resume last: Vite's listener is still attached and expects a live stream. + stdin.resume() + } +} + +/** The user dismissed the password prompt; this is a choice, not a failure. */ +export class PrivilegeDeniedError extends Error { + constructor(message: string) { + super(message) + this.name = 'PrivilegeDeniedError' + } +} + +export interface PrivilegedHelperHandle { + client: PrivilegedClient + close: () => Promise +} + +/** + * Starts the privileged helper and connects to it. + * + * Only this child runs as root: the Node process that owns the dev server, + * its caches and `node_modules` keeps the developer's identity. The user's + * proxy/HOME settings reach Caddy through the helper's environment instead of + * `sudo -E`. + */ +export async function startPrivilegedHelper(options: LaunchOptions): Promise { + const { + socketPath, + token, + helperEntry, + helperArgs = [], + sudoCommand = 'sudo', + skipAuth = false, + direct = false, + env = process.env, + log, + connectTimeoutMs = 120_000, + } = options + + if (!skipAuth && !direct) { + const auth = await authenticateSudo(sudoCommand, options.reason) + if (!auth.ok) { + const message = auth.message ?? 'administrator permission is required' + throw auth.denied ? new PrivilegeDeniedError(message) : new Error(message) + } + } + + const forwarded = collectForwardedEnv(env) + const forwardedArgs = Object.entries(forwarded).map(([key, value]) => `${key}=${value}`) + // `helperEntry` is the runtime and `helperArgs` the script it must run, so + // the helper's own flags always come last. The owner ids travel with them + // because the helper only learns who its client is at handshake time. + const childArgv = [ + ...helperArgs, + '--socket', + socketPath, + '--token', + token, + ...(typeof process.getuid === 'function' ? ['--owner-uid', String(process.getuid())] : []), + ...(typeof process.getgid === 'function' ? ['--owner-gid', String(process.getgid())] : []), + ...(options.caddyStoragePath ? ['--caddy-storage', options.caddyStoragePath] : []), + '--caddy', + options.caddyBin, + '--caddy-config', + options.caddyConfigPath, + ...(options.caddyAdminSocket ? ['--caddy-admin', options.caddyAdminSocket] : []), + ] + + let child: ReturnType | undefined + + if (!direct && platform() === 'win32') { + // UAC: the elevated helper is a separate process and cannot inherit stdio, + // so it reports through the named pipe instead. The user's environment is + // recovered by the helper from its own process env, which Windows keeps. + await spawnElevatedOnWindows({ file: helperEntry, args: childArgv, cwd: process.cwd() }) + } + else { + const command = direct ? helperEntry : sudoCommand + const spawnArgs = direct + ? childArgv + : ['-n', 'env', ...forwardedArgs, helperEntry, ...childArgv] + + // No `detached`: sudo stores its credential per session, and `setsid()` + // would make the helper look like a different login, so `sudo -n` could not + // reuse the password entered a moment earlier. The stdio pipes keep the + // helper reachable from this process. + child = spawn(command, spawnArgs, { + stdio: ['ignore', 'pipe', 'pipe'], + }) + + child.stdout?.on('data', (data: Buffer) => log?.(data.toString().trimEnd())) + child.stderr?.on('data', (data: Buffer) => log?.(data.toString().trimEnd())) + + const exited = new Promise(resolve => child!.once('exit', code => resolve(code))) + + // Refused credentials make the helper exit immediately; report that instead + // of waiting for the full connection timeout. + const earlyExit = await Promise.race([ + exited, + new Promise(resolve => setTimeout(() => resolve(undefined), 500)), + ]) + + if (earlyExit !== undefined && earlyExit !== 0) { + throw new Error( + `privileged helper exited with code ${earlyExit}. ` + + 'Administrator permission was refused, or sudo is not available.', + ) + } + } + + try { + const client = await connectClient({ socketPath, token, timeoutMs: connectTimeoutMs }) + return { + client, + async close() { + await client.close().catch(() => {}) + child?.unref() + }, + } + } + catch (error) { + try { + child?.kill() + } + catch {} + throw error + } +} + +/** Reuses a helper that is already running for this runtime directory. */ +export async function connectExistingHelper( + socketPath: string, + token: string, + timeoutMs = 1500, +): Promise { + try { + return await connectClient({ socketPath, token, timeoutMs }) + } + catch { + return undefined + } +} diff --git a/src/privileged/path-guard.ts b/src/privileged/path-guard.ts new file mode 100644 index 0000000..b338b21 --- /dev/null +++ b/src/privileged/path-guard.ts @@ -0,0 +1,74 @@ +import type { Stats } from 'node:fs' +import { lstatSync } from 'node:fs' + +/** + * Why a path is refused before the helper runs it. + * + * The helper runs as root while the paths come from a process running as the + * developer. Anything that process, or any other local user, can rewrite after + * the check would become root-executed code or root-loaded config, so the + * checks below run once at startup and the result is trusted for the session. + */ +export interface PathRejection { + path: string + reason: string +} + +export interface PathCheckOptions { + /** + * uid allowed to own the path besides root. + * + * The helper entry itself lives in the developer's `node_modules`, so the + * invoking user has to be accepted for the paths that came from there. + */ + ownerUid?: number + /** Human-readable role, used in the rejection message. */ + label: string +} + +function describe(stats: Stats): string { + return `uid=${stats.uid} mode=${(stats.mode & 0o777).toString(8)}` +} + +/** + * Rejects a path the helper must not execute or load. + * + * Symbolic links are refused outright: the check has to describe the file that + * will actually be used, and a link can be repointed after the check. Group- or + * world-writable files are refused because any local user could replace them + * between the check and the exec. + */ +export function checkPrivilegedPath( + path: string, + options: PathCheckOptions, +): PathRejection | undefined { + let stats: Stats + try { + stats = lstatSync(path) + } + catch (error) { + return { path, reason: `${options.label} is not readable: ${(error as Error).message}` } + } + + if (stats.isSymbolicLink()) + return { path, reason: `${options.label} is a symbolic link (${describe(stats)})` } + + if (!stats.isFile() && !stats.isDirectory()) + return { path, reason: `${options.label} is neither a file nor a directory` } + + const allowedOwner = stats.uid === 0 || (options.ownerUid !== undefined && stats.uid === options.ownerUid) + if (!allowedOwner) + return { path, reason: `${options.label} is owned by another user (${describe(stats)})` } + + // Root's own files are fine at 644/755; the point is that no one else can + // write them. + if (stats.mode & 0o022) + return { path, reason: `${options.label} is group- or world-writable (${describe(stats)})` } + + return undefined +} + +/** Formats a rejection for the helper log; one place keeps the wording stable. */ +export function describeRejection(rejection: PathRejection): string { + return `refusing to start: ${rejection.reason} (${rejection.path})` +} diff --git a/src/privileged/ports.ts b/src/privileged/ports.ts new file mode 100644 index 0000000..7b96085 --- /dev/null +++ b/src/privileged/ports.ts @@ -0,0 +1,45 @@ +/** + * Helpers for freeing a listening TCP port without shelling out to `npx`. + * `parseListeningPids` keeps the decision testable: it only turns command + * output into pids, never spawns anything. + */ + +/** Parses `lsof -ti: -sTCP:LISTEN` output (one pid per line). */ +export function parseListeningPids(output: string): number[] { + const pids = new Set() + for (const line of output.split(/\r?\n/)) { + const trimmed = line.trim() + if (!trimmed) + continue + if (!/^\d+$/.test(trimmed)) + continue + const pid = Number(trimmed) + if (Number.isSafeInteger(pid) && pid > 0) + pids.add(pid) + } + return [...pids] +} + +/** Parses `netstat -ano` output for the owning pids of a listening port on Windows. */ +export function parseWindowsListeningPids(output: string, port: number): number[] { + const pids = new Set() + const suffix = `:${port}` + + for (const line of output.split(/\r?\n/)) { + const columns = line.trim().split(/\s+/) + if (columns.length < 5) + continue + const protocol = columns[0].toUpperCase() + if (protocol !== 'TCP') + continue + if (!columns[1].endsWith(suffix)) + continue + if (columns[3].toUpperCase() !== 'LISTENING') + continue + const pid = Number(columns[4]) + if (Number.isSafeInteger(pid) && pid > 0) + pids.add(pid) + } + + return [...pids] +} diff --git a/src/privileged/protocol.ts b/src/privileged/protocol.ts new file mode 100644 index 0000000..09200d4 --- /dev/null +++ b/src/privileged/protocol.ts @@ -0,0 +1,111 @@ +/** + * Wire protocol between the unprivileged plugin process and the privileged + * helper. Newline-delimited JSON keeps the helper dependency-free. + * + * The helper never executes a binary or uses a path supplied by the client: + * both are fixed when the helper starts (`--caddy` and `--caddy-config`), so a + * client that reaches the socket cannot turn the helper into a root shell. + */ + +export type RpcId = number + +export interface RpcErrorPayload { + message: string + code?: string +} + +export type HelperRequest = + | { id: RpcId, method: 'ping' } + | { id: RpcId, method: 'hosts.add', params: { ip: string, host: string } } + | { id: RpcId, method: 'hosts.remove', params: { ip: string, host: string } } + | { id: RpcId, method: 'port.free', params: { port: number } } + | { id: RpcId, method: 'caddy.start' } + | { id: RpcId, method: 'caddy.stop' } + | { id: RpcId, method: 'ca.trust' } + +/** Request method names, used by the client's typed `call` overloads. */ +export type HelperMethod = HelperRequest['method'] + +export type HelperResponse = + | { id: RpcId, ok: true, result?: unknown } + | { id: RpcId, ok: false, error: RpcErrorPayload } + +export type HelperEvent = + | { type: 'event', kind: 'caddy.stdout', data: string } + | { type: 'event', kind: 'caddy.stderr', data: string } + | { type: 'event', kind: 'caddy.exit', code: number | null, signal: string | null } + +export type HelperMessage = HelperResponse | HelperEvent + +export interface ClientHandshake { + type: 'handshake' + token: string +} + +export interface ServerHandshake { + type: 'handshake' + ok: boolean + pid?: number + reason?: string +} + +export type DecodedMessage = + | { ok: true, message: HelperMessage } + | { ok: false, error: string } + +export function serializeMessage(message: unknown): string { + return `${JSON.stringify(message)}\n` +} + +/** Splits a stream into complete lines, keeping the trailing partial line. */ +export function splitLines(buffer: string, chunk: string): { lines: string[], rest: string } { + const parts = (buffer + chunk).split('\n') + const rest = parts.pop() ?? '' + return { lines: parts.filter(line => line.trim() !== ''), rest } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +export function decodeMessage(line: string): DecodedMessage { + let parsed: unknown + try { + parsed = JSON.parse(line) + } + catch (error) { + return { ok: false, error: `invalid JSON: ${(error as Error).message}` } + } + + if (!isRecord(parsed)) + return { ok: false, error: 'message must be an object' } + + if (parsed.type === 'event') { + if (typeof parsed.kind !== 'string') + return { ok: false, error: 'event.kind must be a string' } + return { ok: true, message: parsed as unknown as HelperEvent } + } + + if (typeof parsed.id !== 'number') + return { ok: false, error: 'response.id must be a number' } + + if (parsed.ok === true) + return { ok: true, message: { id: parsed.id, ok: true, result: parsed.result } } + + if (parsed.ok === false) { + const error = isRecord(parsed.error) ? parsed.error : {} + return { + ok: true, + message: { + id: parsed.id, + ok: false, + error: { + message: typeof error.message === 'string' ? error.message : 'helper request failed', + code: typeof error.code === 'string' ? error.code : undefined, + }, + }, + } + } + + return { ok: false, error: 'response.ok must be a boolean' } +} diff --git a/src/privileged/server.ts b/src/privileged/server.ts new file mode 100644 index 0000000..623e43b --- /dev/null +++ b/src/privileged/server.ts @@ -0,0 +1,578 @@ +import type { Buffer } from 'node:buffer' +import type { Socket } from 'node:net' +import type { HelperEvent, HelperRequest, HelperResponse } from './protocol' +import { execFile, spawn } from 'node:child_process' +import { lchownSync, lstatSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { platform } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' +import { promisify } from 'node:util' +import { addHost, removeHost, removeMarkedHosts } from './hosts-file' +import { parseListeningPids, parseWindowsListeningPids } from './ports' +import { serializeMessage } from './protocol' +import { isAllowedFreePort, isSafeHost, isSafeIp } from './validation' + +const execFileAsync = promisify(execFile) + +/** Runs a command whose non-zero exit is expected, keeping stdout. */ +function execFileQuiet(file: string, args: string[]): Promise<{ stdout: string }> { + return new Promise((resolve) => { + execFile(file, args, (error, stdout) => resolve({ stdout: error && !stdout ? '' : stdout })) + }) +} + +export const HOSTS_PATH = platform() === 'win32' + ? 'C:\\Windows\\System32\\drivers\\etc\\hosts' + : '/etc/hosts' + +/** + * Command that drops the resolver cache so a fresh hosts entry is visible + * immediately. Each platform caches differently, so the command is per-OS. + */ +const FLUSH_COMMANDS: Record> = { + win32: [{ file: 'ipconfig', args: ['/flushdns'] }], + darwin: [{ file: 'killall', args: ['-HUP', 'mDNSResponder'] }], + // systemd-resolved is the common case; the legacy name covers older distros + // and hosts without resolved simply have no cache to drop. + linux: [ + { file: 'resolvectl', args: ['flush-caches'] }, + { file: 'systemd-resolve', args: ['--flush-caches'] }, + ], +} + +const FLUSH_FALLBACK = [{ file: 'killall', args: ['-HUP', 'mDNSResponder'] }] as const + +/** Handle on the Caddy process owned by the helper. */ +export interface CaddyChild { + pid?: number + kill: (signal?: NodeJS.Signals) => boolean + /** False once the process has exited; undefined when unknown. */ + running?: boolean +} + +/** + * Everything privileged the helper can do. + * + * Paths and arguments are fixed at helper start-up; the client only chooses + * when* something happens, never *what* runs. That keeps the privileged + * surface to a handful of primitives even if the socket is reached by an + * unexpected local process. + */ +export interface HelperDeps { + readHosts: () => string + writeHosts: (content: string) => void + flushDns: () => Promise + /** Releases a port; never kills the pid in `keepPid`. */ + freePort: (port: number, keepPid?: number) => Promise + /** Caddy binary and the Caddyfile it must load, both fixed at start-up. */ + caddyBin: string + caddyConfigPath: string + caddyCwd: string + caddyEnv: Record + /** Installs the local root CA using a helper-owned command, not a client path. */ + trustLocalCa: () => Promise<{ trusted: boolean, output: string }> + /** Caddy writes its PKI as root; this tree goes back to the user afterwards. */ + caddyStoragePath?: string + storageOwner?: { uid: number, gid: number } + /** Starts Caddy with the configuration fixed at helper start-up. */ + startCaddy: (emit: (event: HelperEvent) => void) => CaddyChild + stopCaddy: () => Promise + log: (message: string) => void +} + +/** + * Resolver caching only affects how fast a new entry is picked up, so a failed + * flush is logged instead of rolling back a hosts edit that already succeeded. + */ +async function flushDnsBestEffort(deps: HelperDeps): Promise { + try { + await deps.flushDns() + } + catch (error) { + deps.log(`dns flush failed: ${(error as Error).message}`) + } +} + +export interface HelperRuntime { + /** `client` identifies the caller so shared host mappings are reference-counted. */ + handle: (request: HelperRequest, client?: object) => Promise + /** Registers a connected client so Caddy output reaches it. */ + attach: (client: object) => void + /** Last client gone: stop Caddy and drop the hosts entries this plugin wrote. */ + detach: (client: object) => Promise + /** Runs the teardown once, regardless of how many clients were attached. */ + dispose: () => Promise + /** Removes hosts entries a previous crashed session left behind. */ + sweepStaleEntries: () => Promise + /** Number of attached dev servers; a signal must not tear down a live one. */ + clientCount: () => number +} + +/** + * Request router shared by the helper process and its tests. Kept free of + * sockets so tests can drive it against a temp hosts file and a fake Caddy. + */ +export function createHelperRuntime(deps: HelperDeps): HelperRuntime { + const clients = new Set() + let teardown: Promise | undefined + // Bumped whenever a client attaches. A teardown that started before the bump + // belongs to a previous generation and must not touch the new session. + let generation = 0 + let caddy: CaddyChild | undefined + + // Which client asked for which host. Two projects may map the same domain, so + // a mapping is only removed once its last holder is gone. + const hostOwners = new Map>() + + const hostId = (ip: string, host: string): string => `${ip} ${host.toLowerCase()}` + + function addHostOwner(client: object | undefined, ip: string, host: string): void { + const id = hostId(ip, host) + let owners = hostOwners.get(id) + if (!owners) { + owners = new Set() + hostOwners.set(id, owners) + } + owners.add(client ?? deps) + } + + /** True when no other client still needs this mapping. */ + function releaseHostOwner(client: object | undefined, ip: string, host: string): boolean { + const id = hostId(ip, host) + const owners = hostOwners.get(id) + if (!owners) + return true + + owners.delete(client ?? deps) + if (owners.size > 0) + return false + + hostOwners.delete(id) + return true + } + + const broadcast = (event: HelperEvent): void => { + const line = serializeMessage(event) + for (const client of clients) { + const socket = client as Socket + if (typeof socket.write === 'function' && !socket.destroyed) + socket.write(line) + } + } + + async function stopCaddy(): Promise { + const current = caddy + caddy = undefined + if (current) { + try { + current.kill('SIGTERM') + } + catch {} + } + await deps.stopCaddy().catch(() => {}) + + // Give Caddy's files back to the developer: the next start runs as root + // again, but the developer must be able to read the PKI in between. + if (deps.caddyStoragePath && deps.storageOwner) + await reclaimTree(deps.caddyStoragePath, deps.storageOwner.uid, deps.storageOwner.gid).catch(() => {}) + } + + function dispose(): Promise { + if (!teardown) { + const startedAt = generation + teardown = (async () => { + try { + // A client may have attached while this teardown was queued; its + // entries belong to the new session and must survive. + if (generation === startedAt) { + const edited = removeMarkedHosts(deps.readHosts()) + if (edited.changed) { + deps.writeHosts(edited.content) + await deps.flushDns().catch(() => {}) + } + } + } + catch (error) { + deps.log(`hosts cleanup failed: ${(error as Error).message}`) + } + + // The new session owns Caddy now; stopping it here would kill a server + // this generation never started. + if (generation === startedAt) + await stopCaddy() + })() + } + return teardown + } + + async function sweepStaleEntries(): Promise { + try { + const edited = removeMarkedHosts(deps.readHosts()) + if (!edited.changed) + return false + deps.writeHosts(edited.content) + await flushDnsBestEffort(deps) + return true + } + catch (error) { + deps.log(`stale hosts cleanup failed: ${(error as Error).message}`) + return false + } + } + + async function handle(request: HelperRequest, client?: object): Promise { + try { + switch (request.method) { + case 'ping': + return { id: request.id, ok: true, result: { pid: process.pid } } + + case 'hosts.add': { + const { ip, host } = request.params + if (!isSafeIp(ip) || !isSafeHost(host)) + return { id: request.id, ok: false, error: { message: 'invalid hosts entry' } } + + const edited = addHost(deps.readHosts(), ip, host) + if (edited.changed) + deps.writeHosts(edited.content) + addHostOwner(client, ip, host) + await flushDnsBestEffort(deps) + return { id: request.id, ok: true } + } + + case 'hosts.remove': { + const { ip, host } = request.params + if (!isSafeIp(ip) || !isSafeHost(host)) + return { id: request.id, ok: false, error: { message: 'invalid hosts entry' } } + + // Another project may still need this domain; only the last holder + // removes the mapping. + const last = releaseHostOwner(client, ip, host) + if (!last) + return { id: request.id, ok: true, result: { kept: true } } + + const edited = removeHost(deps.readHosts(), ip, host) + if (edited.changed) + deps.writeHosts(edited.content) + await flushDnsBestEffort(deps) + return { id: request.id, ok: true } + } + + case 'port.free': { + const { port } = request.params + if (!isAllowedFreePort(port)) + return { id: request.id, ok: false, error: { message: `port ${port} is not releasable` } } + + // Caddy belongs to this helper, so a port sweep must never kill it: + // the pid is the one serving every other project's domains. + const killed = await deps.freePort(port, caddy?.pid) + return { id: request.id, ok: true, result: { killed } } + } + + case 'caddy.start': { + // Idempotent: several dev servers share one Caddy, so a second start + // must attach to the running process instead of restarting it. + if (caddy && caddy.running !== false) + return { id: request.id, ok: true, result: { pid: caddy.pid, started: false } } + + caddy = deps.startCaddy(broadcast) + return { id: request.id, ok: true, result: { pid: caddy.pid, started: true } } + } + + case 'caddy.stop': { + await stopCaddy() + return { id: request.id, ok: true } + } + + case 'ca.trust': { + const result = await deps.trustLocalCa() + return { id: request.id, ok: true, result } + } + + default: + return { id: (request as HelperRequest).id, ok: false, error: { message: 'unknown method' } } + } + } + catch (error) { + return { id: request.id, ok: false, error: { message: (error as Error).message } } + } + } + + return { + handle, + attach(client) { + clients.add(client) + // Only the newest generation may clean up; an in-flight teardown started + // before this call is abandoned rather than cancelled mid-write. + generation++ + teardown = undefined + }, + async detach(client) { + clients.delete(client) + + // Drop only the mappings this client was the last holder of, so a project + // that exits leaves the domains another project still serves alone. + for (const [id, owners] of [...hostOwners]) { + if (!owners.delete(client) || owners.size > 0) + continue + + hostOwners.delete(id) + const [ip, host] = id.split(' ') + try { + const edited = removeHost(deps.readHosts(), ip, host) + if (edited.changed) { + deps.writeHosts(edited.content) + await flushDnsBestEffort(deps) + } + } + catch (error) { + deps.log(`hosts cleanup failed: ${(error as Error).message}`) + } + } + + if (clients.size === 0) + await dispose() + }, + dispose, + sweepStaleEntries, + clientCount: () => clients.size, + } +} + +export interface NodeHelperDepsOptions { + hostsPath?: string + caddyBin: string + caddyConfigPath: string + /** Admin API socket; `ca.trust` talks to it instead of the TCP default. */ + caddyAdminSocket?: string + caddyCwd?: string + caddyEnv?: Record + /** Caddy storage directory; handed back to the user when Caddy stops. */ + caddyStoragePath?: string + /** Owner that receives the storage directory after Caddy stops. */ + storageOwner?: { uid: number, gid: number } + log?: (message: string) => void +} + +/** Best-effort recursive chown, skipping anything it cannot read. */ +async function reclaimTree(target: string, uid: number, gid: number): Promise { + const reclaimed: string[] = [] + + const walk = (path: string): void => { + let stats + try { + // `lstat`: a symlink must never redirect a root chown to its target. + stats = lstatSync(path) + } + catch { + return + } + + try { + lchownSync(path, uid, gid) + reclaimed.push(path) + } + catch {} + + if (!stats.isDirectory()) + return + + let entries: string[] + try { + entries = readdirSync(path) + } + catch { + return + } + + for (const entry of entries) + walk(join(path, entry)) + } + + walk(target) + return reclaimed +} + +/** Installs the Caddy root CA using helper-owned commands. */ +async function trustLocalCaWithSystemTools( + caddyBin: string, + adminSocket: string | undefined, +): Promise<{ trusted: boolean, output: string }> { + // `caddy trust` reads the CA through the admin API, which only exists while + // Caddy is serving; the plugin calls this after Caddy is up. The admin API is + // a unix socket when the template enabled one, so the address follows it + // instead of the old TCP default. + const address = adminSocket ? `unix/${adminSocket}` : '127.0.0.1:2019' + try { + const { stdout, stderr } = await execFileAsync( + caddyBin, + ['trust', '--ca', 'local', '--address', address], + { timeout: 120_000 }, + ) + return { trusted: true, output: `${stdout}${stderr}`.trim() } + } + catch (error) { + const output = String((error as { stdout?: string }).stdout ?? '') + String((error as { stderr?: string }).stderr ?? '') + if (/already|not modified|no changes/i.test(output)) + return { trusted: true, output: output.trim() } + throw new Error(output.trim() || (error as Error).message) + } +} + +/** Production wiring for the helper process. */ +export function createNodeHelperDeps(options: NodeHelperDepsOptions): HelperDeps { + const hostsPath = options.hostsPath ?? HOSTS_PATH + const log = options.log ?? ((message: string) => process.stdout.write(`${message}\n`)) + let caddy: CaddyChild | undefined + + // Caddy runs in its own session, so it no longer dies with this process. The + // `exit` hook is the last chance to take it down; it must stay synchronous, + // which `kill` is. Nothing runs after SIGKILL, and that case is covered by + // the next start freeing the port. + process.on('exit', () => { + if (caddy?.pid === undefined) + return + try { + process.kill(caddy.pid, 'SIGTERM') + } + catch {} + }) + + return { + readHosts: () => readFileSync(hostsPath, 'utf8'), + writeHosts: (content) => { + writeFileSync(hostsPath, content) + }, + flushDns: async () => { + const candidates = FLUSH_COMMANDS[platform()] ?? [...FLUSH_FALLBACK] + let lastError: Error | undefined + + for (const candidate of candidates) { + try { + await execFileAsync(candidate.file, candidate.args) + return + } + catch (error) { + lastError = error as Error + } + } + + throw lastError ?? new Error('no DNS cache flush command for this platform') + }, + freePort: async (port, keepPid) => { + // `lsof` exits with 1 when nothing matches, which is the normal case for + // a free port; only its stdout carries the answer. + const pids = platform() === 'win32' + ? parseWindowsListeningPids((await execFileAsync('netstat', ['-ano'])).stdout, port) + : parseListeningPids((await execFileQuiet('lsof', [`-ti:${port}`, '-sTCP:LISTEN'])).stdout) + + const killed: number[] = [] + for (const pid of pids) { + // Never kill the helper itself or the Caddy it owns: that Caddy serves + // every project sharing this helper, not just the caller's. + if (pid === process.pid || (keepPid !== undefined && pid === keepPid)) + continue + try { + process.kill(pid, 'SIGTERM') + killed.push(pid) + } + catch {} + } + return killed + }, + caddyBin: options.caddyBin, + caddyConfigPath: options.caddyConfigPath, + caddyCwd: options.caddyCwd ?? process.cwd(), + caddyEnv: options.caddyEnv ?? {}, + trustLocalCa: () => trustLocalCaWithSystemTools(options.caddyBin, options.caddyAdminSocket), + caddyStoragePath: options.caddyStoragePath, + storageOwner: options.storageOwner, + startCaddy: (emit) => { + // `--watch` makes Caddy reload when another project rewrites the shared + // Caddyfile. Without it the second project's route never takes effect. + // + // `detached` puts Caddy in its own session. It shares one Caddy between + // projects, so the Ctrl+C that stops one dev server must not reach it: + // the whole foreground process group gets SIGINT, and Caddy exits on + // SIGINT. `kill()` still targets the pid, and the exit event still fires. + // Windows has no process groups to escape and `detached` would open a + // console window there, so it stays off. + const child = spawn(options.caddyBin, ['run', '--adapter', 'caddyfile', '--config', options.caddyConfigPath, '--watch'], { + cwd: options.caddyCwd ?? process.cwd(), + env: { ...process.env, ...options.caddyEnv }, + stdio: ['ignore', 'pipe', 'pipe'], + detached: platform() !== 'win32', + }) + const handle: CaddyChild & { running?: boolean } = { + pid: child.pid, + kill: signal => child.kill(signal), + running: true, + } + child.stdout?.on('data', (data: Buffer) => emit({ type: 'event', kind: 'caddy.stdout', data: data.toString() })) + child.stderr?.on('data', (data: Buffer) => emit({ type: 'event', kind: 'caddy.stderr', data: data.toString() })) + child.on('exit', (code, signal) => { + // Recorded before broadcasting so a later `caddy.start` knows it has to + // spawn again rather than reuse a dead process. + handle.running = false + emit({ type: 'event', kind: 'caddy.exit', code, signal }) + }) + caddy = handle + return handle + }, + stopCaddy: async () => { + const current = caddy + caddy = undefined + if (!current) + return + try { + current.kill('SIGTERM') + } + catch {} + }, + log, + } +} + +/** + * Serves requests on an authenticated connection. When the client disappears — + * including a `SIGKILL` of the plugin process — the socket closes and `dispose` + * restores the machine state. + */ +export function attachSocket(socket: Socket, runtime: HelperRuntime, log: (message: string) => void): void { + let buffer = '' + runtime.attach(socket) + + socket.on('data', (chunk: Buffer) => { + buffer += chunk.toString('utf8') + let index = buffer.indexOf('\n') + while (index >= 0) { + const line = buffer.slice(0, index) + buffer = buffer.slice(index + 1) + index = buffer.indexOf('\n') + + if (!line.trim()) + continue + + let request: HelperRequest + try { + request = JSON.parse(line) as HelperRequest + } + catch (error) { + log(`invalid request: ${(error as Error).message}`) + continue + } + + void runtime.handle(request, socket).then((response) => { + if (!socket.destroyed) + socket.write(serializeMessage(response)) + }) + } + }) + + socket.on('error', (error) => { + log(`connection error: ${error.message}`) + }) + + socket.on('close', () => { + void runtime.detach(socket) + }) +} diff --git a/src/privileged/session.ts b/src/privileged/session.ts new file mode 100644 index 0000000..6986579 --- /dev/null +++ b/src/privileged/session.ts @@ -0,0 +1,169 @@ +import type { PrivilegedClient } from './client' +import { Buffer } from 'node:buffer' +import { existsSync } from 'node:fs' +import { readFile, writeFile } from 'node:fs/promises' +import { platform } from 'node:os' +import { join } from 'node:path' +import * as lockfile from 'proper-lockfile' +import { connectExistingHelper, createHelperToken, startPrivilegedHelper } from './launch' + +export interface SessionScope { + /** Runtime directory shared by the plugin, e.g. `TEMP_DIR`. */ + runtimeDir: string + /** Directory of the running bundle; used to locate the helper entry. */ + helperEntry: string + helperArgs?: string[] + env?: NodeJS.ProcessEnv + sudoCommand?: string + /** Skips the interactive credential check; used by tests. */ + skipAuth?: boolean + /** Runs the helper as the current user; used by tests without privileges. */ + direct?: boolean + /** Caddy storage directory; the helper hands it back to the user on stop. */ + caddyStoragePath?: string + /** Caddy binary the helper may run; the client cannot change it later. */ + caddyBin: string + /** Caddyfile the helper may load. */ + caddyConfigPath: string + /** Admin API socket; used by `ca.trust` instead of the TCP default. */ + caddyAdminSocket?: string + /** Shown before the password prompt so the request is not a surprise. */ + reason?: { target: string, https: boolean } + log?: (message: string) => void +} + +export interface OpenedSession { + client: PrivilegedClient + /** True when an already-running helper was reused. */ + reused: boolean + close: () => Promise +} + +function tokenPath(runtimeDir: string): string { + return join(runtimeDir, 'helper.token') +} + +export function sessionSocketPath(runtimeDir: string): string { + if (platform() === 'win32') + return `\\\\.\\pipe\\uhrp-${Buffer.from(runtimeDir).toString('hex').slice(0, 40)}` + return join(runtimeDir, 'helper.sock') +} + +/** + * Connects to the helper already serving this runtime directory, or starts a + * new one. Reuse matters for multi-instance dev servers: the second instance + * attaches to the running Caddy instead of asking for a password again. + * + * The check-then-start sequence runs under a lock. Two projects starting at the + * same moment would otherwise both fail to find a socket, both spawn a helper, + * and the second would delete the first one's socket while it was still + * serving. + */ +export async function openHelperSession(scope: SessionScope): Promise { + const socketPath = sessionSocketPath(scope.runtimeDir) + const release = await acquireStartLock(scope.runtimeDir) + + try { + const reused = await tryReuse(socketPath, scope) + if (reused) + return reused + + return await startNew(socketPath, scope) + } + finally { + release() + } +} + +async function tryReuse(socketPath: string, scope: SessionScope): Promise { + if (!existsSync(socketPath) && platform() !== 'win32') + return undefined + + const token = await readToken(scope.runtimeDir) + if (!token) + return undefined + + const reused = await connectExistingHelper(socketPath, token) + if (!reused) + return undefined + + return { + client: reused, + reused: true, + close: async () => { + await reused.close() + }, + } +} + +async function startNew(socketPath: string, scope: SessionScope): Promise { + const token = createHelperToken() + const started = await startPrivilegedHelper({ + socketPath, + token, + helperEntry: scope.helperEntry, + helperArgs: scope.helperArgs, + sudoCommand: scope.sudoCommand, + skipAuth: scope.skipAuth, + direct: scope.direct, + env: scope.env, + caddyStoragePath: scope.caddyStoragePath, + caddyBin: scope.caddyBin, + caddyConfigPath: scope.caddyConfigPath, + caddyAdminSocket: scope.caddyAdminSocket, + reason: scope.reason, + log: scope.log, + }) + + await writeToken(scope.runtimeDir, token) + + return { + client: started.client, + reused: false, + close: async () => { + await started.close() + }, + } +} + +/** + * Serialises "is a helper running? if not, start one" across processes. + * + * The lock file is separate from the socket: the socket only exists once a + * helper is listening, while this lock guards the window before that. + */ +async function acquireStartLock(runtimeDir: string): Promise<() => void> { + const lockPath = join(runtimeDir, 'helper.start.lock') + try { + await writeFile(lockPath, '', { flag: 'a' }) + } + catch {} + + const release = await lockfile.lock(lockPath, { + realpath: false, + stale: 30_000, + retries: { retries: 100, minTimeout: 50, maxTimeout: 200 }, + }) + + return () => { + void release().catch(() => {}) + } +} + +async function readToken(runtimeDir: string): Promise { + try { + const content = await readFile(tokenPath(runtimeDir), 'utf8') + const token = content.trim() + return token === '' ? undefined : token + } + catch { + return undefined + } +} + +async function writeToken(runtimeDir: string, token: string): Promise { + try { + await writeFile(tokenPath(runtimeDir), token, { mode: 0o600 }) + } + catch {} +} diff --git a/src/privileged/validation.ts b/src/privileged/validation.ts new file mode 100644 index 0000000..7ed7eac --- /dev/null +++ b/src/privileged/validation.ts @@ -0,0 +1,38 @@ +import { isIP } from 'node:net' + +/** + * Validates values that cross the socket. Everything reaching the privileged + * helper is untrusted, and a hosts line in particular is a plain-text file: an + * unvalidated hostname containing a newline could inject entries this plugin + * does not own and therefore never cleans up. + */ + +/** Ports the plugin may free. Wide enough for the two the proxy binds. */ +export const ALLOWED_FREE_PORTS = new Set([80, 443]) + +const HOSTNAME_PATTERN = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i +const IPV6_IN_HOSTS = /^[0-9a-f:.]+$/i + +export function isSafeHost(host: unknown): host is string { + if (typeof host !== 'string' || host.length === 0 || host.length > 253) + return false + // A hosts entry is whitespace separated, so any whitespace or control + // character would split the line or start a new one. + if (/[\s#\0]/.test(host)) + return false + return HOSTNAME_PATTERN.test(host) +} + +export function isSafeIp(ip: unknown): ip is string { + if (typeof ip !== 'string') + return false + if (isIP(ip) !== 0) + return true + // `::1` and friends are caught by isIP; this keeps the check explicit for + // hosts files that spell IPv6 without brackets. + return IPV6_IN_HOSTS.test(ip) +} + +export function isAllowedFreePort(port: unknown): port is number { + return typeof port === 'number' && Number.isInteger(port) && ALLOWED_FREE_PORTS.has(port) +} diff --git a/src/privileged/windows-elevation.ts b/src/privileged/windows-elevation.ts new file mode 100644 index 0000000..71caa34 --- /dev/null +++ b/src/privileged/windows-elevation.ts @@ -0,0 +1,80 @@ +import type { ChildProcess } from 'node:child_process' +import { spawn } from 'node:child_process' +import process from 'node:process' + +/** + * Windows has no `sudo`: elevation goes through UAC, and the elevated process + * is a new process that cannot inherit the parent's stdio. The helper talks + * over a named pipe, so it only needs to be started detached. + * + * `Start-Process -Verb RunAs` is the documented way to trigger the UAC prompt + * without a helper binary or a dependency on `sudo-prompt`. + */ +export interface WindowsElevationOptions { + /** Executable to run elevated; normally the node runtime. */ + file: string + /** Arguments for that executable. */ + args: string[] + /** Directory to start in; defaults to the parent's. */ + cwd?: string +} + +/** Quotes an argument for PowerShell's single-quoted string syntax. */ +export function quotePowerShell(value: string): string { + return `'${value.replace(/'/g, '\'\'')}'` +} + +/** + * Builds the PowerShell command that starts `file` elevated. Exported so the + * escaping can be unit-tested without touching a real UAC prompt. + */ +export function buildElevationCommand(options: WindowsElevationOptions): string { + const argumentList = options.args.map(quotePowerShell).join(',') + const parts = [ + 'Start-Process', + '-FilePath', + quotePowerShell(options.file), + ] + + if (argumentList) + parts.push('-ArgumentList', argumentList) + + if (options.cwd) + parts.push('-WorkingDirectory', quotePowerShell(options.cwd)) + + parts.push('-Verb', 'RunAs') + parts.push('-WindowStyle', 'Hidden') + + return parts.join(' ') +} + +/** + * Starts the helper elevated on Windows. Resolves once PowerShell has accepted + * the request; the helper's own readiness is observed through the named pipe. + */ +export function spawnElevatedOnWindows(options: WindowsElevationOptions): Promise { + return new Promise((resolve, reject) => { + const script = buildElevationCommand(options) + const child = spawn( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-Command', script], + { stdio: 'ignore', windowsHide: true }, + ) + + // PowerShell exits once UAC is answered; a non-zero code means the user + // dismissed the prompt or elevation was denied. + child.once('error', reject) + child.once('exit', (code) => { + if (code === 0) + return resolve(child) + reject(new Error( + `administrator permission was not granted (PowerShell exited with ${code}). ` + + 'Accept the UAC prompt to let the plugin edit the hosts file and bind ports 80/443.', + )) + }) + }) +} + +export function isWindowsElevationAvailable(): boolean { + return process.platform === 'win32' +} diff --git a/src/utils.ts b/src/utils.ts index e808844..b2974b3 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,8 +1,4 @@ -import { execSync } from 'node:child_process' -import { chmod, readdir, stat } from 'node:fs/promises' import { networkInterfaces } from 'node:os' -import path from 'node:path' -import process from 'node:process' import { createConsola } from 'consola' import c from 'picocolors' import qrcode from 'qrcode-terminal' @@ -11,15 +7,6 @@ export const consola = createConsola({ level: 3, }).withTag('reverse-proxy') -export async function chmodRecursive(_path: string, mode: number): Promise { - await chmod(_path, mode) - if ((await stat(_path)).isDirectory()) { - const children = await readdir(_path) - for (const child of children) - await chmodRecursive(path.join(_path, child), mode) - } -} - export function once any>(fn: T): (...args: Parameters) => ReturnType { let called = false return (...args: any[]) => { @@ -30,21 +17,6 @@ export function once any>(fn: T): (...args: Parame } } -export function isAdmin(): boolean { - if (process.platform === 'win32') { - try { - execSync('net session', { stdio: 'ignore' }) - return true - } - catch { - return false - } - } - else { - return !!(process.getuid && process.getuid() === 0) - } -} - export function getIpv4List(): string[] { const interfaces = networkInterfaces() diff --git a/test/caddy-config.test.ts b/test/caddy-config.test.ts index 90a42c5..e93fea4 100644 --- a/test/caddy-config.test.ts +++ b/test/caddy-config.test.ts @@ -1,16 +1,31 @@ +import { randomUUID } from 'node:crypto' import { afterEach, describe, expect, it } from 'vitest' import { useCaddyConfig } from '../src/caddy/config' +/** + * The config path is a module constant, so every test file in this run shares + * one file. Clearing it wholesale would race the other config tests; each case + * scopes its routes to a unique owner and removes only those. + */ +const owners: string[] = [] + +function ownOwner(): string { + const owner = `owner-${randomUUID()}` + owners.push(owner) + return owner +} + describe('caddy config write with base path', () => { afterEach(async () => { - const { cleanUp } = useCaddyConfig() - await cleanUp() + const { removeProxy } = useCaddyConfig() + for (const owner of owners.splice(0)) + await removeProxy(owner) }) - it('should not override when same host with different base', () => { + it('keeps both bases when one host serves two projects', async () => { const { writeCaddyConfig, configJsonRef } = useCaddyConfig() - writeCaddyConfig([ + await writeCaddyConfig([ { target: 'example.com', portSuffix: ':80', @@ -27,14 +42,78 @@ describe('caddy config write with base path', () => { base: '/api', healthCheck: true, }, - ]) + ], ownOwner()) const cfg = configJsonRef() expect(cfg).toBeTruthy() - const keys = Object.keys(cfg!) - expect(keys).toContain('example.com:80/app/') - expect(keys).toContain('example.com:80/api/') - expect(cfg!['example.com:80/app/'].source).toEqual('localhost:5173') - expect(cfg!['example.com:80/api/'].source).toEqual('localhost:5174') + + const entries = Object.values(cfg!) + const app = entries.find(entry => entry.base === '/app/') + const api = entries.find(entry => entry.base === '/api/') + + expect(app?.source).toEqual('localhost:5173') + expect(api?.source).toEqual('localhost:5174') + }) + + it('keeps another project\'s routes when this one writes', async () => { + const { writeCaddyConfig, readProxies } = useCaddyConfig() + + await writeCaddyConfig([ + { + target: 'a.example.com', + portSuffix: ':80', + tls: '', + source: 'localhost:5173', + base: '/', + healthCheck: true, + }, + ], ownOwner()) + + await writeCaddyConfig([ + { + target: 'b.example.com', + portSuffix: ':80', + tls: '', + source: 'localhost:5174', + base: '/', + healthCheck: true, + }, + ], ownOwner()) + + const targets = Object.values(readProxies()).map(entry => entry.target) + expect(targets).toEqual(expect.arrayContaining(['a.example.com', 'b.example.com'])) + }) + + it('removes only the routes of the project that exits', async () => { + const { writeCaddyConfig, removeProxy, readProxies } = useCaddyConfig() + const exiting = ownOwner() + + await writeCaddyConfig([ + { + target: 'a.example.com', + portSuffix: ':80', + tls: '', + source: 'localhost:5173', + base: '/', + healthCheck: true, + }, + ], exiting) + + await writeCaddyConfig([ + { + target: 'b.example.com', + portSuffix: ':80', + tls: '', + source: 'localhost:5174', + base: '/', + healthCheck: true, + }, + ], ownOwner()) + + await removeProxy(exiting) + + const targets = Object.values(readProxies()).map(entry => entry.target) + expect(targets).toContain('b.example.com') + expect(targets.filter(t => t === 'a.example.com')).toHaveLength(0) }) }) diff --git a/test/caddy/caddyfile-sync.test.ts b/test/caddy/caddyfile-sync.test.ts new file mode 100644 index 0000000..8466fc9 --- /dev/null +++ b/test/caddy/caddyfile-sync.test.ts @@ -0,0 +1,98 @@ +import { randomUUID } from 'node:crypto' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { useCaddyConfig } from '../../src/caddy/config' + +/** + * The Caddyfile has to follow every route change. + * + * Leaving a stopped project's site in it would keep proxying to an upstream + * that no longer runs, and the routes another project still needs would be + * rendered from a stale view. + */ +const rendered: Array> = [] + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'uhrp-sync-')) +}) + +function createConfig() { + return useCaddyConfig({ + // A no-op lock: the ordering guarantee under test is the render call, not + // the file locking, which the config tests cover separately. + lock: { acquire: async () => () => {} }, + // Own file per test: the shared one is written by the other config tests + // in parallel, and their routes would show up in these renders. + configPath: join(dir, 'config.json'), + renderCaddyfile: async (proxies) => { + rendered.push({ ...proxies } as Record) + }, + }) +} + +function route(target: string, source: string) { + return { + target, + portSuffix: ':80', + tls: '', + source, + base: '/', + healthCheck: true, + } +} + +function ownKey(): string { + return `sync-${randomUUID()}` +} + +afterEach(() => { + rendered.length = 0 + rmSync(dir, { recursive: true, force: true }) +}) + +describe('caddyfile follows route changes', () => { + it('re-renders when a route is added', async () => { + const config = createConfig() + const key = ownKey() + + await config.writeCaddyConfig([route('a.example.com', 'localhost:5173')], key) + + expect(rendered).toHaveLength(1) + expect(Object.values(rendered[0]).some(r => r.target === 'a.example.com')).toBe(true) + }) + + it('drops the route of a project that exits', async () => { + const config = createConfig() + const keyA = ownKey() + const keyB = ownKey() + + await config.writeCaddyConfig([route('a.example.com', 'localhost:5173')], keyA) + await config.writeCaddyConfig([route('b.example.com', 'localhost:5174')], keyB) + + await config.removeProxy(keyA) + + // The last render must not contain the project that left, or its domain + // would keep pointing at a dead upstream. + const last = rendered[rendered.length - 1] + const targets = Object.values(last).map(r => r.target) + expect(targets).toContain('b.example.com') + expect(targets.filter(t => t === 'a.example.com')).toHaveLength(0) + }) + + it('renders again after the last project exits', async () => { + const config = createConfig() + const key = ownKey() + + await config.writeCaddyConfig([route('only.example.com', 'localhost:5173')], key) + const rendersBefore = rendered.length + + await config.removeProxy(key) + + expect(rendered.length).toBe(rendersBefore + 1) + expect(Object.values(rendered[rendered.length - 1]).some(r => r.target === 'only.example.com')).toBe(false) + }) +}) diff --git a/test/caddy/fake-config.ts b/test/caddy/fake-config.ts new file mode 100644 index 0000000..7057cd0 --- /dev/null +++ b/test/caddy/fake-config.ts @@ -0,0 +1,37 @@ +import type { ProxyType } from '../../src/caddy/caddyfile' +import type { CaddyConfigApi } from '../../src/caddy/config' + +type Proxy = typeof ProxyType.infer + +/** + * In-memory stand-in for the shared route config. + * + * The real one takes a file lock, which a unit test has no reason to exercise; + * what the plugin decides (which routes it writes and which it removes) is what + * these tests are about. + */ +export function createFakeConfig(): CaddyConfigApi & { proxies: Record } { + const proxies: Record = {} + + return { + proxies, + configJsonRef: () => proxies, + async writeCaddyConfig(entries, key) { + for (const entry of entries) { + const base = entry.base?.endsWith('/') ? entry.base : `${entry.base ?? '/'}/` + proxies[`${entry.target}${entry.portSuffix}${base}#${key}`] = { ...entry, base } + } + }, + async removeProxy(key) { + for (const route of Object.keys(proxies)) { + if (route.endsWith(`#${key}`)) + delete proxies[route] + } + }, + readProxies: () => proxies, + async cleanUp() { + for (const route of Object.keys(proxies)) + delete proxies[route] + }, + } +} diff --git a/test/caddy/index.test.ts b/test/caddy/index.test.ts index 2fa3062..0b6025b 100644 --- a/test/caddy/index.test.ts +++ b/test/caddy/index.test.ts @@ -1,3 +1,4 @@ +import { spawnSync } from 'node:child_process' import { existsSync } from 'node:fs' import { unlink } from 'node:fs/promises' import { dirname, resolve } from 'node:path' @@ -9,6 +10,22 @@ import { caddyPath, TEMP_DIR } from '../../src/caddy/constants' const __dirname = dirname(fileURLToPath(import.meta.url)) const baseTestUrl = resolve(__dirname, '../') +/** + * These cases start a real Caddy, so they need the privileges the plugin would + * ask a developer for: writing hosts and binding 80/443. Without them every + * case fails after its own timeout, which is noise rather than signal. + * + * Skipped instead of failed: the behaviour under test is already covered by the + * fake-session tests, and this file exists to catch adapter-level regressions. + */ +function hasSudoWithoutPrompt(): boolean { + if (process.platform === 'win32') + return false + return spawnSync('sudo', ['-n', 'true'], { stdio: 'ignore' }).status === 0 +} + +const canRunRealCaddy = hasSudoWithoutPrompt() + beforeAll(async () => { if (existsSync(resolve(baseTestUrl, TEMP_DIR))) await unlink(resolve(baseTestUrl, TEMP_DIR)) @@ -24,23 +41,18 @@ describe('caddy', () => { expect(existsSync(caddyPath)).toBe(true) }) - describe('reverse-proxy', () => { + describe.skipIf(!canRunRealCaddy)('reverse-proxy', () => { beforeEach(async () => { const baseUrl = resolve(baseTestUrl, '.uhrp') if (existsSync(resolve(baseUrl, 'CADDYFILE'))) await unlink(resolve(baseUrl, 'CADDYFILE')) - if (existsSync(resolve(baseUrl, 'caddylock'))) - await unlink(resolve(baseUrl, 'caddylock')) }) it.sequential(':80', async () => { const caddy = new CaddyInstant() - expect(caddy.checkLock()).toBe(false) await caddy.run('127.0.0.1:8080', 'test-1.abc.com') - expect(caddy.checkLock()).toBe(true) - const caddyfile = await caddy.getCaddyfile() expect(caddyfile).toMatchInlineSnapshot(` "{ @@ -106,13 +118,10 @@ describe('caddy', () => { it.sequential(':443', async () => { const caddy = new CaddyInstant() - expect(caddy.checkLock()).toBe(false) await caddy.run('127.0.0.1:8080', 'test-2.abc.com', { https: true, }) - expect(caddy.checkLock()).toBe(true) - const caddyfile = await caddy.getCaddyfile() expect(caddyfile).toMatchInlineSnapshot(` "{ @@ -177,11 +186,8 @@ describe('caddy', () => { it.sequential('both', async () => { const caddy_80 = new CaddyInstant() - expect(caddy_80.checkLock()).toBe(false) await caddy_80.run('127.0.0.1:8080', 'test-3.abc.com') - expect(caddy_80.checkLock()).toBe(true) - let caddyfile = await caddy_80.getCaddyfile() expect(caddyfile).toMatchInlineSnapshot(` "{ @@ -243,14 +249,11 @@ describe('caddy', () => { `) const caddy_443 = new CaddyInstant() - expect(caddy_443.checkLock()).toBe(true) await caddy_443.run('127.0.0.1:8080', 'test-4.abc.com', { https: true, healthCheck: false, }) - expect(caddy_443.checkLock()).toBe(true) - await sleep(50) caddyfile = await caddy_443.getCaddyfile() expect(caddyfile).toMatchInlineSnapshot(` @@ -328,14 +331,11 @@ describe('caddy', () => { it.sequential(':443 with base', async () => { const caddy = new CaddyInstant() - expect(caddy.checkLock()).toBe(false) await caddy.run('127.0.0.1:8080', 'test-2.abc.com', { https: true, base: '/test/', }) - expect(caddy.checkLock()).toBe(true) - const caddyfile = await caddy.getCaddyfile() expect(caddyfile).toMatchInlineSnapshot(` "{ @@ -401,15 +401,11 @@ describe('caddy', () => { it.sequential('same host different base no override', async () => { const caddyA = new CaddyInstant() - expect(caddyA.checkLock()).toBe(false) await caddyA.run('127.0.0.1:8081', 'test-5.abc.com', { base: '/app', }) - expect(caddyA.checkLock()).toBe(true) - const caddyB = new CaddyInstant() - expect(caddyB.checkLock()).toBe(true) await caddyB.run('127.0.0.1:8082', 'test-5.abc.com', { base: '/api', }) diff --git a/test/caddy/privileged-session.test.ts b/test/caddy/privileged-session.test.ts new file mode 100644 index 0000000..f0c1068 --- /dev/null +++ b/test/caddy/privileged-session.test.ts @@ -0,0 +1,243 @@ +import type { CaddyDependencies } from '../../src/caddy' +import type { HelperEvent } from '../../src/privileged/protocol' +import { existsSync, rmSync } from 'node:fs' +import { beforeEach, describe, expect, it } from 'vitest' +import { CaddyInstant } from '../../src/caddy' +import { caddyLockFilePath } from '../../src/caddy/constants' +import { HelperCaddyProcess } from '../../src/caddy/privileged' +import { createFakeConfig } from './fake-config' + +// Each test starts from an unlocked state: a leftover lock would make the +// plugin believe another instance already owns Caddy. +beforeEach(() => { + for (const path of [caddyLockFilePath, `${caddyLockFilePath}.lock`]) { + if (existsSync(path)) + rmSync(path, { recursive: true, force: true }) + } +}) + +/** + * Builds a fake privileged session: it records the primitive calls the plugin + * makes and lets a test push Caddy output back, so the plugin's decisions are + * observable without root, sudo or a real Caddy process. + */ +function createFakeSession() { + const calls: Array<{ method: string, params?: unknown }> = [] + const listeners = new Set<(event: HelperEvent) => void>() + let closed = false + + const client = { + connected: true, + async call(method: string, params?: unknown) { + calls.push({ method, params }) + if (method === 'ping') + return { pid: 1 } + if (method === 'port.free') + return { killed: [] } + if (method === 'caddy.start') + return { pid: 4242 } + if (method === 'ca.trust') + return { trusted: true, output: '' } + return undefined + }, + onEvent(listener: (event: HelperEvent) => void) { + listeners.add(listener) + return () => listeners.delete(listener) + }, + async close() { + closed = true + }, + } + + return { + calls, + get closed() { + return closed + }, + emit(event: HelperEvent) { + for (const listener of listeners) + listener(event) + }, + session: { + client: client as never, + reused: false, + close: async () => { + closed = true + }, + } as Awaited>, + } +} + +/** Waits until the plugin has issued a given helper call, without a fixed sleep. */ +async function waitForCall(fake: ReturnType, method: string): Promise { + for (let attempt = 0; attempt < 200; attempt++) { + if (fake.calls.some(call => call.method === method)) + return + await new Promise(resolve => setTimeout(resolve, 10)) + } + throw new Error(`helper never received "${method}"; got ${fake.calls.map(call => call.method).join(', ')}`) +} + +/** Runs Caddy until it reports it is serving, then returns. */ +async function startServing(fake: ReturnType, run: Promise): Promise { + await waitForCall(fake, 'caddy.start') + fake.emit({ type: 'event', kind: 'caddy.stderr', data: '{"msg":"serving initial configuration"}' }) + await run +} + +describe('caddy through the privileged helper', () => { + it('asks the helper to add the host mapping and free ports 80/443', async () => { + const fake = createFakeSession() + const caddy = new CaddyInstant({ openSession: async () => fake.session, config: () => createFakeConfig() }) + + await startServing(fake, caddy.run('127.0.0.1:8080', 'test.abc.com')) + + expect(fake.calls).toContainEqual({ method: 'hosts.add', params: { ip: '127.0.0.1', host: 'test.abc.com' } }) + expect(fake.calls).toContainEqual({ method: 'port.free', params: { port: 443 } }) + expect(fake.calls).toContainEqual({ method: 'port.free', params: { port: 80 } }) + }) + + it('starts Caddy through the helper without naming a binary', async () => { + const fake = createFakeSession() + const caddy = new CaddyInstant({ openSession: async () => fake.session, config: () => createFakeConfig() }) + + await startServing(fake, caddy.run('127.0.0.1:8080', 'test.abc.com')) + + // The request carries no params: the helper owns the binary and arguments. + expect(fake.calls).toContainEqual({ method: 'caddy.start' }) + }) + + it('removes its own mapping during cleanup', async () => { + const fake = createFakeSession() + const caddy = new CaddyInstant({ openSession: async () => fake.session, config: () => createFakeConfig() }) + + await startServing(fake, caddy.run('127.0.0.1:8080', 'test.abc.com')) + await caddy.baseCleanup() + + expect(fake.calls).toContainEqual({ + method: 'hosts.remove', + params: { ip: '127.0.0.1', host: 'test.abc.com' }, + }) + }) + + it('leaves Caddy running for the projects that are still up', async () => { + const fake = createFakeSession() + const caddy = new CaddyInstant({ openSession: async () => fake.session, config: () => createFakeConfig() }) + + await startServing(fake, caddy.run('127.0.0.1:8080', 'test.abc.com')) + await caddy.baseCleanup() + + // The helper stops Caddy when its last client disconnects; a single client + // exiting must not take the shared proxy down. + expect(fake.calls.map(call => call.method)).not.toContain('caddy.stop') + }) + + it('surfaces a helper failure before Caddy starts', async () => { + const fake = createFakeSession() + const failing = { + ...fake.session, + client: { + connected: true, + onEvent: fake.session.client.onEvent, + close: fake.session.client.close, + call: async (method: string) => { + if (method === 'hosts.add') + throw new Error('EACCES: permission denied, open /etc/hosts') + return (fake.session.client.call as (m: string) => Promise)(method) + }, + }, + } as typeof fake.session + + const caddy = new CaddyInstant({ openSession: async () => failing, config: () => createFakeConfig() }) + + await expect(caddy.run('127.0.0.1:8080', 'test.abc.com')).rejects.toThrow('EACCES: permission denied') + }) + + it('reopens a session after the helper connection dropped', async () => { + const first = createFakeSession() + const second = createFakeSession() + let opened = 0 + + const caddy = new CaddyInstant({ + config: () => createFakeConfig(), + openSession: async () => { + opened++ + return opened === 1 ? first.session : second.session + }, + }) + + await startServing(first, caddy.run('127.0.0.1:8080', 'test.abc.com')) + + // Simulate the helper dying: the cached client reports itself disconnected. + ;(first.session.client as unknown as { connected: boolean }).connected = false + + // The next run must not reuse the dead session; it opens a fresh one. + const secondRun = caddy.run('127.0.0.1:8099', 'test2.abc.com') + await waitForCall(second, 'hosts.add') + await waitForCall(second, 'caddy.start') + second.emit({ type: 'event', kind: 'caddy.stderr', data: '{"msg":"serving initial configuration"}' }) + await secondRun.catch(() => {}) + + expect(opened).toBe(2) + }) +}) + +describe('helper caddy process', () => { + it('re-emits stdout and stderr from helper events', () => { + const listeners = new Set<(event: HelperEvent) => void>() + const client = { + onEvent(listener: (event: HelperEvent) => void) { + listeners.add(listener) + return () => listeners.delete(listener) + }, + call: async () => undefined, + } + + const child = new HelperCaddyProcess(client as never) + const received: string[] = [] + child.stderr.on('data', (data: { toString: () => string }) => received.push(data.toString())) + + for (const listener of listeners) + listener({ type: 'event', kind: 'caddy.stderr', data: '{"level":"error"}' }) + + expect(received).toEqual(['{"level":"error"}']) + }) + + it('emits exit with the helper-reported code', () => { + const listeners = new Set<(event: HelperEvent) => void>() + const client = { + onEvent(listener: (event: HelperEvent) => void) { + listeners.add(listener) + return () => listeners.delete(listener) + }, + call: async () => undefined, + } + + const child = new HelperCaddyProcess(client as never) + const exit = new Promise<[number | null, string | null]>( + resolve => child.on('exit', (code: number | null, signal: string | null) => resolve([code, signal])), + ) + + for (const listener of listeners) + listener({ type: 'event', kind: 'caddy.exit', code: 1, signal: null }) + + return expect(exit).resolves.toEqual([1, null]) + }) + + it('does not ask the helper to stop twice', async () => { + const stopped: string[] = [] + const client = { + onEvent: () => () => {}, + call: async (method: string) => { + stopped.push(method) + }, + } + + const child = new HelperCaddyProcess(client as never) + child.kill() + child.kill() + await new Promise(resolve => setTimeout(resolve, 10)) + + expect(stopped.filter(method => method === 'caddy.stop')).toHaveLength(1) + }) +}) diff --git a/test/caddy/shared-start.test.ts b/test/caddy/shared-start.test.ts new file mode 100644 index 0000000..9cec891 --- /dev/null +++ b/test/caddy/shared-start.test.ts @@ -0,0 +1,66 @@ +import type { OpenedPrivilegedSession } from '../../src/caddy/privileged' +import type { HelperEvent } from '../../src/privileged/protocol' +import { describe, expect, it } from 'vitest' +import { CaddyInstant } from '../../src/caddy' +import { createFakeConfig } from './fake-config' + +/** + * A second project attaches to the Caddy the first one started. That Caddy + * already printed its "serving initial configuration" line, so a plugin that + * waits for it again would never resolve and never print its success message. + */ +function createReusingSession() { + const calls: string[] = [] + const listeners = new Set<(event: HelperEvent) => void>() + + const client = { + connected: true, + async call(method: string): Promise { + calls.push(method) + if (method === 'caddy.start') + return { pid: 4242, started: false } + if (method === 'port.free') + return { killed: [] } + if (method === 'ca.trust') + return { trusted: true, output: '' } + return undefined + }, + onEvent(listener: (event: HelperEvent) => void) { + listeners.add(listener) + return () => listeners.delete(listener) + }, + async close() {}, + } + + return { + calls, + session: { + // The plugin only uses the members above; the rest of the client surface + // is exercised by the helper's own tests. + client: client as unknown as OpenedPrivilegedSession['client'], + reused: true, + close: async () => {}, + }, + } +} + +describe('attaching to a running Caddy', () => { + it('resolves without waiting for a startup line it will never see', async () => { + const fake = createReusingSession() + const caddy = new CaddyInstant({ + openSession: async () => fake.session, + config: () => createFakeConfig(), + }) + + // A timeout would mean the plugin is waiting for output the reused Caddy + // already emitted for the first project. + const run = caddy.run('127.0.0.1:8080', 'second.abc.com') + + await expect(Promise.race([ + run, + new Promise((_, reject) => setTimeout(() => reject(new Error('run() never resolved')), 2000)), + ])).resolves.toBeUndefined() + + expect(fake.calls).toContain('caddy.start') + }, 10_000) +}) diff --git a/test/caddyfile.test.ts b/test/caddyfile.test.ts index 9da4e5a..871ecfe 100644 --- a/test/caddyfile.test.ts +++ b/test/caddyfile.test.ts @@ -5,6 +5,7 @@ describe('caddyfile host grouping and route ordering', async () => { it('should group same host and order routes by base length desc', async () => { const ctx = validateTemplateContext({ caddy_root: '/tmp', + admin_socket: '/tmp/caddy-admin.sock', proxies: [ { target: 'example.com', @@ -44,4 +45,19 @@ describe('caddyfile host grouping and route ordering', async () => { expect(posApi).toBeGreaterThan(-1) expect(posApiV2).toBeLessThan(posApi) }) + + it('binds the admin API to a unix socket', async () => { + const ctx = validateTemplateContext({ + caddy_root: '/tmp', + admin_socket: '/tmp/uhrp/caddy-admin.sock', + proxies: [], + }) + + const caddyfile = await genCaddyfile(ctx) + + // Caddy runs as root; a TCP admin API would let any local user reconfigure + // it with no further check. + expect(caddyfile).toContain('admin unix//tmp/uhrp/caddy-admin.sock') + expect(caddyfile).not.toContain('localhost:2019') + }) }) diff --git a/test/privilege-denial-hot-restart.test.ts b/test/privilege-denial-hot-restart.test.ts new file mode 100644 index 0000000..842d31f --- /dev/null +++ b/test/privilege-denial-hot-restart.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest' +import { resetPrivilegeDenied } from '../src/privilege-state' + +/** + * The entry points must refuse to start the proxy once the user has declined, + * so a hot restart does not pop the password prompt again. + */ +describe('declined permission blocks every entry point', () => { + it('pops the password prompt at most once per process', async () => { + vi.resetModules() + resetPrivilegeDenied() + + const { authenticateSudo } = await import('../src/privileged/launch') + + // `-n` is tried first; when it fails the interactive prompt runs. Point both + // at a command that always fails so no real sudo is involved. + const first = await authenticateSudo('false') + expect(first.ok).toBe(false) + expect(first.denied).toBe(true) + + // Below the module that records it, the flag is what stops the second ask. + const { isPrivilegeDenied, markPrivilegeDenied } = await import('../src/privilege-state') + expect(isPrivilegeDenied()).toBe(false) + markPrivilegeDenied() + expect(isPrivilegeDenied()).toBe(true) + }) + + it('explains why the password is needed before asking', async () => { + const { describePasswordReason } = await import('../src/privileged/launch') + + const httpsLines = describePasswordReason('app.example.com', true) + expect(httpsLines[0]).toContain('app.example.com') + expect(httpsLines.join('\n')).toContain('443') + expect(httpsLines.join('\n')).toContain('hosts') + + const httpLines = describePasswordReason('app.example.com', false) + expect(httpLines.join('\n')).toContain('80') + }) +}) diff --git a/test/privileged/caddy-trust.test.ts b/test/privileged/caddy-trust.test.ts new file mode 100644 index 0000000..4ec0ccc --- /dev/null +++ b/test/privileged/caddy-trust.test.ts @@ -0,0 +1,45 @@ +import type { CaddyChild, HelperDeps } from '../../src/privileged/server' +import { describe, expect, it, vi } from 'vitest' +import { createHelperRuntime } from '../../src/privileged/server' + +function createDeps(overrides: Partial = {}): HelperDeps { + return { + readHosts: () => '127.0.0.1\tlocalhost\n', + writeHosts: () => {}, + flushDns: async () => {}, + freePort: async () => [], + caddyBin: '/fake/caddy', + caddyConfigPath: '/fake/Caddyfile', + caddyCwd: '/', + caddyEnv: {}, + trustLocalCa: async () => ({ trusted: true, output: '' }), + startCaddy: (): CaddyChild => ({ pid: 1, kill: () => true }), + stopCaddy: async () => {}, + log: () => {}, + ...overrides, + } +} + +describe('ca trust through the helper', () => { + it('reports the trust result to the plugin', async () => { + const trustLocalCa = vi.fn().mockResolvedValue({ trusted: true, output: 'installed' }) + const runtime = createHelperRuntime(createDeps({ trustLocalCa })) + + const response = await runtime.handle({ id: 1, method: 'ca.trust' }) + + expect(response).toEqual({ id: 1, ok: true, result: { trusted: true, output: 'installed' } }) + expect(trustLocalCa).toHaveBeenCalledOnce() + }) + + it('surfaces a failure so the plugin can warn the developer', async () => { + const runtime = createHelperRuntime(createDeps({ + trustLocalCa: async () => { + throw new Error('keytool exited with 1') + }, + })) + + const response = await runtime.handle({ id: 2, method: 'ca.trust' }) + + expect(response).toEqual({ id: 2, ok: false, error: { message: 'keytool exited with 1' } }) + }) +}) diff --git a/test/privileged/concurrent-hosts.test.ts b/test/privileged/concurrent-hosts.test.ts new file mode 100644 index 0000000..b9c0ee8 --- /dev/null +++ b/test/privileged/concurrent-hosts.test.ts @@ -0,0 +1,63 @@ +import type { CaddyChild, HelperDeps } from '../../src/privileged/server' +import { describe, expect, it } from 'vitest' +import { createHelperRuntime } from '../../src/privileged/server' + +function createDeps(initial: string): { deps: HelperDeps, content: () => string } { + let hosts = initial + + const deps: HelperDeps = { + readHosts: () => hosts, + writeHosts: (content) => { + hosts = content + }, + flushDns: async () => {}, + freePort: async () => [], + caddyBin: '/fake/caddy', + caddyConfigPath: '/fake/Caddyfile', + caddyCwd: '/', + caddyEnv: {}, + trustLocalCa: async () => ({ trusted: true, output: '' }), + startCaddy: (): CaddyChild => ({ pid: 1, kill: () => true }), + stopCaddy: async () => {}, + log: () => {}, + } + + return { deps, content: () => hosts } +} + +/** + * Several dev-server instances share one helper. Requests arrive on separate + * sockets and are dispatched without waiting for each other, so a lost update + * would drop one instance's domain mapping. + */ +describe('concurrent hosts edits', () => { + it('keeps both entries when two clients add a host simultaneously', async () => { + const { deps, content } = createDeps('127.0.0.1\tlocalhost\n') + const runtime = createHelperRuntime(deps) + + await Promise.all([ + runtime.handle({ id: 1, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'a.abc.com' } }), + runtime.handle({ id: 2, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'b.abc.com' } }), + ]) + + const hosts = content() + expect(hosts).toContain('a.abc.com') + expect(hosts).toContain('b.abc.com') + }) + + it('does not lose an entry when a remove races an add for a different host', async () => { + const { deps, content } = createDeps('127.0.0.1\tlocalhost\n') + const runtime = createHelperRuntime(deps) + + await runtime.handle({ id: 1, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'old.abc.com' } }) + + await Promise.all([ + runtime.handle({ id: 2, method: 'hosts.remove', params: { ip: '127.0.0.1', host: 'old.abc.com' } }), + runtime.handle({ id: 3, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'new.abc.com' } }), + ]) + + const hosts = content() + expect(hosts).not.toContain('old.abc.com') + expect(hosts).toContain('new.abc.com') + }) +}) diff --git a/test/privileged/dns-flush-commands.test.ts b/test/privileged/dns-flush-commands.test.ts new file mode 100644 index 0000000..f66f9d8 --- /dev/null +++ b/test/privileged/dns-flush-commands.test.ts @@ -0,0 +1,29 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const source = readFileSync(new URL('../../src/privileged/server.ts', import.meta.url), 'utf8') + +/** + * The flush command is picked per platform because each OS caches different + * things. Reading the table keeps the assertion independent of `process.platform` + * so the check also runs on the maintainer's macOS machine. + */ +describe('dns flush commands per platform', () => { + it('uses mDNSResponder on macOS', () => { + expect(source).toMatch(/darwin:\s*\[\{ file: 'killall', args: \['-HUP', 'mDNSResponder'\] \}\]/) + }) + + it('uses ipconfig on Windows', () => { + expect(source).toMatch(/win32:\s*\[\{ file: 'ipconfig', args: \['\/flushdns'\] \}\]/) + }) + + it('uses resolvectl on Linux, with the legacy name as fallback', () => { + expect(source).toContain(`{ file: 'resolvectl', args: ['flush-caches'] }`) + expect(source).toContain(`{ file: 'systemd-resolve', args: ['--flush-caches'] }`) + }) + + it('tries the next candidate when the first command is unavailable', () => { + expect(source).toContain('for (const candidate of candidates)') + expect(source).toContain('lastError = error as Error') + }) +}) diff --git a/test/privileged/host-owners.test.ts b/test/privileged/host-owners.test.ts new file mode 100644 index 0000000..f182d80 --- /dev/null +++ b/test/privileged/host-owners.test.ts @@ -0,0 +1,95 @@ +import type { CaddyChild, HelperDeps } from '../../src/privileged/server' +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { beforeEach, describe, expect, it } from 'vitest' +import { createHelperRuntime } from '../../src/privileged/server' + +/** + * Two projects can map the same domain. Removing the mapping when the first of + * them exits would break the other, so ownership is reference-counted. + */ +function createDeps() { + const dir = mkdtempSync(join(tmpdir(), 'uhrp-owner-')) + const hostsPath = join(dir, 'hosts') + writeFileSync(hostsPath, '127.0.0.1\tlocalhost\n') + + const deps: HelperDeps = { + readHosts: () => readFileSync(hostsPath, 'utf8'), + writeHosts: (content: string) => writeFileSync(hostsPath, content), + flushDns: async () => {}, + freePort: async () => [], + caddyBin: '/usr/bin/false', + caddyConfigPath: join(dir, 'Caddyfile'), + caddyCwd: dir, + caddyEnv: {}, + trustLocalCa: async () => ({ trusted: true, output: '' }), + startCaddy: (): CaddyChild => ({ pid: 1, kill: () => true, running: true }), + stopCaddy: async () => {}, + log: () => {}, + } + + return { deps, hostsPath } +} + +let ctx: ReturnType + +beforeEach(() => { + ctx = createDeps() +}) + +describe('shared host mappings', () => { + it('keeps the mapping while another client still needs it', async () => { + const runtime = createHelperRuntime(ctx.deps) + + const a = {} + const b = {} + runtime.attach(a) + runtime.attach(b) + + await runtime.handle({ id: 1, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'shared.abc.com' } }, a) + await runtime.handle({ id: 2, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'shared.abc.com' } }, b) + + // First project exits. + await runtime.detach(a) + + expect(readFileSync(ctx.hostsPath, 'utf8')).toContain('shared.abc.com') + }) + + it('removes the mapping when the last client leaves', async () => { + const runtime = createHelperRuntime(ctx.deps) + + const a = {} + const b = {} + runtime.attach(a) + runtime.attach(b) + + await runtime.handle({ id: 1, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'shared.abc.com' } }, a) + await runtime.handle({ id: 2, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'shared.abc.com' } }, b) + + await runtime.detach(a) + await runtime.detach(b) + + expect(readFileSync(ctx.hostsPath, 'utf8')).not.toContain('shared.abc.com') + }) + + it('ignores an explicit remove from a client that still shares the host', async () => { + const runtime = createHelperRuntime(ctx.deps) + + const a = {} + const b = {} + runtime.attach(a) + runtime.attach(b) + + await runtime.handle({ id: 1, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'shared.abc.com' } }, a) + await runtime.handle({ id: 2, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'shared.abc.com' } }, b) + + const response = await runtime.handle( + { id: 3, method: 'hosts.remove', params: { ip: '127.0.0.1', host: 'shared.abc.com' } }, + a, + ) + + expect(response).toMatchObject({ ok: true, result: { kept: true } }) + expect(readFileSync(ctx.hostsPath, 'utf8')).toContain('shared.abc.com') + }) +}) diff --git a/test/privileged/hosts-file.test.ts b/test/privileged/hosts-file.test.ts new file mode 100644 index 0000000..751b207 --- /dev/null +++ b/test/privileged/hosts-file.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { + addHost, + HOSTS_MARKER, + listMarkedHosts, + removeHost, + removeMarkedHosts, +} from '../../src/privileged/hosts-file' + +const BASE = [ + '##', + '# Host Database', + '##', + '127.0.0.1\tlocalhost', + '255.255.255.255\tbroadcasthost', + '::1\tlocalhost', + '', +].join('\n') + +describe('hosts file edits', () => { + it('appends a marked entry and reports a change', () => { + const result = addHost(BASE, '127.0.0.1', 'test.abc.com') + + expect(result.changed).toBe(true) + expect(result.content).toContain(`127.0.0.1 test.abc.com ${HOSTS_MARKER}`) + expect(result.content).toContain('# Host Database') + expect(result.content).toContain('255.255.255.255\tbroadcasthost') + }) + + it('replaces a stale entry for the same host instead of duplicating it', () => { + const once = addHost(BASE, '127.0.0.1', 'test.abc.com') + const twice = addHost(once.content, '127.0.0.1', 'test.abc.com') + + const occurrences = twice.content.split('\n').filter(line => line.includes('test.abc.com')) + expect(occurrences).toEqual([`127.0.0.1 test.abc.com ${HOSTS_MARKER}`]) + expect(twice.changed).toBe(false) + }) + + it('keeps the IPv6 localhost mapping when adding an IPv4 entry', () => { + const result = addHost(BASE, '127.0.0.1', 'localhost') + + expect(result.content).toContain('::1\tlocalhost') + expect(result.content).toContain(`127.0.0.1 localhost ${HOSTS_MARKER}`) + }) + + it('removes both the marked entry and legacy unmarked mappings', () => { + const withLegacy = `${BASE}127.0.0.1 legacy.abc.com\n` + const added = addHost(withLegacy, '127.0.0.1', 'legacy.abc.com') + + const result = removeHost(added.content, '127.0.0.1', 'legacy.abc.com') + + expect(result.content).not.toContain('legacy.abc.com') + expect(result.content).toContain('127.0.0.1\tlocalhost') + }) + + it('sweeps only the entries this plugin wrote', () => { + const content = [ + BASE, + `127.0.0.1 stale-1.abc.com ${HOSTS_MARKER}`, + `127.0.0.1 stale-2.abc.com ${HOSTS_MARKER}`, + '127.0.0.1 user-own-entry.local', + ].join('\n') + + const result = removeMarkedHosts(content) + + expect(result.content).not.toContain('stale-1.abc.com') + expect(result.content).not.toContain('stale-2.abc.com') + expect(result.content).toContain('127.0.0.1 user-own-entry.local') + expect(result.changed).toBe(true) + }) + + it('preserves CRLF line endings', () => { + const crlf = '127.0.0.1\tlocalhost\r\n' + const result = addHost(crlf, '127.0.0.1', 'test.abc.com') + + expect(result.content).toBe(`127.0.0.1\tlocalhost\r\n127.0.0.1 test.abc.com ${HOSTS_MARKER}\r\n`) + }) + + it('reports entries it owns', () => { + const content = `${BASE}127.0.0.1 a.abc.com ${HOSTS_MARKER}\n127.0.0.1 b.abc.com ${HOSTS_MARKER}\n` + + expect(listMarkedHosts(content)).toEqual([ + { ip: '127.0.0.1', host: 'a.abc.com' }, + { ip: '127.0.0.1', host: 'b.abc.com' }, + ]) + }) +}) diff --git a/test/privileged/idle-exit.test.ts b/test/privileged/idle-exit.test.ts new file mode 100644 index 0000000..6f02598 --- /dev/null +++ b/test/privileged/idle-exit.test.ts @@ -0,0 +1,94 @@ +import type { HelperDeps } from '../../src/privileged/server' +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { startHelper } from '../../src/privileged/bootstrap' + +/** + * A helper nobody is using must not stay behind as an idle root process. It + * also must not remove a socket a newer helper already owns. + */ +function createDeps(dir: string): HelperDeps { + const hostsPath = join(dir, 'hosts') + writeFileSync(hostsPath, '127.0.0.1\tlocalhost\n') + // The helper refuses to start when its Caddyfile is missing. + writeFileSync(join(dir, 'Caddyfile'), '') + + return { + readHosts: () => readFileSync(hostsPath, 'utf8'), + writeHosts: (content: string) => writeFileSync(hostsPath, content), + flushDns: async () => {}, + freePort: async () => [], + caddyBin: '/usr/bin/false', + caddyConfigPath: join(dir, 'Caddyfile'), + caddyCwd: dir, + caddyEnv: {}, + trustLocalCa: async () => ({ trusted: true, output: '' }), + startCaddy: () => ({ pid: 1, kill: () => true, running: true }), + stopCaddy: async () => {}, + log: () => {}, + } +} + +const opened: Array<{ close: () => Promise }> = [] + +afterEach(async () => { + for (const helper of opened.splice(0)) + await helper.close().catch(() => {}) +}) + +describe('idle exit', () => { + it('removes its own socket and token when nobody is connected', async () => { + const dir = mkdtempSync(join(tmpdir(), 'uhrp-idle-')) + const socketPath = join(dir, 'helper.sock') + const token = 'token-abc' + + const helper = await startHelper({ + socketPath, + token, + ownerUid: process.getuid?.(), + ownerGid: process.getgid?.(), + caddyBin: '/usr/bin/false', + caddyConfigPath: join(dir, 'Caddyfile'), + idleTimeoutMs: 100, + // In-process helper: it must release the socket without ending the runner. + exitOnIdle: false, + deps: createDeps(dir), + log: () => {}, + }) + opened.push(helper) + + await new Promise(resolve => setTimeout(resolve, 400)) + + expect(existsSync(socketPath)).toBe(false) + expect(existsSync(join(dir, 'helper.token'))).toBe(false) + }) + + it('leaves a socket alone when a newer helper owns it', async () => { + const dir = mkdtempSync(join(tmpdir(), 'uhrp-idle-')) + const socketPath = join(dir, 'helper.sock') + const token = 'token-old' + + const helper = await startHelper({ + socketPath, + token, + ownerUid: process.getuid?.(), + ownerGid: process.getgid?.(), + caddyBin: '/usr/bin/false', + caddyConfigPath: join(dir, 'Caddyfile'), + idleTimeoutMs: 100, + exitOnIdle: false, + deps: createDeps(dir), + log: () => {}, + }) + opened.push(helper) + + // Another helper took over the token while this one was idle. + writeFileSync(join(dir, 'helper.token'), 'token-new') + + await new Promise(resolve => setTimeout(resolve, 400)) + + expect(readFileSync(join(dir, 'helper.token'), 'utf8')).toBe('token-new') + }) +}) diff --git a/test/privileged/path-guard.test.ts b/test/privileged/path-guard.test.ts new file mode 100644 index 0000000..865fc9c --- /dev/null +++ b/test/privileged/path-guard.test.ts @@ -0,0 +1,87 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { checkPrivilegedPath } from '../../src/privileged/path-guard' + +/** + * The helper executes the Caddy binary and loads the Caddyfile as root, while + * both paths come from a process running as the developer. A path another user + * can rewrite would turn that into a local privilege gain. + */ +describe('checkPrivilegedPath', () => { + let dir: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'uhrp-guard-')) + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('accepts a file only the invoking user can write', () => { + const file = join(dir, 'caddy') + writeFileSync(file, '#!/bin/sh\n') + chmodSync(file, 0o700) + + expect(checkPrivilegedPath(file, { label: 'Caddy binary', ownerUid: process.getuid?.() })).toBeUndefined() + }) + + it('rejects a group-writable file', () => { + const file = join(dir, 'caddy') + writeFileSync(file, '#!/bin/sh\n') + chmodSync(file, 0o770) + + const rejection = checkPrivilegedPath(file, { label: 'Caddy binary', ownerUid: process.getuid?.() }) + + expect(rejection?.reason).toContain('group- or world-writable') + }) + + it('rejects a world-writable file', () => { + const file = join(dir, 'caddy') + writeFileSync(file, '#!/bin/sh\n') + chmodSync(file, 0o757) + + expect(checkPrivilegedPath(file, { label: 'Caddy binary', ownerUid: process.getuid?.() })).toBeDefined() + }) + + it('rejects a symbolic link', () => { + const real = join(dir, 'real-caddy') + const link = join(dir, 'caddy') + writeFileSync(real, '#!/bin/sh\n') + chmodSync(real, 0o700) + symlinkSync(real, link) + + const rejection = checkPrivilegedPath(link, { label: 'Caddy binary', ownerUid: process.getuid?.() }) + + // A link can be repointed after the check, so the checked file might not be + // the one that ends up running. + expect(rejection?.reason).toContain('symbolic link') + }) + + it('rejects a missing path', () => { + const rejection = checkPrivilegedPath(join(dir, 'absent'), { label: 'Caddyfile', ownerUid: process.getuid?.() }) + + expect(rejection?.reason).toContain('not readable') + }) + + it('rejects a file owned by someone else', () => { + const file = join(dir, 'caddy') + writeFileSync(file, '#!/bin/sh\n') + chmodSync(file, 0o700) + + // A uid that is neither root nor the caller's. + const otherUid = (process.getuid?.() ?? 0) + 1 + const rejection = checkPrivilegedPath(file, { label: 'Caddy binary', ownerUid: otherUid }) + + expect(rejection?.reason).toContain('owned by another user') + }) + + it('accepts a directory, which is how the Caddyfile path starts out', () => { + const nested = join(dir, 'runtime') + mkdirSync(nested, { mode: 0o700 }) + + expect(checkPrivilegedPath(nested, { label: 'Caddyfile', ownerUid: process.getuid?.() })).toBeUndefined() + }) +}) diff --git a/test/privileged/ports.test.ts b/test/privileged/ports.test.ts new file mode 100644 index 0000000..fcc94a4 --- /dev/null +++ b/test/privileged/ports.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { parseListeningPids, parseWindowsListeningPids } from '../../src/privileged/ports' + +describe('parseListeningPids', () => { + it('reads one pid per line', () => { + expect(parseListeningPids('123\n456\n')).toEqual([123, 456]) + }) + + it('ignores noise and duplicates', () => { + expect(parseListeningPids(' 789 \n\n789\nnot-a-pid\n')).toEqual([789]) + }) + + it('returns nothing for empty output', () => { + expect(parseListeningPids('')).toEqual([]) + }) +}) + +describe('parseWindowsListeningPids', () => { + const output = [ + 'Active Connections', + '', + ' Proto Local Address Foreign Address State PID', + ' TCP 0.0.0.0:443 0.0.0.0:0 LISTENING 4321', + ' TCP [::]:443 [::]:0 LISTENING 4321', + ' TCP 0.0.0.0:8080 0.0.0.0:0 LISTENING 9999', + ' TCP 127.0.0.1:5000 127.0.0.1:6000 ESTABLISHED 1234', + ' UDP 0.0.0.0:443 *:* 5555', + ].join('\r\n') + + it('returns only the owners of the requested listening port', () => { + expect(parseWindowsListeningPids(output, 443)).toEqual([4321]) + }) + + it('ignores other ports and non-listening sockets', () => { + expect(parseWindowsListeningPids(output, 5000)).toEqual([]) + expect(parseWindowsListeningPids(output, 8080)).toEqual([9999]) + }) +}) diff --git a/test/privileged/protocol-client.test.ts b/test/privileged/protocol-client.test.ts new file mode 100644 index 0000000..25a71fe --- /dev/null +++ b/test/privileged/protocol-client.test.ts @@ -0,0 +1,198 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { startHelper } from '../../src/privileged/bootstrap' +import { collectForwardedEnv, connectClient } from '../../src/privileged/client' +import { decodeMessage, serializeMessage, splitLines } from '../../src/privileged/protocol' +import { createNodeHelperDeps } from '../../src/privileged/server' + +const cleanups: Array<() => Promise | void> = [] +let counter = 0 + +function tempSocket(): string { + counter++ + return join(tmpdir(), `uhrp-test-${process.pid}-${counter}.sock`) +} + +function createTempHosts(initial = '127.0.0.1\tlocalhost\n'): string { + counter++ + const path = join(tmpdir(), `uhrp-hosts-${process.pid}-${counter}`) + writeFileSync(path, initial) + return path +} + +function helperDeps(hostsPath: string) { + return createNodeHelperDeps({ + hostsPath, + log: () => {}, + caddyBin: process.execPath, + caddyConfigPath: hostsPath, + }) +} + +afterEach(async () => { + for (const cleanup of cleanups.splice(0)) + await cleanup() +}) + +describe('protocol', () => { + it('round-trips a request through serialize/decode', () => { + const decoded = decodeMessage(serializeMessage({ id: 1, ok: true, result: { pid: 2 } }).trim()) + + expect(decoded).toEqual({ ok: true, message: { id: 1, ok: true, result: { pid: 2 } } }) + }) + + it('rejects malformed lines without throwing', () => { + expect(decodeMessage('not json').ok).toBe(false) + expect(decodeMessage('{"id":"1","ok":true}').ok).toBe(false) + }) + + it('keeps the trailing partial line when splitting a stream', () => { + const first = splitLines('', '{"id":1}\n{"id":') + expect(first.lines).toEqual(['{"id":1}']) + + const second = splitLines(first.rest, '2}\n') + expect(second.lines).toEqual(['{"id":2}']) + expect(second.rest).toBe('') + }) +}) + +describe('forwarded environment', () => { + it('forwards only the variables the helper needs', () => { + const env = collectForwardedEnv({ + HOME: '/Users/dev', + HTTPS_PROXY: 'http://127.0.0.1:7890', + HTTPS_PROXY_VALUE_EXTRA: 'ignored', + PATH: '/usr/bin', + SECRET_TOKEN: 'nope', + }) + + expect(env).toEqual({ + HOME: '/Users/dev', + HTTPS_PROXY: 'http://127.0.0.1:7890', + }) + }) + + it('skips unset and empty values', () => { + expect(collectForwardedEnv({ HOME: '', USER: 'dev' })).toEqual({ USER: 'dev' }) + }) +}) + +describe('helper over a socket', () => { + it('edits the hosts file through the helper and restores it when the client leaves', async () => { + const socketPath = tempSocket() + const hostsPath = createTempHosts() + + const helper = await startHelper({ + socketPath, + token: 'secret-token', + caddyBin: process.execPath, + caddyConfigPath: hostsPath, + // The path guard checks ownership; this test runs without sudo, so the + // invoking user owns both paths. + ownerUid: process.getuid?.(), + deps: helperDeps(hostsPath), + log: () => {}, + }) + cleanups.push(() => helper.close()) + + const client = await connectClient({ socketPath, token: 'secret-token', timeoutMs: 5000 }) + cleanups.push(() => client.close()) + + await client.call('hosts.add', { ip: '127.0.0.1', host: 'test.abc.com' }) + expect(readFileSync(hostsPath, 'utf8')).toContain('test.abc.com') + + await client.close() + await new Promise(resolve => setTimeout(resolve, 150)) + + expect(readFileSync(hostsPath, 'utf8')).not.toContain('test.abc.com') + }) + + it('sweeps entries a killed session left behind, before accepting clients', async () => { + const socketPath = tempSocket() + const hostsPath = createTempHosts('127.0.0.1\tlocalhost\n127.0.0.1 stale.abc.com # uhrp\n') + + const helper = await startHelper({ + socketPath, + token: 'sweep', + caddyBin: process.execPath, + caddyConfigPath: hostsPath, + // The path guard checks ownership; this test runs without sudo. + ownerUid: process.getuid?.(), + deps: helperDeps(hostsPath), + log: () => {}, + }) + cleanups.push(() => helper.close()) + + // The helper cleans up during start-up, so the stale entry is already gone. + expect(readFileSync(hostsPath, 'utf8')).not.toContain('stale.abc.com') + }) + + it('refuses a client that presents the wrong token', async () => { + const socketPath = tempSocket() + const hostsPath = createTempHosts() + const helper = await startHelper({ + socketPath, + token: 'right', + caddyBin: process.execPath, + caddyConfigPath: hostsPath, + // The path guard checks ownership; this test runs without sudo. + ownerUid: process.getuid?.(), + deps: helperDeps(hostsPath), + log: () => {}, + }) + cleanups.push(() => helper.close()) + + await expect( + connectClient({ socketPath, token: 'wrong', timeoutMs: 800, retryIntervalMs: 50 }), + ).rejects.toThrow(/rejected the handshake|invalid token|not reachable/) + }) + + it('reports a failing primitive as a rejected call', async () => { + const socketPath = tempSocket() + const hostsPath = createTempHosts() + + const helper = await startHelper({ + socketPath, + token: 'failing', + caddyBin: process.execPath, + caddyConfigPath: hostsPath, + // The path guard checks ownership; this test runs without sudo. + ownerUid: process.getuid?.(), + deps: { + ...helperDeps(hostsPath), + writeHosts: () => { + throw new Error('EACCES: permission denied, open /etc/hosts') + }, + }, + log: () => {}, + }) + cleanups.push(() => helper.close()) + + const client = await connectClient({ socketPath, token: 'failing', timeoutMs: 5000 }) + cleanups.push(() => client.close()) + + await expect( + client.call('hosts.add', { ip: '127.0.0.1', host: 'fail.abc.com' }), + ).rejects.toThrow('EACCES: permission denied') + }) + + it('does not leave a socket file behind after the helper closes', async () => { + const socketPath = tempSocket() + const hostsPath = createTempHosts() + const helper = await startHelper({ + socketPath, + token: 'socket', + caddyBin: process.execPath, + caddyConfigPath: hostsPath, + // The path guard checks ownership; this test runs without sudo. + ownerUid: process.getuid?.(), + deps: helperDeps(hostsPath), + log: () => {}, + }) + + expect(existsSync(socketPath)).toBe(true) + await helper.close() + }) +}) diff --git a/test/privileged/runtime.test.ts b/test/privileged/runtime.test.ts new file mode 100644 index 0000000..8a1f548 --- /dev/null +++ b/test/privileged/runtime.test.ts @@ -0,0 +1,252 @@ +import type { HelperEvent } from '../../src/privileged/protocol' +import type { CaddyChild, HelperDeps } from '../../src/privileged/server' +import { describe, expect, it } from 'vitest' +import { createHelperRuntime } from '../../src/privileged/server' + +interface Harness { + deps: HelperDeps + hostsWrites: string[] + flushCount: () => number + stoppedCaddy: () => number + startedArgs: () => string[] + emit: (event: HelperEvent) => void +} + +function createHarness(initialHosts: string): Harness { + let hosts = initialHosts + const hostsWrites: string[] = [] + let flushes = 0 + let stops = 0 + let started: string[] = [] + let emit: (event: HelperEvent) => void = () => {} + + const deps: HelperDeps = { + readHosts: () => hosts, + writeHosts: (content) => { + hosts = content + hostsWrites.push(content) + }, + flushDns: async () => { + flushes++ + }, + freePort: async () => [4242], + caddyBin: '/fake/caddy', + caddyConfigPath: '/fake/Caddyfile', + caddyCwd: '/', + caddyEnv: {}, + trustLocalCa: async () => ({ trusted: true, output: '' }), + startCaddy: (next): CaddyChild => { + started = [deps.caddyBin, 'run', '--adapter', 'caddyfile', '--config', deps.caddyConfigPath] + emit = next + return { pid: 9876, kill: () => true } + }, + stopCaddy: async () => { + stops++ + }, + log: () => {}, + } + + return { + deps, + hostsWrites, + flushCount: () => flushes, + stoppedCaddy: () => stops, + startedArgs: () => started, + emit: event => emit(event), + } +} + +const HOSTS = '127.0.0.1\tlocalhost\n' + +describe('helper runtime', () => { + it('adds the requested host mapping and flushes DNS', async () => { + const harness = createHarness(HOSTS) + const runtime = createHelperRuntime(harness.deps) + + const response = await runtime.handle({ + id: 1, + method: 'hosts.add', + params: { ip: '127.0.0.1', host: 'test.abc.com' }, + }) + + expect(response).toEqual({ id: 1, ok: true }) + expect(harness.hostsWrites.at(-1)).toContain('127.0.0.1 test.abc.com') + expect(harness.flushCount()).toBe(1) + }) + + it('rejects a hostname that could inject a second hosts line', async () => { + const harness = createHarness(HOSTS) + const runtime = createHelperRuntime(harness.deps) + + const response = await runtime.handle({ + id: 1, + method: 'hosts.add', + params: { ip: '127.0.0.1', host: 'evil.test\n0.0.0.0 bank.example' }, + }) + + expect(response.ok).toBe(false) + expect(harness.hostsWrites).toHaveLength(0) + }) + + it('rejects an IP that is not an address', async () => { + const harness = createHarness(HOSTS) + const runtime = createHelperRuntime(harness.deps) + + const response = await runtime.handle({ + id: 1, + method: 'hosts.add', + params: { ip: 'not-an-ip', host: 'test.abc.com' }, + }) + + expect(response.ok).toBe(false) + }) + + it('removes the mapping again on request', async () => { + const harness = createHarness(HOSTS) + const runtime = createHelperRuntime(harness.deps) + + await runtime.handle({ id: 1, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'test.abc.com' } }) + await runtime.handle({ id: 2, method: 'hosts.remove', params: { ip: '127.0.0.1', host: 'test.abc.com' } }) + + expect(harness.hostsWrites.at(-1)).not.toContain('test.abc.com') + }) + + it('stops Caddy and drops its own mappings when the client disconnects', async () => { + const harness = createHarness(HOSTS) + const runtime = createHelperRuntime(harness.deps) + const client = {} + + runtime.attach(client) + await runtime.handle({ id: 1, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'test.abc.com' } }) + await runtime.handle({ id: 2, method: 'caddy.start' }) + + await runtime.detach(client) + + expect(harness.stoppedCaddy()).toBe(1) + expect(harness.hostsWrites.at(-1)).not.toContain('test.abc.com') + }) + + it('keeps the machine untouched while another client is still attached', async () => { + const harness = createHarness(HOSTS) + const runtime = createHelperRuntime(harness.deps) + const first = {} + const second = {} + + runtime.attach(first) + runtime.attach(second) + await runtime.handle({ id: 1, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'test.abc.com' } }) + + await runtime.detach(first) + + expect(harness.stoppedCaddy()).toBe(0) + expect(harness.hostsWrites.at(-1)).toContain('test.abc.com') + + await runtime.detach(second) + expect(harness.stoppedCaddy()).toBe(1) + }) + + it('forwards Caddy output to attached clients', async () => { + const harness = createHarness(HOSTS) + const runtime = createHelperRuntime(harness.deps) + const writes: string[] = [] + const client = { + destroyed: false, + write: (line: string) => { + writes.push(line) + return true + }, + } + + runtime.attach(client) + await runtime.handle({ id: 1, method: 'caddy.start' }) + harness.emit({ type: 'event', kind: 'caddy.stderr', data: 'caddy proxying' }) + + expect(writes.join('')).toContain('caddy proxying') + }) + + it('starts Caddy with the binary and Caddyfile fixed at start-up', async () => { + const harness = createHarness(HOSTS) + const runtime = createHelperRuntime(harness.deps) + + await runtime.handle({ id: 1, method: 'caddy.start' }) + + expect(harness.startedArgs()).toEqual([ + '/fake/caddy', + 'run', + '--adapter', + 'caddyfile', + '--config', + '/fake/Caddyfile', + ]) + }) + + it('returns a structured error when writing the hosts file fails', async () => { + const harness = createHarness(HOSTS) + harness.deps.writeHosts = () => { + throw new Error('EACCES: permission denied') + } + const runtime = createHelperRuntime(harness.deps) + + const response = await runtime.handle({ + id: 7, + method: 'hosts.add', + params: { ip: '127.0.0.1', host: 'test.abc.com' }, + }) + + expect(response).toEqual({ id: 7, ok: false, error: { message: 'EACCES: permission denied' } }) + }) + + it('keeps the hosts edit when only the DNS flush fails', async () => { + const harness = createHarness(HOSTS) + harness.deps.flushDns = async () => { + throw new Error('No matching processes belonging to you were found') + } + const runtime = createHelperRuntime(harness.deps) + + const response = await runtime.handle({ + id: 8, + method: 'hosts.add', + params: { ip: '127.0.0.1', host: 'test.abc.com' }, + }) + + expect(response).toEqual({ id: 8, ok: true }) + expect(harness.hostsWrites.at(-1)).toContain('test.abc.com') + }) + + it('reports pids released for a port', async () => { + const harness = createHarness(HOSTS) + const runtime = createHelperRuntime(harness.deps) + + const response = await runtime.handle({ id: 3, method: 'port.free', params: { port: 443 } }) + + expect(response).toEqual({ id: 3, ok: true, result: { killed: [4242] } }) + }) + + it('refuses to release a port the plugin never binds', async () => { + const harness = createHarness(HOSTS) + const runtime = createHelperRuntime(harness.deps) + + const response = await runtime.handle({ id: 4, method: 'port.free', params: { port: 22 } }) + + expect(response.ok).toBe(false) + }) + + it('resets teardown when a new client attaches after cleanup', async () => { + const harness = createHarness(HOSTS) + const runtime = createHelperRuntime(harness.deps) + const first = {} + const second = {} + + runtime.attach(first) + await runtime.handle({ id: 1, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'a.abc.com' } }) + await runtime.detach(first) + + runtime.attach(second) + await runtime.handle({ id: 2, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'b.abc.com' } }) + + expect(harness.stoppedCaddy()).toBe(1) + + await runtime.detach(second) + expect(harness.stoppedCaddy()).toBe(2) + }) +}) diff --git a/test/privileged/shared-caddy.test.ts b/test/privileged/shared-caddy.test.ts new file mode 100644 index 0000000..dcd190a --- /dev/null +++ b/test/privileged/shared-caddy.test.ts @@ -0,0 +1,96 @@ +import type { CaddyChild, HelperDeps } from '../../src/privileged/server' +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { beforeEach, describe, expect, it } from 'vitest' +import { createHelperRuntime } from '../../src/privileged/server' + +/** + * Several dev servers share one helper, so the Caddy it owns belongs to all of + * them. These cover the guarantees that sharing depends on. + */ +function createDeps(overrides: Partial = {}) { + const dir = mkdtempSync(join(tmpdir(), 'uhrp-shared-')) + const hostsPath = join(dir, 'hosts') + writeFileSync(hostsPath, '127.0.0.1\tlocalhost\n') + + const started: Array = [] + const freedPorts: Array<{ port: number, keepPid?: number }> = [] + + const deps: HelperDeps = { + readHosts: () => readFileSync(hostsPath, 'utf8'), + writeHosts: (content: string) => writeFileSync(hostsPath, content), + flushDns: async () => {}, + freePort: async (port, keepPid) => { + freedPorts.push({ port, keepPid }) + return [] + }, + caddyBin: '/usr/bin/false', + caddyConfigPath: join(dir, 'Caddyfile'), + caddyCwd: dir, + caddyEnv: {}, + trustLocalCa: async () => ({ trusted: true, output: '' }), + startCaddy: (): CaddyChild => { + const child: CaddyChild & { running?: boolean } = { pid: 9000 + started.length, kill: () => true, running: true } + started.push(child) + return child + }, + stopCaddy: async () => {}, + log: () => {}, + ...overrides, + } + + return { deps, started, freedPorts, dir } +} + +let ctx: ReturnType + +beforeEach(() => { + ctx = createDeps() +}) + +describe('caddy.start is idempotent', () => { + it('spawns once and reports the running pid to later callers', async () => { + const runtime = createHelperRuntime(ctx.deps) + + const first = await runtime.handle({ id: 1, method: 'caddy.start' }) + const second = await runtime.handle({ id: 2, method: 'caddy.start' }) + + expect(ctx.started).toHaveLength(1) + expect(first).toMatchObject({ ok: true, result: { started: true } }) + expect(second).toMatchObject({ ok: true, result: { started: false } }) + expect((second as { result: { pid: number } }).result.pid) + .toBe((first as { result: { pid: number } }).result.pid) + }) + + it('spawns again after Caddy exited', async () => { + const runtime = createHelperRuntime(ctx.deps) + await runtime.handle({ id: 1, method: 'caddy.start' }) + + // A crashed Caddy must not be reported as running. + ctx.started[0].running = false + await runtime.handle({ id: 2, method: 'caddy.start' }) + + expect(ctx.started).toHaveLength(2) + }) +}) + +describe('port.free protects the shared Caddy', () => { + it('passes the running Caddy pid so the helper can skip it', async () => { + const runtime = createHelperRuntime(ctx.deps) + const started = await runtime.handle({ id: 1, method: 'caddy.start' }) + const pid = (started as { result: { pid: number } }).result.pid + + await runtime.handle({ id: 2, method: 'port.free', params: { port: 443 } }) + + expect(ctx.freedPorts).toEqual([{ port: 443, keepPid: pid }]) + }) + + it('passes no pid when Caddy is not running', async () => { + const runtime = createHelperRuntime(ctx.deps) + + await runtime.handle({ id: 1, method: 'port.free', params: { port: 443 } }) + + expect(ctx.freedPorts).toEqual([{ port: 443, keepPid: undefined }]) + }) +}) diff --git a/test/privileged/start-guard.test.ts b/test/privileged/start-guard.test.ts new file mode 100644 index 0000000..cf3eb20 --- /dev/null +++ b/test/privileged/start-guard.test.ts @@ -0,0 +1,96 @@ +import type { HelperDeps } from '../../src/privileged/server' +import { chmodSync, existsSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { startHelper } from '../../src/privileged/bootstrap' + +/** + * The helper runs as root and takes the Caddy binary and Caddyfile paths from + * a process running as the developer. If either can be rewritten by another + * local user, starting is a privilege gain, so it must refuse instead. + */ +function stubDeps(): HelperDeps { + return { + readHosts: () => '127.0.0.1\tlocalhost\n', + writeHosts: () => {}, + flushDns: async () => {}, + freePort: async () => [], + caddyBin: '/usr/bin/false', + caddyConfigPath: '/dev/null', + caddyCwd: '/tmp', + caddyEnv: {}, + trustLocalCa: async () => ({ trusted: true, output: '' }), + startCaddy: () => ({ pid: 1, kill: () => true, running: true }), + stopCaddy: async () => {}, + log: () => {}, + } +} + +describe('startHelper refuses unsafe paths', () => { + let dir: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'uhrp-start-guard-')) + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('starts when the Caddyfile is only writable by its owner', async () => { + const caddyfile = join(dir, 'Caddyfile') + writeFileSync(caddyfile, '') + chmodSync(caddyfile, 0o600) + + const helper = await startHelper({ + socketPath: join(dir, 'helper.sock'), + token: 'guard-token', + caddyBin: '/usr/bin/false', + caddyConfigPath: caddyfile, + ownerUid: process.getuid?.(), + exitOnIdle: false, + deps: stubDeps(), + log: () => {}, + }) + + expect(existsSync(join(dir, 'helper.sock'))).toBe(true) + await helper.close() + }) + + it('refuses a group-writable Caddyfile', async () => { + const caddyfile = join(dir, 'Caddyfile') + writeFileSync(caddyfile, '') + chmodSync(caddyfile, 0o660) + + await expect(startHelper({ + socketPath: join(dir, 'helper.sock'), + token: 'guard-token', + caddyBin: '/usr/bin/false', + caddyConfigPath: caddyfile, + ownerUid: process.getuid?.(), + exitOnIdle: false, + deps: stubDeps(), + log: () => {}, + })).rejects.toThrow(/group- or world-writable/) + }) + + it('refuses a Caddyfile that is a symbolic link', async () => { + const real = join(dir, 'real-Caddyfile') + const link = join(dir, 'Caddyfile') + writeFileSync(real, '') + chmodSync(real, 0o600) + symlinkSync(real, link) + + await expect(startHelper({ + socketPath: join(dir, 'helper.sock'), + token: 'guard-token', + caddyBin: '/usr/bin/false', + caddyConfigPath: link, + ownerUid: process.getuid?.(), + exitOnIdle: false, + deps: stubDeps(), + log: () => {}, + })).rejects.toThrow(/symbolic link/) + }) +}) diff --git a/test/privileged/storage-handback.test.ts b/test/privileged/storage-handback.test.ts new file mode 100644 index 0000000..e312ddb --- /dev/null +++ b/test/privileged/storage-handback.test.ts @@ -0,0 +1,65 @@ +import type { CaddyChild, HelperDeps } from '../../src/privileged/server' +import { mkdirSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' +import { describe, expect, it } from 'vitest' +import { createHelperRuntime } from '../../src/privileged/server' + +function baseDeps(overrides: Partial = {}): HelperDeps { + return { + readHosts: () => '', + writeHosts: () => {}, + flushDns: async () => {}, + freePort: async () => [], + caddyBin: '/fake/caddy', + caddyConfigPath: '/fake/Caddyfile', + caddyCwd: '/', + caddyEnv: {}, + trustLocalCa: async () => ({ trusted: true, output: '' }), + startCaddy: (): CaddyChild => ({ pid: 1, kill: () => true }), + stopCaddy: async () => {}, + log: () => {}, + ...overrides, + } +} + +/** + * Caddy runs as root inside the helper, so everything it writes under its + * storage directory is root-owned. The helper must hand the tree back once + * Caddy stops, otherwise the developer cannot read its PKI between runs. + */ +describe('caddy storage handback on stop', () => { + it('returns the storage tree to the configured owner when caddy stops', async () => { + const storage = join(tmpdir(), `uhrp-handback-${process.pid}`) + const nested = join(storage, 'pki', 'authorities', 'local') + mkdirSync(nested, { recursive: true }) + writeFileSync(join(nested, 'root.crt'), 'certificate') + + const uid = process.getuid?.() ?? 0 + const gid = process.getgid?.() ?? 0 + + const runtime = createHelperRuntime(baseDeps({ + caddyStoragePath: storage, + storageOwner: { uid, gid }, + })) + + await runtime.handle({ id: 1, method: 'caddy.start' }) + await runtime.handle({ id: 2, method: 'caddy.stop' }) + await new Promise(resolve => setTimeout(resolve, 200)) + + expect(statSync(storage).uid).toBe(uid) + expect(statSync(join(nested, 'root.crt')).uid).toBe(uid) + }) + + it('stops caddy even when the storage directory is gone', async () => { + const runtime = createHelperRuntime(baseDeps({ + caddyStoragePath: join(tmpdir(), `uhrp-missing-${process.pid}`), + storageOwner: { uid: 0, gid: 0 }, + })) + + const response = await runtime.handle({ id: 3, method: 'caddy.stop' }) + + expect(response).toEqual({ id: 3, ok: true }) + }) +}) diff --git a/test/privileged/sudo-sigint.test.ts b/test/privileged/sudo-sigint.test.ts new file mode 100644 index 0000000..19f9ba3 --- /dev/null +++ b/test/privileged/sudo-sigint.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { authenticateSudo } from '../../src/privileged/launch' + +/** + * `sudo -v` inherits the terminal, so Ctrl+C reaches the dev server too. The + * prompt has to absorb that signal: without a handler the default action + * terminates the process, which reads as "the proxy killed my dev server" when + * the user only meant to skip the prompt. + * + * The cancellation branch itself is not covered here. Sending a real SIGINT to + * the runner would take the suite down, and aiming one at a child process does + * not reproduce the group-wide delivery a terminal Ctrl+C performs. What is + * pinned down is the handler's lifetime, which is the part that regresses + * silently: a leaked handler would swallow the user's later Ctrl+C. + */ +describe('sudo prompt and SIGINT', () => { + it('installs a SIGINT handler only for the duration of the prompt', async () => { + const before = process.listenerCount('SIGINT') + + // A command that fails immediately stands in for a cancelled prompt. + const result = await authenticateSudo('false', { target: 'sigint.abc.com', https: true, quiet: true }) + + expect(result.ok).toBe(false) + expect(result.denied).toBe(true) + + // The handler must not outlive the prompt, or it would swallow the user's + // later Ctrl+C when they actually want to stop the dev server. + expect(process.listenerCount('SIGINT')).toBe(before) + }) + + it('reports a plain failure differently from a cancellation', async () => { + const result = await authenticateSudo('false', { target: 'sigint.abc.com', https: true, quiet: true }) + + // No signal was sent, so this is a failed password, not a cancellation. + expect(result.message).toContain('administrator permission is required') + }) + + it('returns ok without prompting when sudo needs no password', async () => { + // `true` exits 0, which is what a cached credential looks like. + const result = await authenticateSudo('true', { target: 'sigint.abc.com', https: true, quiet: true }) + + expect(result.ok).toBe(true) + expect(result.denied).toBeUndefined() + }) + + it('does not leave the handler behind when the prompt throws', async () => { + const before = process.listenerCount('SIGINT') + + // A missing binary makes spawn emit `error` rather than `exit`. + await authenticateSudo('/nonexistent-sudo-binary', { target: 'sigint.abc.com', https: true, quiet: true }) + + expect(process.listenerCount('SIGINT')).toBe(before) + }) +}) diff --git a/test/privileged/teardown-generation.test.ts b/test/privileged/teardown-generation.test.ts new file mode 100644 index 0000000..b63719f --- /dev/null +++ b/test/privileged/teardown-generation.test.ts @@ -0,0 +1,65 @@ +import type { CaddyChild, HelperDeps } from '../../src/privileged/server' +import { describe, expect, it } from 'vitest' +import { createHelperRuntime } from '../../src/privileged/server' + +function createDeps(initial: string): { + deps: HelperDeps + content: () => string + stopCount: () => number +} { + let hosts = initial + let stops = 0 + + const deps: HelperDeps = { + readHosts: () => hosts, + writeHosts: (content) => { + hosts = content + }, + flushDns: async () => { + // Slow enough for a new client to attach while teardown is in flight. + await new Promise(resolve => setTimeout(resolve, 30)) + }, + freePort: async () => [], + caddyBin: '/fake/caddy', + caddyConfigPath: '/fake/Caddyfile', + caddyCwd: '/', + caddyEnv: {}, + trustLocalCa: async () => ({ trusted: true, output: '' }), + startCaddy: (): CaddyChild => ({ pid: 1, kill: () => true }), + stopCaddy: async () => { + stops++ + }, + log: () => {}, + } + + return { deps, content: () => hosts, stopCount: () => stops } +} + +/** + * A dev server restarting quickly disconnects and reconnects the helper. The + * teardown of the old session must not delete the new session's entries or stop + * the Caddy the new session just started. + */ +describe('teardown vs a reconnecting client', () => { + it('leaves the new session untouched when a client attaches mid-teardown', async () => { + const { deps, content, stopCount } = createDeps('127.0.0.1\tlocalhost\n') + const runtime = createHelperRuntime(deps) + + const firstClient = {} + runtime.attach(firstClient) + await runtime.handle({ id: 1, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'a.abc.com' } }) + + // Disconnect, then reconnect before the teardown finished. + const detaching = runtime.detach(firstClient) + const secondClient = {} + runtime.attach(secondClient) + await runtime.handle({ id: 2, method: 'hosts.add', params: { ip: '127.0.0.1', host: 'b.abc.com' } }) + + await detaching + await new Promise(resolve => setTimeout(resolve, 80)) + + const hosts = content() + expect(hosts).toContain('b.abc.com') + expect(stopCount()).toBe(0) + }) +}) diff --git a/test/privileged/terminal-mode.test.ts b/test/privileged/terminal-mode.test.ts new file mode 100644 index 0000000..dd33a7f --- /dev/null +++ b/test/privileged/terminal-mode.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { authenticateSudo } from '../../src/privileged/launch' + +/** + * Vite turns on raw mode for its `h + enter` shortcuts. In raw mode the tty + * does no line editing, so the Enter that should submit the password reaches + * sudo as a bare carriage return and the read never completes — the user sees + * the password "not submit" until they press Enter again. + * + * The prompt therefore switches the terminal back to cooked mode and restores + * whatever it found, so Vite's shortcuts keep working afterwards. + * + * The raw-mode switch itself is not covered here: the test runner's stdin is a + * pipe, so the tty branch is skipped. What these pin down is that the guard + * works and the prompt leaves the stream alone when there is no terminal — + * a regression there would break non-interactive runs (CI, editors) entirely. + */ +describe('terminal mode during the password prompt', () => { + it('works when stdin is not a terminal', async () => { + // The test runner's stdin is a pipe, not a tty. + expect(process.stdin.isTTY).toBeFalsy() + + const result = await authenticateSudo('false', { target: 'tty.abc.com', https: true, quiet: true }) + + expect(result.ok).toBe(false) + expect(result.denied).toBe(true) + }) + + it('leaves the stream usable after the prompt', async () => { + const before = process.stdin.isPaused() + + await authenticateSudo('false', { target: 'tty.abc.com', https: true, quiet: true }) + + // Switching modes must not resume or pause the stream as a side effect. + expect(process.stdin.isPaused()).toBe(before) + }) +}) diff --git a/test/privileged/windows-elevation.test.ts b/test/privileged/windows-elevation.test.ts new file mode 100644 index 0000000..352c2bc --- /dev/null +++ b/test/privileged/windows-elevation.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { buildElevationCommand, quotePowerShell } from '../../src/privileged/windows-elevation' + +describe('windows elevation command', () => { + it('starts the executable with the runas verb', () => { + const command = buildElevationCommand({ + file: 'C:\\Program Files\\nodejs\\node.exe', + args: ['C:\\app\\privileged-helper.js', '--socket', '\\\\.\\pipe\\uhrp-1', '--token', 'abc'], + }) + + expect(command).toContain('Start-Process') + expect(command).toContain('-Verb RunAs') + expect(command).toContain(`'C:\\Program Files\\nodejs\\node.exe'`) + expect(command).toContain(`'\\\\.\\pipe\\uhrp-1'`) + expect(command).toContain(`'abc'`) + }) + + it('omits the argument list when there is nothing to pass', () => { + const command = buildElevationCommand({ file: 'helper.exe', args: [] }) + + expect(command).not.toContain('-ArgumentList') + expect(command).toContain('-Verb RunAs') + }) + + it('passes the working directory when provided', () => { + const command = buildElevationCommand({ file: 'helper.exe', args: [], cwd: 'C:\\work dir' }) + + expect(command).toContain(`-WorkingDirectory 'C:\\work dir'`) + }) + + it('doubles single quotes so PowerShell treats them literally', () => { + expect(quotePowerShell(`C:\\it's here`)).toBe(`'C:\\it''s here'`) + }) + + it('keeps a path with an apostrophe intact through escaping', () => { + const command = buildElevationCommand({ file: `C:\\o'brien\\node.exe`, args: [] }) + + expect(command).toContain(`'C:\\o''brien\\node.exe'`) + // An unescaped quote would terminate the literal and break the command. + expect(command).not.toContain(`'C:\\o'brien\\node.exe'`) + }) +}) diff --git a/test/vite-config.test.ts b/test/vite-config.test.ts new file mode 100644 index 0000000..643bc18 --- /dev/null +++ b/test/vite-config.test.ts @@ -0,0 +1,33 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { isPrivilegeDenied, markPrivilegeDenied, resetPrivilegeDenied } from '../src/privilege-state' + +beforeEach(() => { + resetPrivilegeDenied() +}) + +/** + * A hot restart rebuilds the dev server inside the same process (Vite calls + * `Object.assign(server, newServer)`), so the decline has to live at module + * scope. These cover the state that decision depends on. + */ +describe('privilege denial state', () => { + it('starts as not denied', () => { + expect(isPrivilegeDenied()).toBe(false) + }) + + it('stays denied across repeated reads', () => { + markPrivilegeDenied() + + // A rebuild re-runs configureServer but must keep seeing the same answer. + expect(isPrivilegeDenied()).toBe(true) + expect(isPrivilegeDenied()).toBe(true) + }) + + it('is not cleared by anything the plugin does per instance', () => { + markPrivilegeDenied() + + // Nothing in the normal flow resets it; only a new process (or the test + // hook) can. + expect(isPrivilegeDenied()).toBe(true) + }) +})