diff --git a/CLAUDE.md b/CLAUDE.md index 05450c0..5d30ee8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,10 +2,10 @@ ## What this is Expose any local service on an Omarchy machine (model API, web app, desktop) with one command and a chosen auth mode — no containers/K8s/user system. -Core: `omarchy-remote expose ` (private, Tailscale — the default) or `... --auth login|token|password|none [--via pangolin|cloudflare]` (public). +Core: `omarchy-remote expose ` (private, Tailscale — the default) or `... --auth login|token|password|bearer|none [--via cloudflare|pangolin]` (public; default provider Cloudflare, or Pangolin if only it is set up). Private path: `tailscale serve --https=`, tracked by name in `~/.config/omarchy-remote/tailscale.map`. Public path: Pangolin resource `.` → site tunnel → 127.0.0.1: (login/token/password/none), -or Cloudflare: proxied CNAME `.` → tunnel `omarchy-remote-` (user unit `omarchy-remote-tunnel`) → 127.0.0.1: (bearer/none; bearer = WAF custom rule). +or Cloudflare: proxied CNAME `.` → tunnel `omarchy-remote-` (user unit `omarchy-remote-tunnel`) → 127.0.0.1: (login/token = Cloudflare Access, bearer = WAF custom rule, none). Built-in service: browser remote desktop (noVNC :6080 → wayvnc :5900 → Hyprland). Long-term goal: a lightweight personal cloud on top of Omarchy (no Kubernetes, containers, or heavyweight user system): "your own computer, managed by AI, reachable anywhere". Planned: zero-config access via a shared domain + self-hosted @@ -55,7 +55,10 @@ per-user desktops, and distribution through the Omarchy plugin marketplace. - Cloudflare: only `.` (one level) — the free Universal SSL cert doesn't cover deeper names. Never overwrite existing DNS records. - Cloudflare negative answers are cached up to 30 min (SOA minimum 1800); probe new hostnames via DoH (`cf_probe`), not the local resolver. - WAF custom rules take ~10–30 s to reach the edge; `expose --auth bearer` must see a keyless 403 before reporting success. Free plan: 5 custom rules per zone. -- Cloudflare API token needs: Account Cloudflare Tunnel Edit; Zone DNS Edit, Zone WAF Edit, Zone Read. Access (login/token on Cloudflare) is not implemented. +- Cloudflare API token needs: Account Cloudflare Tunnel Edit; Zone DNS Edit, Zone WAF Edit, Zone Read. `login`/`token` on Cloudflare also need Zero Trust on the account plus Access: Apps and Policies, Service Tokens, and Organizations/IdPs/Groups (Edit). +- Cloudflare `login`/`token` = Access app `omarchy-remote::` + reusable policy `omarchy-remote:` (emails / service token, decision `non_identity`) + service token `omarchy-remote:`. Inline policies in the app body can't carry `include`; create the policy first, delete the app before its policy. Never verified against a live Zero Trust org: the test account's org belongs to Eros (don't use it); mock tests only. +- Cloudflare `password` is refused: a WAF block can't send `WWW-Authenticate`, so browsers would never get a prompt. +- Rollback traps run after errexit has unwound the function, so locals are gone: bake values into the trap string (`'"$fqdn"'`), don't reference `$fqdn` inside it. - noVNC settings live in `$NOVNC_DIR/defaults.json`; `index.html` symlinks to `vnc.html` so `/` opens the desktop. ## Roadmap diff --git a/README.md b/README.md index 30b1e22..71b919f 100644 --- a/README.md +++ b/README.md @@ -5,24 +5,25 @@ model API, a web app you are building, or the desktop itself — with one comman of your choice. No containers, no Kubernetes, no public IP, no open ports. ``` - ┌─► 127.0.0.1:11434 (e.g. a model API) -browser / app ─► Pangolin ┼─► 127.0.0.1:3000 (e.g. your web app) - (public + login) └─► 127.0.0.1:6080 (the desktop, via noVNC) -your own devices ─► Tailscale (private, peer-to-peer) +your own devices ── Tailscale (private, peer-to-peer) ──┐ ┌─► 127.0.0.1:11434 (e.g. a model API) + ├────┼─► 127.0.0.1:3000 (e.g. your web app) +anyone, any app ─── Cloudflare or Pangolin (public) ────┘ └─► 127.0.0.1:6080 (the desktop, via noVNC) ``` ```bash omarchy-remote expose app 3000 # -> https://..ts.net:3000 (default: Tailscale, only your own devices) omarchy-remote expose app 3000 --auth login --allow friend@example.com -# -> https://app.home.example.com (public; browser login, your friend gets an email code) +# -> https://app.example.com (public; browser login, your friend gets an email code) omarchy-remote expose ai 11434 --auth bearer # -> https://ai.example.com (public; OpenAI-style clients send "Authorization: Bearer ") ``` Private URLs go through **Tailscale** (free for personal use, no domain needed). Public URLs -go through **Pangolin** (browser login, tokens, passwords) or **Cloudflare** (API keys checked -at Cloudflare's edge — works with OpenAI-compatible clients and SDKs, including streaming). +go through the provider you pick: **Cloudflare** (browser login and service tokens via +Cloudflare Access, plus API keys checked at Cloudflare's edge — works with OpenAI-compatible +clients and SDKs, including streaming) or **Pangolin** (browser login, tokens, passwords). +Set up one or both. How your service runs (a binary, a script, a container) is up to you — `expose` only needs a port on `127.0.0.1`. The tool also ships a browser remote desktop as a ready-made service. @@ -76,7 +77,7 @@ Optional, so `expose` doesn't need sudo: `sudo tailscale set --operator=$USER`. ``` The key is stored in `~/.config/omarchy-remote/pangolin.key` (mode 600). -## One-time setup: Cloudflare (public URLs, API keys) +## One-time setup: Cloudflare (public URLs) Needs a domain on Cloudflare (the free plan is enough). Services get `https://.` (one level below the domain, so Cloudflare's free certificate covers them). @@ -84,6 +85,11 @@ Needs a domain on Cloudflare (the free plan is enough). Services get `https:// ``` Without `--auth` the URL is private (Tailscale). With `--auth` it is public, through the -provider in `--via pangolin|cloudflare`, or the default set with -`omarchy-remote config set public ` (initially `pangolin`; `--auth bearer` always -uses Cloudflare). +provider in `--via cloudflare|pangolin`, or the default set with +`omarchy-remote config set public `. Unset, the default is Cloudflare (Pangolin if +only Pangolin is set up on this machine); `--auth bearer` always uses Cloudflare and +`--auth password` always uses Pangolin. | `--auth` | For | How to get in | Share with someone | Providers | |---|---|---|---|---| -| `login` | people, in a browser | login page: members of your Pangolin org sign in with their account; others get an email code | `--allow a@x.com,b@y.com` | Pangolin | -| `token` | programs / APIs | headers `P-Access-Token-Id` + `P-Access-Token`, or `?p_token=.` | give them the token | Pangolin | +| `login` | people, in a browser | login page with an email code. Pangolin: members of your Pangolin org can also sign in with their account | `--allow a@x.com,b@y.com` (required on Cloudflare) | Cloudflare, Pangolin | +| `token` | programs / APIs | Cloudflare: headers `CF-Access-Client-Id` + `CF-Access-Client-Secret`. Pangolin: headers `P-Access-Token-Id` + `P-Access-Token`, or `?p_token=.` | give them the token | Cloudflare, Pangolin | | `password` | programs and browsers | HTTP Basic: `curl -u user:pass …` or `https://user:pass@host/` | give them the password | Pangolin | | `bearer` | programs, OpenAI-style clients | `Authorization: Bearer ` (e.g. `base_url=https://ai.example.com/v1`, `api_key=`) | give them the key | Cloudflare | -| `none` | everyone | no login — requires `--yes-public` | share the URL | Pangolin, Cloudflare | +| `none` | everyone | no login — requires `--yes-public` | share the URL | Cloudflare, Pangolin | Passwords, tokens and keys are generated for you and **printed once** — save them. A new Pangolin address needs about 30 seconds for its certificate (404 / connection errors until -then). `expose --auth bearer` waits until Cloudflare refuses keyless requests before it reports -success (usually 10–30 s). After `unexpose`, an address may keep answering for a few seconds. +then). On Cloudflare, `expose` with `bearer`, `login` or `token` waits until requests without +credentials are refused before it reports success (usually 10–30 s). After `unexpose`, an address may keep answering for a few seconds. -> `bearer` uses one Cloudflare WAF custom rule per service; the free plan allows 5 custom rules +> On Cloudflare, `login` and `token` create one Access application (plus a policy, and for +> `token` a service token) per service, all named `omarchy-remote:`; `unexpose` removes +> them. `bearer` uses one Cloudflare WAF custom rule per service; the free plan allows 5 custom rules > per domain (shared with any rules you made yourself). Anyone who can open your Cloudflare > dashboard can read the keys in those rules. @@ -148,8 +157,8 @@ Shows what this machine exposes, with copy/open buttons for each URL ``` omarchy-remote expose private URL via Tailscale +omarchy-remote expose --auth login|token|bearer|none [--allow emails] [--via cloudflare] omarchy-remote expose --auth login|token|password|none [--allow emails] [--user name] [--via pangolin] -omarchy-remote expose --auth bearer|none [--via cloudflare] omarchy-remote unexpose omarchy-remote config set public pangolin|cloudflare omarchy-remote list show exposed services diff --git a/README.zh-CN.md b/README.zh-CN.md index bb7678d..42be5f6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -3,22 +3,21 @@ 在任何地方访问你 [Omarchy](https://omarchy.org) 电脑上的服务:本地模型的 API、你正在开发的网页,或者桌面本身。一条命令暴露出去,再选一种登录方式。不需要容器、不需要 Kubernetes、不需要公网 IP,也不用开端口。 ``` - ┌─► 127.0.0.1:11434 (比如模型 API) -浏览器 / 程序 ─► Pangolin ┼─► 127.0.0.1:3000 (比如你的网页) - (公网 + 登录) └─► 127.0.0.1:6080 (桌面,通过 noVNC) -你自己的设备 ─► Tailscale(私有,点对点直连) +你自己的设备 ── Tailscale(私有,点对点直连)───┐ ┌─► 127.0.0.1:11434 (比如模型 API) + ├────┼─► 127.0.0.1:3000 (比如你的网页) +任何人、任何程序 ── Cloudflare 或 Pangolin(公网)┘ └─► 127.0.0.1:6080 (桌面,通过 noVNC) ``` ```bash omarchy-remote expose app 3000 # -> https://<机器名>..ts.net:3000(默认走 Tailscale,只有你自己的设备能访问) omarchy-remote expose app 3000 --auth login --allow friend@example.com -# -> https://app.home.example.com(公网;浏览器登录,朋友用邮箱验证码进入) +# -> https://app.example.com (公网;浏览器登录,朋友用邮箱验证码进入) omarchy-remote expose ai 11434 --auth bearer # -> https://ai.example.com (公网;OpenAI 风格的客户端用 "Authorization: Bearer " 调用) ``` -私有地址走 **Tailscale**(个人使用免费,不需要域名)。公网地址走 **Pangolin**(浏览器登录、访问令牌、密码),或者走 **Cloudflare**(在 Cloudflare 边缘校验 API key,兼容 OpenAI 风格的客户端和 SDK,包括流式输出)。 +私有地址走 **Tailscale**(个人使用免费,不需要域名)。公网地址走你选的提供方:**Cloudflare**(通过 Cloudflare Access 做浏览器登录和服务令牌,还能在 Cloudflare 边缘校验 API key,兼容 OpenAI 风格的客户端和 SDK,包括流式输出),或者 **Pangolin**(浏览器登录、访问令牌、密码)。两个都配或只配一个都行。 服务怎么跑(二进制、脚本还是容器)由你决定,`expose` 只要求它在 `127.0.0.1` 上监听一个端口。工具还自带一个浏览器远程桌面,作为现成的服务。 @@ -56,11 +55,12 @@ curl -fsSL https://raw.githubusercontent.com/second-state/omarchy-remote/main/in ``` 密钥保存在 `~/.config/omarchy-remote/pangolin.key`(权限 600)。 -## 一次性配置:Cloudflare(公网地址、API key) +## 一次性配置:Cloudflare(公网地址) 需要一个托管在 Cloudflare 上的域名,免费版就够。服务地址是 `https://<名字>.<域名>`:只能在域名下一层,这样 Cloudflare 免费证书才能覆盖。 1. 创建 API 令牌:我的个人资料 → API 令牌 → 创建令牌 → 自定义令牌。权限选 **帐户:Cloudflare Tunnel(编辑)**,以及 **区域:DNS(编辑)、区域 WAF(编辑)、区域(读取)**,作用范围限定为你的帐户和这个域名。 + 要用 `--auth login` 或 `--auth token`,还需要在这个帐户上开通 **Zero Trust**(免费版支持 50 个用户),并给令牌加上 **帐户:Access: Apps and Policies(编辑)、Access: Service Tokens(编辑)、Access: Organizations, Identity Providers, and Groups(编辑)**。第一个 `login` 服务会为你的 Zero Trust 组织开启邮箱验证码登录(如果还没开)。 2. 在 Omarchy 机器上执行: ```bash omarchy-remote cloudflare login # 输入令牌(不会显示)并选择域名 @@ -78,21 +78,21 @@ omarchy-remote list omarchy-remote unexpose <名字> ``` -不带 `--auth` 时是私有地址(Tailscale)。带了 `--auth` 就是公网地址,走 `--via pangolin|cloudflare` 指定的提供方;不写 `--via` 时用 `omarchy-remote config set public <提供方>` 设置的默认值(初始为 `pangolin`;`--auth bearer` 总是走 Cloudflare)。 +不带 `--auth` 时是私有地址(Tailscale)。带了 `--auth` 就是公网地址,走 `--via cloudflare|pangolin` 指定的提供方;不写 `--via` 时用 `omarchy-remote config set public <提供方>` 设置的默认值。没设置时默认走 Cloudflare(如果这台机器只配了 Pangolin,就走 Pangolin);`--auth bearer` 总是走 Cloudflare,`--auth password` 总是走 Pangolin。 | `--auth` | 给谁用 | 怎么进入 | 分享给别人 | 提供方 | |---|---|---|---|---| -| `login` | 人,用浏览器 | 登录页:Pangolin 组织成员用账号直接登录,其他人用邮箱验证码 | `--allow a@x.com,b@y.com` | Pangolin | -| `token` | 程序 / API | 请求头 `P-Access-Token-Id` + `P-Access-Token`,或 `?p_token=.` | 把令牌发给对方 | Pangolin | +| `login` | 人,用浏览器 | 登录页,用邮箱验证码进入。Pangolin 上,组织成员也可以用账号直接登录 | `--allow a@x.com,b@y.com`(Cloudflare 上必填) | Cloudflare、Pangolin | +| `token` | 程序 / API | Cloudflare:请求头 `CF-Access-Client-Id` + `CF-Access-Client-Secret`。Pangolin:请求头 `P-Access-Token-Id` + `P-Access-Token`,或 `?p_token=.` | 把令牌发给对方 | Cloudflare、Pangolin | | `password` | 程序和浏览器都行 | HTTP Basic:`curl -u 用户:密码 …` 或 `https://用户:密码@域名/` | 把密码发给对方 | Pangolin | | `bearer` | 程序、OpenAI 风格的客户端 | `Authorization: Bearer `(比如 `base_url=https://ai.example.com/v1`,`api_key=`) | 把 key 发给对方 | Cloudflare | -| `none` | 所有人 | 不需要登录,必须加 `--yes-public` 确认 | 直接发地址 | Pangolin、Cloudflare | +| `none` | 所有人 | 不需要登录,必须加 `--yes-public` 确认 | 直接发地址 | Cloudflare、Pangolin | 密码、令牌和 key 都由工具自动生成,**只显示一次**,请自己保存好。 -Pangolin 的新地址需要大约 30 秒签发证书,在此之前出现 404 或连不上是正常的。`expose --auth bearer` 会等到 Cloudflare 确实拒绝不带 key 的请求后才报告成功(通常 10~30 秒)。`unexpose` 之后,地址可能还会响应几秒。 +Pangolin 的新地址需要大约 30 秒签发证书,在此之前出现 404 或连不上是正常的。在 Cloudflare 上,`bearer`、`login`、`token` 三种方式的 `expose` 都会等到不带凭据的请求确实被拒绝后才报告成功(通常 10~30 秒)。`unexpose` 之后,地址可能还会响应几秒。 -> 每个 `bearer` 服务占用一条 Cloudflare WAF 自定义规则。免费版每个域名最多 5 条,跟你自己建的规则共用。能打开你 Cloudflare 后台的人,都能看到这些规则里的 key。 +> 在 Cloudflare 上,每个 `login` 或 `token` 服务会建一个 Access 应用和一条策略(`token` 还会建一个服务令牌),名字都是 `omarchy-remote:<名字>`,`unexpose` 时一并删除。每个 `bearer` 服务占用一条 Cloudflare WAF 自定义规则。免费版每个域名最多 5 条,跟你自己建的规则共用。能打开你 Cloudflare 后台的人,都能看到这些规则里的 key。 ### 远程桌面 @@ -116,8 +116,8 @@ omarchy plugin add https://github.com/second-state/omarchy-remote-plugin --enabl ``` omarchy-remote expose <名字> <端口> 通过 Tailscale 生成私有地址 +omarchy-remote expose <名字> <端口> --auth login|token|bearer|none [--allow 邮箱] [--via cloudflare] omarchy-remote expose <名字> <端口> --auth login|token|password|none [--allow 邮箱] [--user 用户名] [--via pangolin] -omarchy-remote expose <名字> <端口> --auth bearer|none [--via cloudflare] omarchy-remote unexpose <名字> omarchy-remote config set public pangolin|cloudflare omarchy-remote list 查看已暴露的服务 diff --git a/bin/omarchy-remote b/bin/omarchy-remote index 98696a6..2317617 100755 --- a/bin/omarchy-remote +++ b/bin/omarchy-remote @@ -9,7 +9,7 @@ set -euo pipefail -VERSION="0.5.0" +VERSION="0.6.0" # ---- settings (override in ~/.config/omarchy-remote/config) ----------------- CONFIG_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/omarchy-remote/config" @@ -23,7 +23,7 @@ WEBSOCKIFY_REPO="https://github.com/novnc/websockify" WEBSOCKIFY_REF="master" PANGOLIN_ENDPOINT="https://app.pangolin.net" PANGOLIN_API="https://api.pangolin.net/v1" -PUBLIC_VIA="pangolin" # provider used when --auth is given without --via +PUBLIC_VIA="" # provider used when --auth is given without --via ("" = auto, see cmd_expose) NOVNC_QUALITY=6 # 0-9, lower = smaller/faster NOVNC_COMPRESSION=2 # 0-9, higher = more CPU, less bandwidth # shellcheck source=/dev/null @@ -333,11 +333,15 @@ cmd_expose() { if ! [[ "$port" =~ ^[0-9]+$ ]] || ((port < 1 || port > 65535)); then die "Invalid port: $port"; fi # No --via: private by default; asking for an auth mode means a public URL. - # bearer exists only on Cloudflare, so it picks Cloudflare by itself. + # bearer exists only on Cloudflare and password only on Pangolin, so they pick their provider. if [[ -z "$via" ]]; then if [[ -z "$auth" ]]; then via=tailscale elif [[ "$auth" == bearer ]]; then via=cloudflare - else via="$PUBLIC_VIA"; fi + elif [[ "$auth" == password ]]; then via=pangolin + elif [[ -n "$PUBLIC_VIA" ]]; then via="$PUBLIC_VIA" + # Not configured: Cloudflare, unless only Pangolin is set up on this machine. + elif [[ -r "$PG_ENV_FILE" && ! -r "$CF_ENV_FILE" ]]; then via=pangolin + else via=cloudflare; fi fi case "$via" in tailscale) @@ -353,8 +357,11 @@ cmd_expose() { if [[ "$via" == pangolin && "$auth" == bearer ]]; then die "--auth bearer is only available with --via cloudflare (Pangolin can't check Bearer keys)." fi - if [[ "$via" == cloudflare && "$auth" =~ ^(login|token|password)$ ]]; then - die "--auth $auth isn't supported with Cloudflare yet (it needs Cloudflare Access). Use --via pangolin, or --auth bearer|none." + if [[ "$via" == cloudflare && "$auth" == password ]]; then + die "--auth password isn't available with Cloudflare (browsers can't get a password prompt there). Use --via pangolin, or --auth login|token|bearer." + fi + if [[ "$via" == cloudflare && "$auth" == login && -z "$allow" ]]; then + die "With Cloudflare, --auth login needs --allow a@x.com[,b@y.com] (who may sign in with an email code)." fi [[ -z "$allow" || "$auth" == login ]] || die "--allow only applies to --auth login." ;; @@ -366,7 +373,7 @@ cmd_expose() { case "$via" in tailscale) ts_expose "$name" "$port" ;; pangolin) pg_expose "$name" "$port" "$auth" "$allow" "$user" ;; - cloudflare) cf_expose "$name" "$port" "$auth" ;; + cloudflare) cf_expose "$name" "$port" "$auth" "$allow" ;; esac } @@ -421,7 +428,7 @@ pg_expose() { "{\"name\":\"$PG_TAG:$name:$auth\",\"subdomain\":\"$name\",\"domainId\":\"$PG_DOMAIN_ID\",\"mode\":\"http\"}")" id="$(json_get "$r" "str(d['data']['resourceId'])")" # From here on, delete the half-made resource if any later step fails (die exits). - trap 'warn "Rolling back $fqdn"; pg_api DELETE "/public-resource/'"$id"'" >/dev/null || true' EXIT + trap 'warn "Rolling back '"$fqdn"'"; pg_api DELETE "/public-resource/'"$id"'" >/dev/null || true' EXIT pg_api PUT "/public-resource/$id/target" \ "{\"siteId\":$PG_SITE_ID,\"ip\":\"127.0.0.1\",\"port\":$port,\"method\":\"http\",\"enabled\":true}" >/dev/null @@ -502,6 +509,9 @@ cf_login() { Create an API token at dash.cloudflare.com -> My Profile -> API Tokens -> Custom token: Account: Cloudflare Tunnel (Edit) Zone: DNS (Edit), Zone WAF (Edit), Zone (Read) limited to your account and the zone (domain) you want to use. +For --auth login|token also add (and enable Zero Trust, free plan, on the account): + Account: Access: Apps and Policies (Edit), Access: Service Tokens (Edit), + Access: Organizations, Identity Providers, and Groups (Edit) EOF local key zones zone_id read -rsp "API token (hidden): " key; echo @@ -618,6 +628,85 @@ cf_waf_del() { # cf_waf_del NAME [[ -z "$rid" ]] || cf_api DELETE "/zones/$CF_ZONE_ID/rulesets/$rs/rules/$rid" >/dev/null } +# ---- Cloudflare Access (--auth login|token) ---- +# One self-hosted Access app per service, named omarchy-remote::, guarded by +# one reusable policy omarchy-remote:: allowed emails (login, email one-time code) +# or one service token omarchy-remote: (token). Needs Zero Trust on the account. + +cf_otp_idp() { # -> id of the account's one-time PIN login method (created if missing) + local r id + r="$(cf_api --soft GET "/accounts/$CF_ACCOUNT/access/identity_providers")" \ + || die "Cloudflare Access isn't available: $(json_get "$r" "'; '.join(e.get('message','') for e in d.get('errors') or [])" 2>/dev/null) +Enable Zero Trust (free plan) for this account in the dashboard, and give the API token the Access permissions (see: omarchy-remote cloudflare login)." + id="$(json_get "$r" "next((x['id'] for x in d['result'] if x.get('type')=='onetimepin'), '')")" + if [[ -z "$id" ]]; then + info "Enabling email one-time-code login in your Cloudflare Zero Trust organization" >&2 + r="$(cf_api POST "/accounts/$CF_ACCOUNT/access/identity_providers" '{"name":"One-time PIN","type":"onetimepin","config":{}}')" + id="$(json_get "$r" "d['result']['id']")" + fi + echo "$id" +} + +# cf_access_add NAME FQDN login|token [EMAILS] — for token, the new service token's JSON +# (client id + secret, shown once) is left in CF_SERVICE_TOKEN. +cf_access_add() { + local name="$1" fqdn="$2" auth="$3" allow="${4:-}" idp="" r body pol + CF_SERVICE_TOKEN="" + if [[ "$auth" == token ]]; then + CF_SERVICE_TOKEN="$(cf_api POST "/accounts/$CF_ACCOUNT/access/service_tokens" "{\"name\":\"$PG_TAG:$name\"}")" + else + idp="$(cf_otp_idp)" + fi + body="$(python3 - "$PG_TAG:$name" "$auth" "$allow" "$CF_SERVICE_TOKEN" <<'PY' +import json, sys +name, auth, allow, st = sys.argv[1:5] +if auth == "token": + rules, decision = [{"service_token": {"token_id": json.loads(st)["result"]["id"]}}], "non_identity" +else: + rules, decision = [{"email": {"email": e.strip()}} for e in allow.split(",") if e.strip()], "allow" +print(json.dumps({"name": name, "decision": decision, "include": rules})) +PY +)" + r="$(cf_api POST "/accounts/$CF_ACCOUNT/access/policies" "$body")" + pol="$(json_get "$r" "d['result']['id']")" + body="$(python3 - "$PG_TAG:$name:$auth" "$fqdn" "$pol" "$idp" <<'PY' +import json, sys +name, fqdn, pol, idp = sys.argv[1:5] +app = {"name": name, "domain": fqdn, "type": "self_hosted", "session_duration": "24h", + "app_launcher_visible": False, "policies": [{"id": pol, "precedence": 1}]} +if idp: # login: straight to the email-code page, no other login methods offered + app.update(allowed_idps=[idp], auto_redirect_to_identity=True) +print(json.dumps(app)) +PY +)" + if ! r="$(cf_api --soft POST "/accounts/$CF_ACCOUNT/access/apps" "$body")"; then + cf_api --soft DELETE "/accounts/$CF_ACCOUNT/access/policies/$pol" >/dev/null || true # nothing else points at it + die "Cloudflare Access app for $fqdn: $(json_get "$r" "'; '.join(e.get('message','') for e in d.get('errors') or [])" 2>/dev/null || echo "$r")" + fi +} + +# cf_access_del NAME FQDN — remove the app, then its policy, then its service token. +# Tolerates pieces that are already gone (also used for rollback). +cf_access_del() { + local name="$1" fqdn="$2" r ids app pol st + r="$(cf_api --soft GET "/accounts/$CF_ACCOUNT/access/apps?domain=$fqdn")" || return 0 + ids="$(json_get "$r" "'\n'.join(a['id'] for a in d['result'] or [] if a.get('domain')=='$fqdn' and (a.get('name') or '').startswith('$PG_TAG:$name:'))")" + for app in $ids; do + r="$(cf_api GET "/accounts/$CF_ACCOUNT/access/apps/$app")" + cf_api DELETE "/accounts/$CF_ACCOUNT/access/apps/$app" >/dev/null + while read -r pol st; do + [[ -n "$pol" ]] || continue + cf_api --soft DELETE "/accounts/$CF_ACCOUNT/access/policies/$pol" >/dev/null || warn "Couldn't delete Access policy $pol" + [[ "$st" == - ]] || cf_api --soft DELETE "/accounts/$CF_ACCOUNT/access/service_tokens/$st" >/dev/null || warn "Couldn't delete service token $st" + done < <(json_get "$r" "'\n'.join('%s %s' % (p['id'], next((i['service_token']['token_id'] for i in p.get('include') or [] if 'service_token' in i), '-')) for p in d['result'].get('policies') or [] if p.get('name')=='$PG_TAG:$name')") + done + # A service token made just before a failed policy/app step has nothing pointing at it. + r="$(cf_api --soft GET "/accounts/$CF_ACCOUNT/access/service_tokens?name=$PG_TAG:$name")" || return 0 + for st in $(json_get "$r" "'\n'.join(t['id'] for t in d['result'] or [] if t.get('name')=='$PG_TAG:$name')"); do + cf_api --soft DELETE "/accounts/$CF_ACCOUNT/access/service_tokens/$st" >/dev/null || true + done +} + # cf_probe FQDN -> HTTP status of https://FQDN/ without credentials. Resolves through # Cloudflare DoH so a negative answer cached by the local resolver (the zone's SOA # allows up to 30 min) doesn't hide a record we just created. @@ -630,7 +719,7 @@ cf_probe() { } cf_expose() { - local name="$1" port="$2" auth="$3" fqdn c key="" code i + local name="$1" port="$2" auth="$3" allow="${4:-}" fqdn c key="" code i cf_load fqdn="$name.$CF_ZONE" c="$(cf_dns_comment "$fqdn")" @@ -638,10 +727,12 @@ cf_expose() { cf_ensure_tunnel info "Creating https://$fqdn -> 127.0.0.1:$port (Cloudflare)" - trap 'warn "Rolling back $fqdn"; cf_waf_del "'"$name"'" || true; r=$(cf_api --soft GET "/zones/$CF_ZONE_ID/dns_records?name='"$fqdn"'") && id=$(json_get "$r" "d[\"result\"][0][\"id\"] if d[\"result\"] else \"\"") && [[ -n "$id" ]] && cf_api --soft DELETE "/zones/$CF_ZONE_ID/dns_records/$id" >/dev/null; cf_ingress del "'"$fqdn"'" || true' EXIT + trap 'warn "Rolling back '"$fqdn"'"; cf_waf_del "'"$name"'" || true; cf_access_del "'"$name"'" "'"$fqdn"'" || true; r=$(cf_api --soft GET "/zones/$CF_ZONE_ID/dns_records?name='"$fqdn"'") && id=$(json_get "$r" "d[\"result\"][0][\"id\"] if d[\"result\"] else \"\"") && [[ -n "$id" ]] && cf_api --soft DELETE "/zones/$CF_ZONE_ID/dns_records/$id" >/dev/null; cf_ingress del "'"$fqdn"'" || true' EXIT if [[ "$auth" == bearer ]]; then key="sk-omr-$(python3 -c 'import secrets; print(secrets.token_hex(24))')" # lowercase: the rule compares lower()-ed headers cf_waf_add "$name" "$fqdn" "$key" + elif [[ "$auth" == login || "$auth" == token ]]; then + cf_access_add "$name" "$fqdn" "$auth" "$allow" # before DNS: never reachable without it fi cf_ingress add "$fqdn" "$port" cf_api POST "/zones/$CF_ZONE_ID/dns_records" \ @@ -656,6 +747,14 @@ cf_expose() { sleep 3 done [[ "$code" == 403 ]] || die "The URL still answers without the key (HTTP $code) — not publishing it." + elif [[ "$auth" == login || "$auth" == token ]]; then + info "Waiting for Cloudflare Access to guard it (up to 2 min)…" + for i in $(seq 1 40); do + code="$(cf_probe "$fqdn")" + [[ "$code" =~ ^(302|401|403)$ ]] && break + sleep 3 + done + [[ "$code" =~ ^(302|401|403)$ ]] || die "The URL answers without signing in (HTTP $code) — not publishing it." fi trap - EXIT @@ -663,6 +762,9 @@ cf_expose() { echo " If a device looked this name up before it existed, its DNS cache may say 'not found' for up to 30 min." case "$auth" in none) warn "Anyone on the internet can open it." ;; + login) echo " Opens a Cloudflare Access login page. Allowed: $allow (email code)" ;; + token) + json_get "$CF_SERVICE_TOKEN" "' Client ID: '+d['result']['client_id']+' Client secret: '+d['result']['client_secret']+' (shown once — save it)\n curl -H \"CF-Access-Client-Id: '+d['result']['client_id']+'\" -H \"CF-Access-Client-Secret: '+d['result']['client_secret']+'\" https://$fqdn/'" ;; bearer) echo " Key: $key (shown once — save it)" echo " curl -H 'Authorization: Bearer $key' https://$fqdn/" @@ -678,6 +780,7 @@ cf_unexpose() { [[ -z "$id" ]] || cf_api DELETE "/zones/$CF_ZONE_ID/dns_records/$id" >/dev/null cf_ingress del "$fqdn" cf_waf_del "$name" + cf_access_del "$name" "$fqdn" info "Removed https://$fqdn (Cloudflare)" } @@ -880,14 +983,16 @@ omarchy-remote $VERSION — reach local services on your Omarchy machine from an Usage: omarchy-remote expose private URL via Tailscale (default): https://.ts.net: + expose --auth login|token|bearer|none [--allow a@x.com,b@y.com] [--via cloudflare] + public URL https://. via Cloudflare (login/token use + Cloudflare Access; bearer = API key checked at Cloudflare's edge) expose --auth login|token|password|none [--allow a@x.com,b@y.com] [--via pangolin] - public URL https://. behind the chosen login - expose --auth bearer|none [--via cloudflare] - public URL via Cloudflare; bearer = API key checked at Cloudflare's edge + public URL https://. via Pangolin unexpose list [--json] show exposed services (--json: for scripts and the Omarchy plugin) config set public pangolin|cloudflare - provider used when --auth is given without --via (default: pangolin) + provider used when --auth is given without --via (default: + cloudflare, or pangolin if only Pangolin is set up) setup install wayvnc + noVNC and start the desktop services status show desktop service and publishing status tailscale on|off publish privately to your tailnet (HTTPS)