Skip to content

Commit aeb63ae

Browse files
authored
feat(ci): add SimDeck proxy worker and proxy link support (#66)
* refactor: simplify simdeck service lifecycle * fix: harden service launch environment * feat: add CI proxy links and password gating * fix(ci): format worker generated types
1 parent 2c6577e commit aeb63ae

11 files changed

Lines changed: 17738 additions & 4 deletions

File tree

‎actions/run-android-comment-session/action.yml‎

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,18 @@ inputs:
8686
description: Verify the public Cloudflare Tunnel health endpoint before continuing.
8787
required: false
8888
default: "false"
89+
ci_proxy_url:
90+
description: Optional SimDeck CI proxy Worker URL, such as https://ci.simdeck.sh.
91+
required: false
92+
default: https://simdeck-ci-proxy.djdeveloperr.workers.dev
93+
proxy_links:
94+
description: Post SimDeck CI proxy links instead of raw Cloudflare Tunnel links.
95+
required: false
96+
default: "true"
97+
session_password:
98+
description: Optional password required by the SimDeck CI proxy before it opens the tunnel.
99+
required: false
100+
default: ""
89101

90102
runs:
91103
using: composite
@@ -112,6 +124,9 @@ runs:
112124
INPUT_ANDROID_ARCH_VALUE: ${{ inputs.android_arch }}
113125
INPUT_ANDROID_BUILD_TOOLS_VALUE: ${{ inputs.android_build_tools }}
114126
INPUT_PUBLIC_HEALTH_CHECK_VALUE: ${{ inputs.public_health_check }}
127+
INPUT_CI_PROXY_URL_VALUE: ${{ inputs.ci_proxy_url }}
128+
INPUT_PROXY_LINKS_VALUE: ${{ inputs.proxy_links }}
129+
INPUT_SESSION_PASSWORD_VALUE: ${{ inputs.session_password }}
115130
KEEPALIVE_SECONDS_VALUE: ${{ inputs.keepalive_seconds }}
116131
BUILD_WORKFLOW_VALUE: ${{ inputs.build_workflow }}
117132
ARTIFACT_PREFIX_VALUE: ${{ inputs.artifact_prefix }}
@@ -148,6 +163,12 @@ runs:
148163
write_env "SIMDECK_ANDROID_TARGET" "${INPUT_ANDROID_TARGET_VALUE}"
149164
write_env "SIMDECK_ANDROID_ARCH" "${INPUT_ANDROID_ARCH_VALUE}"
150165
write_env "SIMDECK_ANDROID_BUILD_TOOLS" "${INPUT_ANDROID_BUILD_TOOLS_VALUE}"
166+
write_env "SIMDECK_CI_PROXY_URL" "${INPUT_CI_PROXY_URL_VALUE}"
167+
write_env "SIMDECK_PROXY_LINKS" "${INPUT_PROXY_LINKS_VALUE}"
168+
write_env "SIMDECK_SESSION_PASSWORD" "${INPUT_SESSION_PASSWORD_VALUE}"
169+
if [[ -n "${INPUT_SESSION_PASSWORD_VALUE}" ]]; then
170+
echo "::add-mask::${INPUT_SESSION_PASSWORD_VALUE}"
171+
fi
151172
write_env "INPUT_PUBLIC_HEALTH_CHECK" "${INPUT_PUBLIC_HEALTH_CHECK_VALUE}"
152173
write_env "KEEPALIVE_SECONDS" "${KEEPALIVE_SECONDS_VALUE}"
153174
write_env "BUILD_WORKFLOW" "${BUILD_WORKFLOW_VALUE}"
@@ -186,6 +207,12 @@ runs:
186207
187208
echo "SIMDECK_STREAM_PROFILE=${stream_profile}" >> "${GITHUB_ENV}"
188209
echo "SIMDECK_PUBLIC_HEALTH_CHECK=${public_health_check}" >> "${GITHUB_ENV}"
210+
if [[ -n "${SIMDECK_SESSION_PASSWORD:-}" ]]; then
211+
if [[ "${SIMDECK_PROXY_LINKS}" != "true" || -z "${SIMDECK_CI_PROXY_URL:-}" ]]; then
212+
echo "session_password requires proxy_links: true and a non-empty ci_proxy_url." >&2
213+
exit 1
214+
fi
215+
fi
189216
echo "Stream profile: ${stream_profile}"
190217
191218
- name: Install Android SDK packages
@@ -390,6 +417,72 @@ runs:
390417
391418
echo "url=${tunnel_url}" >> "${GITHUB_OUTPUT}"
392419
echo "access_token=${access_token}" >> "${GITHUB_OUTPUT}"
420+
public_url="${tunnel_url}?simdeckToken=${access_token}"
421+
ci_proxy_url="${SIMDECK_CI_PROXY_URL%/}"
422+
if [[ "${SIMDECK_PROXY_LINKS}" == "true" && -n "${ci_proxy_url}" ]]; then
423+
encoded_redirect="$(SIMDECK_TUNNEL_URL="${tunnel_url}" \
424+
SIMDECK_ACCESS_TOKEN="${access_token}" \
425+
SIMDECK_SESSION_PASSWORD="${SIMDECK_SESSION_PASSWORD:-}" \
426+
SIMDECK_PLATFORM="android" \
427+
SIMDECK_REPO="${REPO}" \
428+
SIMDECK_PR_NUMBER="${PR_NUMBER}" \
429+
SIMDECK_RUN_ID="${GITHUB_RUN_ID}" \
430+
SIMDECK_KEEPALIVE_SECONDS="${KEEPALIVE_SECONDS}" \
431+
node --input-type=module <<'NODE'
432+
import { webcrypto } from "node:crypto";
433+
434+
const env = process.env;
435+
const encoder = new TextEncoder();
436+
const base64url = (bytes) =>
437+
Buffer.from(bytes).toString("base64url");
438+
const keepalive = Number(env.SIMDECK_KEEPALIVE_SECONDS || "1800");
439+
const payload = {
440+
v: 1,
441+
upstream: env.SIMDECK_TUNNEL_URL,
442+
platform: env.SIMDECK_PLATFORM,
443+
repo: env.SIMDECK_REPO,
444+
pr: env.SIMDECK_PR_NUMBER,
445+
runId: env.SIMDECK_RUN_ID,
446+
expiresAt: new Date(Date.now() + (keepalive + 600) * 1000).toISOString(),
447+
};
448+
449+
const password = env.SIMDECK_SESSION_PASSWORD || "";
450+
const token = env.SIMDECK_ACCESS_TOKEN || "";
451+
if (password) {
452+
const salt = webcrypto.getRandomValues(new Uint8Array(16));
453+
const iv = webcrypto.getRandomValues(new Uint8Array(12));
454+
const passwordBytes = encoder.encode(password);
455+
const material = new Uint8Array(passwordBytes.length + salt.length + 1);
456+
material.set(passwordBytes);
457+
material[passwordBytes.length] = 0;
458+
material.set(salt, passwordBytes.length + 1);
459+
const digest = await webcrypto.subtle.digest("SHA-256", material);
460+
const key = await webcrypto.subtle.importKey(
461+
"raw",
462+
digest,
463+
{ name: "AES-GCM", length: 256 },
464+
false,
465+
["encrypt"],
466+
);
467+
const ciphertext = new Uint8Array(
468+
await webcrypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoder.encode(token)),
469+
);
470+
payload.tokenCipher = {
471+
algorithm: "SHA256-SALTED+A256GCM",
472+
ciphertext: base64url(ciphertext),
473+
iv: base64url(iv),
474+
salt: base64url(salt),
475+
};
476+
} else {
477+
payload.token = token;
478+
}
479+
480+
process.stdout.write(base64url(Buffer.from(JSON.stringify(payload))));
481+
NODE
482+
)"
483+
public_url="${ci_proxy_url}?redirect=${encoded_redirect}"
484+
fi
485+
echo "public_url=${public_url}" >> "${GITHUB_OUTPUT}"
393486
394487
public_health_url="${tunnel_url}/api/health?simdeckToken=${access_token}"
395488
tunnel_host="${tunnel_url#https://}"
@@ -576,7 +669,7 @@ runs:
576669
fi
577670
578671
cat > comment.md <<'EOF'
579-
__MENTION__SimDeck Android session is ready: [Open SimDeck](${{ steps.stream.outputs.url }}?simdeckToken=${{ steps.stream.outputs.access_token }}&device=${{ steps.emulator.outputs.udid }})
672+
__MENTION__SimDeck Android session is ready: [Open SimDeck](${{ steps.stream.outputs.public_url }}&device=${{ steps.emulator.outputs.udid }})
580673
581674
The selected emulator is booted and the PR APK will launch here once its build artifact is installed.
582675
@@ -698,7 +791,7 @@ runs:
698791
shell: bash
699792
run: |
700793
cat > comment.md <<'EOF'
701-
SimDeck Android session is ready: [Open SimDeck](${{ steps.stream.outputs.url }}?simdeckToken=${{ steps.stream.outputs.access_token }}&device=${{ steps.android.outputs.udid }})
794+
SimDeck Android session is ready: [Open SimDeck](${{ steps.stream.outputs.public_url }}&device=${{ steps.android.outputs.udid }})
702795
703796
App: `${{ steps.android.outputs.package_name }}`
704797
Commit: `${{ steps.pr.outputs.sha }}`

‎actions/run-ios-comment-session/action.yml‎

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,18 @@ inputs:
8282
description: Verify the public Cloudflare Tunnel health endpoint before continuing.
8383
required: false
8484
default: "false"
85+
ci_proxy_url:
86+
description: Optional SimDeck CI proxy Worker URL, such as https://ci.simdeck.sh.
87+
required: false
88+
default: https://simdeck-ci-proxy.djdeveloperr.workers.dev
89+
proxy_links:
90+
description: Post SimDeck CI proxy links instead of raw Cloudflare Tunnel links.
91+
required: false
92+
default: "true"
93+
session_password:
94+
description: Optional password required by the SimDeck CI proxy before it opens the tunnel.
95+
required: false
96+
default: ""
8597

8698
runs:
8799
using: composite
@@ -106,6 +118,9 @@ runs:
106118
INPUT_DEVICE_STRATEGY_VALUE: ${{ inputs.device_strategy }}
107119
INPUT_SIMULATOR_CACHE_VALUE: ${{ inputs.simulator_cache }}
108120
INPUT_PUBLIC_HEALTH_CHECK_VALUE: ${{ inputs.public_health_check }}
121+
INPUT_CI_PROXY_URL_VALUE: ${{ inputs.ci_proxy_url }}
122+
INPUT_PROXY_LINKS_VALUE: ${{ inputs.proxy_links }}
123+
INPUT_SESSION_PASSWORD_VALUE: ${{ inputs.session_password }}
109124
KEEPALIVE_SECONDS_VALUE: ${{ inputs.keepalive_seconds }}
110125
BUILD_WORKFLOW_VALUE: ${{ inputs.build_workflow }}
111126
ARTIFACT_PREFIX_VALUE: ${{ inputs.artifact_prefix }}
@@ -138,6 +153,12 @@ runs:
138153
write_env "SIMDECK_VERSION" "${SIMDECK_VERSION_VALUE}"
139154
write_env "INPUT_STREAM_PROFILE" "${INPUT_STREAM_PROFILE_VALUE}"
140155
write_env "INPUT_SIMULATOR_NAME" "${INPUT_SIMULATOR_NAME_VALUE}"
156+
write_env "SIMDECK_CI_PROXY_URL" "${INPUT_CI_PROXY_URL_VALUE}"
157+
write_env "SIMDECK_PROXY_LINKS" "${INPUT_PROXY_LINKS_VALUE}"
158+
write_env "SIMDECK_SESSION_PASSWORD" "${INPUT_SESSION_PASSWORD_VALUE}"
159+
if [[ -n "${INPUT_SESSION_PASSWORD_VALUE}" ]]; then
160+
echo "::add-mask::${INPUT_SESSION_PASSWORD_VALUE}"
161+
fi
141162
write_env "INPUT_DEVICE_STRATEGY" "${INPUT_DEVICE_STRATEGY_VALUE}"
142163
write_env "INPUT_SIMULATOR_CACHE" "${INPUT_SIMULATOR_CACHE_VALUE}"
143164
write_env "INPUT_PUBLIC_HEALTH_CHECK" "${INPUT_PUBLIC_HEALTH_CHECK_VALUE}"
@@ -198,6 +219,12 @@ runs:
198219
echo "SIMDECK_DEVICE_STRATEGY=${device_strategy}" >> "${GITHUB_ENV}"
199220
echo "SIMDECK_STREAM_PROFILE=${stream_profile}" >> "${GITHUB_ENV}"
200221
echo "SIMDECK_PUBLIC_HEALTH_CHECK=${public_health_check}" >> "${GITHUB_ENV}"
222+
if [[ -n "${SIMDECK_SESSION_PASSWORD:-}" ]]; then
223+
if [[ "${SIMDECK_PROXY_LINKS}" != "true" || -z "${SIMDECK_CI_PROXY_URL:-}" ]]; then
224+
echo "session_password requires proxy_links: true and a non-empty ci_proxy_url." >&2
225+
exit 1
226+
fi
227+
fi
201228
echo "Simulator cache: ${simulator_cache}"
202229
echo "Preferred simulator: ${simulator_name:-<none>}"
203230
echo "Device strategy: ${device_strategy}"
@@ -354,6 +381,72 @@ runs:
354381
355382
echo "url=${tunnel_url}" >> "${GITHUB_OUTPUT}"
356383
echo "access_token=${access_token}" >> "${GITHUB_OUTPUT}"
384+
public_url="${tunnel_url}?simdeckToken=${access_token}"
385+
ci_proxy_url="${SIMDECK_CI_PROXY_URL%/}"
386+
if [[ "${SIMDECK_PROXY_LINKS}" == "true" && -n "${ci_proxy_url}" ]]; then
387+
encoded_redirect="$(SIMDECK_TUNNEL_URL="${tunnel_url}" \
388+
SIMDECK_ACCESS_TOKEN="${access_token}" \
389+
SIMDECK_SESSION_PASSWORD="${SIMDECK_SESSION_PASSWORD:-}" \
390+
SIMDECK_PLATFORM="ios" \
391+
SIMDECK_REPO="${REPO}" \
392+
SIMDECK_PR_NUMBER="${PR_NUMBER}" \
393+
SIMDECK_RUN_ID="${GITHUB_RUN_ID}" \
394+
SIMDECK_KEEPALIVE_SECONDS="${KEEPALIVE_SECONDS}" \
395+
node --input-type=module <<'NODE'
396+
import { webcrypto } from "node:crypto";
397+
398+
const env = process.env;
399+
const encoder = new TextEncoder();
400+
const base64url = (bytes) =>
401+
Buffer.from(bytes).toString("base64url");
402+
const keepalive = Number(env.SIMDECK_KEEPALIVE_SECONDS || "1800");
403+
const payload = {
404+
v: 1,
405+
upstream: env.SIMDECK_TUNNEL_URL,
406+
platform: env.SIMDECK_PLATFORM,
407+
repo: env.SIMDECK_REPO,
408+
pr: env.SIMDECK_PR_NUMBER,
409+
runId: env.SIMDECK_RUN_ID,
410+
expiresAt: new Date(Date.now() + (keepalive + 600) * 1000).toISOString(),
411+
};
412+
413+
const password = env.SIMDECK_SESSION_PASSWORD || "";
414+
const token = env.SIMDECK_ACCESS_TOKEN || "";
415+
if (password) {
416+
const salt = webcrypto.getRandomValues(new Uint8Array(16));
417+
const iv = webcrypto.getRandomValues(new Uint8Array(12));
418+
const passwordBytes = encoder.encode(password);
419+
const material = new Uint8Array(passwordBytes.length + salt.length + 1);
420+
material.set(passwordBytes);
421+
material[passwordBytes.length] = 0;
422+
material.set(salt, passwordBytes.length + 1);
423+
const digest = await webcrypto.subtle.digest("SHA-256", material);
424+
const key = await webcrypto.subtle.importKey(
425+
"raw",
426+
digest,
427+
{ name: "AES-GCM", length: 256 },
428+
false,
429+
["encrypt"],
430+
);
431+
const ciphertext = new Uint8Array(
432+
await webcrypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoder.encode(token)),
433+
);
434+
payload.tokenCipher = {
435+
algorithm: "SHA256-SALTED+A256GCM",
436+
ciphertext: base64url(ciphertext),
437+
iv: base64url(iv),
438+
salt: base64url(salt),
439+
};
440+
} else {
441+
payload.token = token;
442+
}
443+
444+
process.stdout.write(base64url(Buffer.from(JSON.stringify(payload))));
445+
NODE
446+
)"
447+
public_url="${ci_proxy_url}?redirect=${encoded_redirect}"
448+
fi
449+
echo "public_url=${public_url}" >> "${GITHUB_OUTPUT}"
357450
358451
public_health_url="${tunnel_url}/api/health?simdeckToken=${access_token}"
359452
tunnel_host="${tunnel_url#https://}"
@@ -676,7 +769,7 @@ runs:
676769
fi
677770
678771
cat > comment.md <<'EOF'
679-
__MENTION__SimDeck iOS session is ready: [Open SimDeck](${{ steps.stream.outputs.url }}?simdeckToken=${{ steps.stream.outputs.access_token }}&device=${{ env.SIMULATOR_UDID }})
772+
__MENTION__SimDeck iOS session is ready: [Open SimDeck](${{ steps.stream.outputs.public_url }}&device=${{ env.SIMULATOR_UDID }})
680773
681774
The selected simulator is booted and the PR app will launch here once its build artifact is installed.
682775
@@ -837,7 +930,7 @@ runs:
837930
shell: bash
838931
run: |
839932
cat > comment.md <<'EOF'
840-
SimDeck iOS session is ready: [Open SimDeck](${{ steps.stream.outputs.url }}?simdeckToken=${{ steps.stream.outputs.access_token }}&device=${{ steps.simulator.outputs.udid }})
933+
SimDeck iOS session is ready: [Open SimDeck](${{ steps.stream.outputs.public_url }}&device=${{ steps.simulator.outputs.udid }})
841934
842935
App: `${{ steps.simulator.outputs.bundle_id }}`
843936
Commit: `${{ steps.pr.outputs.sha }}`

‎docs/guide/github-actions.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ jobs:
104104
command_comment_author: ${{ github.event.comment.user.login }}
105105
build_workflow: build-ios-simulator.yml
106106
bundle_id: com.example.app
107+
session_password: ${{ secrets.SIMDECK_PASSWORD }}
107108
```
108109
109110
## Android comment workflow
@@ -141,6 +142,7 @@ jobs:
141142
command_comment_author: ${{ github.event.comment.user.login }}
142143
build_workflow: build-android-apk.yml
143144
package_name: com.example.app
145+
session_password: ${{ secrets.SIMDECK_PASSWORD }}
144146
```
145147
146148
## Version pins
@@ -198,6 +200,17 @@ Supported quality values include `tiny`, `low`, `economy`, `fast`, `smooth`, `ba
198200
| `avd_name` | `SimDeck_Pixel_CI` | Preferred Android emulator |
199201
| `keepalive_seconds` | `1800` | Session lifetime after launch |
200202
| `simulator_cache` | `true` | Restore and save simulator cache |
203+
| `proxy_links` | `true` | Post SimDeck CI proxy links |
204+
| `ci_proxy_url` | SimDeck Worker URL | Optional SimDeck CI proxy Worker URL |
205+
| `session_password` | empty | Optional password for proxy-gated sessions |
206+
207+
## Password-protected links
208+
209+
Set a repository secret such as `SIMDECK_PASSWORD` and pass it as
210+
`session_password`. The action posts a SimDeck proxy link instead of the raw
211+
Cloudflare Tunnel URL by default. When a password is configured, the daemon
212+
token is encrypted into the proxy payload, so decoding the URL alone does not
213+
grant simulator access. Set `proxy_links: "false"` to post raw tunnel links.
201214
202215
## What the session does
203216

‎packages/ci-proxy-worker/README.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# SimDeck CI Proxy Worker
2+
3+
Stateless Cloudflare Worker for password-gating temporary SimDeck CI tunnel
4+
links.
5+
6+
CI posts a stable Worker URL with an encoded payload:
7+
8+
```text
9+
https://simdeck-ci-proxy.djdeveloperr.workers.dev/?redirect=<base64url-payload>
10+
```
11+
12+
The payload points at the temporary Cloudflare Tunnel. When a session password
13+
is configured, the SimDeck daemon token is encrypted with that password before
14+
it is placed in the payload, so decoding the URL is not enough to bypass the
15+
prompt.
16+
17+
Deploy:
18+
19+
```sh
20+
cd packages/ci-proxy-worker
21+
npm install
22+
npm run deploy
23+
```
24+
25+
Later, attach `ci.simdeck.sh` to this Worker in Cloudflare.

0 commit comments

Comments
 (0)