Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ references/
.vercel
.claude/
dstack/.env
dstack/.env.*
dstack/AGENT_*.md
7 changes: 7 additions & 0 deletions browser/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
FROM mcr.microsoft.com/playwright:v1.58.2-noble
WORKDIR /app
COPY package.json ./
RUN npm install
COPY server.js ./
EXPOSE 3000
CMD ["node", "server.js"]
57 changes: 57 additions & 0 deletions browser/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Browser Service

Headless Playwright browser behind OpenVPN for cookie-authenticated fetches from the TEE.

## Why

YouTube/Google tie auth cookies to the IP where the session was created. Raw `fetch()` from the TEE's datacenter IP returns `logged_in: 0`. A real browser with injected cookies through a VPN works.

## Architecture

```
Custom capability code
→ fetch('http://browser:3000/browse', {...})
→ Playwright (headless Chromium)
→ socks5://openvpn-socks5:1080
→ ProtonVPN → youtube.com
```

## API

**POST /browse**
```json
{
"url": "https://www.youtube.com/feed/history",
"cookies": [{"name": "SID", "value": "...", "domain": ".youtube.com", "path": "/", "secure": true, "httpOnly": false, "sameSite": "Lax"}],
"userAgent": "Mozilla/5.0 ...",
"script": "document.title"
}
```

Returns `{ status, url, data }` (with script) or `{ status, url, body }` (without).

Each request gets a fresh browser context — no state leaks between requests.

**GET /health** — `{ ok: true, proxy: "socks5://..." }`

## Upgrade Path: Envoy/Neko (persistent browser)

Current: stateless Playwright — inject cookies per request, destroy context after.

Next: replace `browser` service with Envoy's Neko container for a persistent "teleport browser":

1. Replace `browser` Dockerfile with Envoy's `neko/Dockerfile` (Chromium + extension + ws-bridge)
2. Mount a profile volume for persistent logins (`/home/neko/.config/chromium`)
3. Expose WebRTC port for visual access (debugging, manual auth)
4. Extension bridge at `:3000` replaces our `/browse` endpoint
5. Custom capabilities call `http://browser:3000/api/bridge` instead of `/browse`
6. Keep `openvpn-socks5` sidecar — Chromium launched with `--proxy-server=socks5://openvpn-socks5:1080`

Key files from envoy to crib:
- `neko/Dockerfile` — Neko + extension + bridge
- `neko/ws-bridge.js` — HTTP command bridge (extension polls, clients submit)
- `neko/chromium.conf` — supervisord config with proxy flags
- `extension/` — Chrome extension for undetectable automation
- `src/controller/bridge.ts` — WebSocket client for scripting

The switch is: stateless (inject cookies) → persistent (stay logged in). Same VPN sidecar, same compose slot.
7 changes: 7 additions & 0 deletions browser/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "oauth3-browser",
"private": true,
"dependencies": {
"playwright": "^1.52.0"
}
}
56 changes: 56 additions & 0 deletions browser/server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
const http = require('http');
const { chromium } = require('playwright');

const PROXY = process.env.PROXY_URL || 'socks5://openvpn-socks5:1080';
let browser;

async function handleBrowse(req, res) {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const { url, cookies, userAgent, script } = JSON.parse(Buffer.concat(chunks).toString());

const context = await browser.newContext({ userAgent: userAgent || undefined });
try {
if (cookies?.length) await context.addCookies(cookies);
const page = await context.newPage();
const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });

let result;
if (script) {
result = { status: response.status(), url: page.url(), data: await page.evaluate(script) };
} else {
result = { status: response.status(), url: page.url(), body: await page.content() };
}

res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
} finally {
await context.close();
}
}

const server = http.createServer(async (req, res) => {
try {
if (req.method === 'GET' && req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ ok: true, proxy: PROXY }));
}
if (req.method === 'POST' && req.url === '/browse') {
return await handleBrowse(req, res);
}
res.writeHead(404);
res.end('Not found');
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
});

(async () => {
browser = await chromium.launch({
args: ['--no-sandbox', '--disable-dev-shm-usage'],
proxy: { server: PROXY },
});
console.log(`Browser service listening on :3000 (proxy: ${PROXY})`);
server.listen(3000);
})();
42 changes: 32 additions & 10 deletions dstack/DEPLOY.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,40 @@
# Deploy to a TEE

Production deployment on [dstack](https://docs.phala.network/dstack/overview) (Phala CVM):
Deployment on [dstack](https://docs.phala.network/dstack/overview) (Phala CVM).

```bash
cp dstack/.env.staging dstack/.env
# Set: JWT_SECRET, ANTHROPIC_API_KEY, PG_PASSWORD, DOMAIN, CLOUDFLARE_API_TOKEN
**We only deploy to STAGING right now. Production is not active.**

## Staging

CVM app ID: `23da7533b60fe6e5f5e30c97f30af5bd7ccdf4df`
URL: https://tee.oauth3-stage.monerolink.com
Orchestrator: https://oauth3-stage.monerolink.com (Vercel)

docker build -t ghcr.io/YOU/oauth3-proxy:latest proxy/
docker push ghcr.io/YOU/oauth3-proxy:latest
```bash
# 1. Build & push image (only if proxy code changed)
docker build -t ghcr.io/amiller/oauth3-proxy:latest proxy/
docker push ghcr.io/amiller/oauth3-proxy:latest
# Pin digest (attestation requires exact match):
docker inspect ghcr.io/YOU/oauth3-proxy:latest --format '{{index .RepoDigests 0}}'
# Update dstack/docker-compose.yml with digest
docker inspect ghcr.io/amiller/oauth3-proxy:latest --format '{{index .RepoDigests 0}}'
# Update dstack/docker-compose.yml oauth3-proxy image with new digest

phala deploy --cvm-id <UUID> -c dstack/docker-compose.yml -e dstack/.env
# 2. Deploy (env-only update doesn't need image rebuild)
phala deploy --cvm-id 23da7533b60fe6e5f5e30c97f30af5bd7ccdf4df \
-c dstack/docker-compose.yml -e dstack/.env.staging
```

The CVM runs: dstack-ingress (attested TLS via Cloudflare) → oauth3-proxy → postgres.
## Files

- `docker-compose.yml` — the ONE compose file, includes ingress + postgres + proxy + ssh
- `.env.staging` — staging secrets (JWT_SECRET shared with Vercel orchestrator)
- `.env.production` — production secrets (not currently used)

## Architecture

dstack-ingress (attested TLS via Cloudflare) → oauth3-proxy → postgres

## Gotchas

- **JWT_SECRET must match** between Vercel (orchestrator) and Phala (proxy) — without it, approval flow breaks silently
- **Volumes persist** across `phala deploy` updates — DB data and certs survive redeploys
- **Only one compose file** — `dstack/docker-compose.yml`. There is no other.
59 changes: 0 additions & 59 deletions dstack/deploy/docker-compose.yml

This file was deleted.

57 changes: 0 additions & 57 deletions dstack/docker-compose-sidecar.yaml

This file was deleted.

40 changes: 32 additions & 8 deletions dstack/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@

services:
dstack-ingress:
image: dstacktee/dstack-ingress:20250929@sha256:2b47b3e538df0b3e7724255b89369194c8c83a7cfba64d2faf0115ad0a586458
image: dstacktee/dstack-ingress:1.3@sha256:875ea58172b33cbb3e130dce2ff44b36c05f2153c434dc1ef02798d5994716cf
ports:
- "443:443"
environment:
DOMAIN: ${DOMAIN}
DOMAIN: tee.oauth3-stage.monerolink.com
DNS_PROVIDER: cloudflare
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN}
GATEWAY_DOMAIN: ${GATEWAY_DOMAIN:-_.dstack-pha-prod9.phala.network}
CERTBOT_EMAIL: ${CERTBOT_EMAIL:-amiller@oauth3.net}
GATEWAY_DOMAIN: _.dstack-pha-prod9.phala.network
CERTBOT_EMAIL: amiller@oauth3.net
SET_CAA: "true"
TARGET_ENDPOINT: http://oauth3-proxy:3737
volumes:
Expand All @@ -22,24 +22,48 @@ services:
restart: unless-stopped

oauth3-proxy:
image: ghcr.io/amiller/oauth3-proxy@sha256:ffabcb2adbde3912be9ec97da4b2ad0501827ea0041a4bc6513f38125e16757a
image: ghcr.io/amiller/oauth3-proxy@sha256:e184dfd7464db32168aed80b4dc116c0dc853ab01c8ae9db9d0867e9240a454d
ports:
- "3737:3737"
environment:
PORT: "3737"
DB_PATH: /data/proxy.db
DATABASE_URL: postgres://${PG_USER:-oauth3}:${PG_PASSWORD}@postgres:5432/${PG_DB:-oauth3}
CORS_ORIGIN: ${CORS_ORIGIN}
CORS_ORIGIN: https://oauth3-stage.monerolink.com,https://amiller.github.io
JWT_SECRET: ${JWT_SECRET:-}
PUBLIC_URL: https://${DOMAIN}
ORCHESTRATOR_URL: ${ORCHESTRATOR_URL:-}
PUBLIC_URL: https://tee.oauth3-stage.monerolink.com
ORCHESTRATOR_URL: https://oauth3-stage.monerolink.com
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
volumes:
- oauth3-data:/data
restart: unless-stopped

# gost: local unauthenticated SOCKS5 that forwards to remote authenticated WG proxy.
# Chromium/Playwright can't do authenticated SOCKS5, so gost bridges the gap.
# Same pattern as borgcube's chisel-client containers.
socks5-relay:
image: ginuerzh/gost:latest
command:
- "-L=:1080"
- "-F=socks5://${WG_PROXY_USER}:${WG_PROXY_PASS}@${WG_PROXY_HOST:-162.251.235.137}:${WG_PROXY_PORT:-10800}"
restart: unless-stopped

# Browser service: headless Playwright behind VPN for cookie-authenticated fetches.
# Upgrade path: replace with Envoy Neko container for persistent "teleport browser".
# See browser/README.md for details.
browser:
image: ghcr.io/amiller/oauth3-browser@sha256:d2a16a9ce76ec7897c5f47a468634f120c9ef9c23223a95b17ece904964315b5
expose:
- "3000"
shm_size: 1gb
environment:
PROXY_URL: socks5://socks5-relay:1080
depends_on:
- socks5-relay
restart: unless-stopped

ssh:
image: ghcr.io/amiller/dstack-ssh-sidecar@sha256:619756189423e1ad1452a5606adf57ef72ac5812fcd31933fdd156e53848ab53
privileged: true
Expand Down
7 changes: 7 additions & 0 deletions openvpn-socks5/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
FROM curve25519xsalsa20poly1305/openvpn-socks5:latest

# Custom entrypoint that reads config from env var
COPY entrypoint.sh /entrypoint-custom.sh
RUN chmod +x /entrypoint-custom.sh

ENTRYPOINT ["/entrypoint-custom.sh"]
Loading