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
18 changes: 18 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Keep the Docker build context small and reproducible.
# (The proxy image builds from the repo root because :server depends on :shared.)
.git
.github
**/build/
**/.gradle/
.gradle/
.kotlin/
kotlin-js-store/
node_modules/
**/node_modules/
.idea/
*.iml
.DS_Store
local.properties
# iOS artefacts are irrelevant to the JVM proxy build
ios*/
*.xcworkspace
54 changes: 54 additions & 0 deletions .github/workflows/publish-proxy-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: Publish proxy image

# Builds the CORS proxy container and publishes it to GitHub Container Registry
# so self-hosters can run a reproducible, pre-built image instead of building
# from source. Triggered by tags like `proxy-v1.0.0` and manual runs.
on:
push:
tags:
- "proxy-v*"
workflow_dispatch:

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository_owner }}/spectacled-proxy

jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}},prefix=,value=${{ github.ref_name }}
type=raw,value=latest

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: server/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,8 @@ This project uses the **Gradle Wrapper** and the **Foojay toolchain resolver**.
┣ 📂 iosTasksApp/ ← iOS Xcode project for Tasks
┣ 📄 spectacled.xcworkspace ← Xcode workspace combining all three iOS apps
┣ 📂 server/ ← Ktor backend scaffold — currently unused template
boilerplate, not required to build or run any app
┣ 📂 server/ ← Ktor CORS proxy for the Web build (see server/README.md).
Only the browser needs it; native apps talk CalDAV directly.
┗ 📂 gradle/
┗ 📄 libs.versions.toml ← ★ All dependency versions live here
Expand Down Expand Up @@ -177,6 +177,14 @@ Each command below works for any of the three apps — just swap `composeJournal

Requires a recent browser (Chrome 119+, Firefox 120+, Safari 18.2+).

> **⚠️ The Web build needs the CORS proxy.** Browsers block cross-origin WebDAV requests, so the
> web app routes CalDAV traffic through the small Ktor proxy in [`server/`](server/README.md), which
> adds the required CORS headers (native apps talk to CalDAV directly and don't need it). Run it
> locally with `./gradlew :server:run` and point **Settings → Proxy server** at
> `http://localhost:8088`. For hosting, **self-host your own** instance (a shared proxy can see your
> credentials in transit) — see [`server/README.md`](server/README.md) for Docker/Fly.io setup and
> the trust caveats.

### 🍎 iOS

Requires macOS + Xcode 16+.
Expand All @@ -199,6 +207,8 @@ DEVELOPMENT_TEAM=YOUR_APPLE_TEAM_ID
| Command | What it does |
|------------------------------|----------------------------------------------------------------|
| `./gradlew :shared:allTests` | Run the shared module's test suite |
| `./gradlew :server:run` | Run the Web CORS proxy locally on `http://localhost:8088` |
| `./gradlew :server:test` | Run the proxy's test suite |
| `./gradlew clean` | Delete all build outputs |
| `./gradlew --stop` | Stop all Gradle daemons (useful after a bad incremental build) |

Expand Down
37 changes: 37 additions & 0 deletions fly.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Fly.io deployment for the Spectacled CalDAV CORS proxy.
#
# This is a TEMPLATE — copy it and set your own values before deploying:
# 1. Change `app` to a unique name (`fly apps create <name>`).
# 2. Set PROXY_ALLOWED_ORIGINS to the origin(s) that serve your web build.
# 3. Optionally lock PROXY_ALLOWED_TARGET_HOSTS to the CalDAV host(s) you trust
# (strongly recommended for a shared/demo instance).
# Then: `fly deploy`
#
# See server/README.md for the full self-hosting guide and the trust caveats.

app = "spectacled-proxy"
primary_region = "fra"

[build]
dockerfile = "server/Dockerfile"

[env]
# The app reads PORT; keep this in sync with internal_port below.
PORT = "8080"
# Only allow https CalDAV targets (default). Set to "false" only for local testing.
PROXY_REQUIRE_HTTPS_TARGET = "true"
# REQUIRED for a public instance: the web origin(s) allowed to use this proxy.
PROXY_ALLOWED_ORIGINS = "https://spectacled.techbee.at"
# Recommended for a demo instance: restrict which CalDAV hosts may be reached.
# PROXY_ALLOWED_TARGET_HOSTS = "example-caldav.org"

[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = "stop"
auto_start_machines = true
min_machines_running = 0

[[vm]]
size = "shared-cpu-1x"
memory = "512mb"
32 changes: 32 additions & 0 deletions server/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# syntax=docker/dockerfile:1

# Build the Ktor proxy as a self-contained fat JAR.
# Build context is the repository ROOT (the server module depends on :shared), e.g.:
# docker build -f server/Dockerfile -t spectacled-proxy .
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app

# Warm the Gradle wrapper cache separately from sources for better layer caching.
COPY gradlew gradlew.bat settings.gradle.kts build.gradle.kts gradle.properties ./
COPY gradle ./gradle
RUN chmod +x gradlew

# Copy the rest of the project and build only the server fat JAR.
COPY . .
RUN ./gradlew --no-daemon :server:buildFatJar

# Minimal JRE runtime image.
FROM eclipse-temurin:21-jre AS runtime
WORKDIR /app

# Run as a non-root user.
RUN useradd --system --uid 10001 --create-home appuser
USER appuser

COPY --from=build /app/server/build/libs/server-all.jar /app/server.jar

# PaaS platforms (Fly.io, Render, …) inject PORT; the app honours it, defaulting to 8088.
ENV PORT=8080
EXPOSE 8080

ENTRYPOINT ["java", "-jar", "/app/server.jar"]
99 changes: 99 additions & 0 deletions server/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Spectacled CORS Proxy

A tiny [Ktor](https://ktor.io/) reverse proxy that lets the **web build** of Spectacled talk
to CalDAV servers.

## Why this exists

Spectacled talks to CalDAV servers using WebDAV HTTP methods (`PROPFIND`, `REPORT`, `MKCOL`, …).
On Android, iOS, and Desktop the app contacts your CalDAV server directly. **In the browser it
can't:** browsers enforce [CORS](https://developer.mozilla.org/docs/Web/HTTP/CORS), and CalDAV
servers (Nextcloud, Radicale, …) generally don't send the CORS headers a browser requires for
cross-origin WebDAV. Since Spectacled works against *any* server you point it at, we can't rely on
each of those servers being reconfigured.

This proxy sits between the web app and your CalDAV server: the browser calls the proxy (same
origin policy satisfied by CORS headers the proxy adds), and the proxy forwards the request to the
real server named in the `X-Target-Url` header. CORS is a **browser-only** restriction — the native
apps don't use this proxy at all.

> ### ⚠️ Trust: run your own
> The proxy terminates TLS, so it sees the `Authorization` header (your CalDAV credentials) in
> transit. **Whoever runs the proxy could read those credentials.** For that reason:
> - **Self-host your own instance** whenever you can — then you are the only one in the path.
> - Any shared/public instance (including a project demo) should be treated as **evaluation only —
> do not use real credentials** against a proxy you don't control.

## How it works

- Reads the destination from the `X-Target-Url` request header (or a `?target=` query parameter).
- Validates the target (scheme, host allow-list, private-address block — see below), then forwards
the method, headers, and body, streaming the response back with permissive CORS headers.
- `GET /` is a health/info endpoint.

## Configuration (environment variables)

| Variable | Default | Purpose |
|------------------------------|----------|-----------------------------------------------------------------------------------------------|
| `PORT` | `8088` | Port to bind. PaaS hosts (Fly.io, Render, …) inject this automatically. |
| `PROXY_ALLOWED_ORIGINS` | *(any)* | Comma-separated web origins allowed by CORS, e.g. `https://spectacled.techbee.at`. Unset = reflect any origin (**dev only**, logged as a warning). Set this in production. |
| `PROXY_ALLOWED_TARGET_HOSTS` | *(any)* | Comma-separated allow-list of destination hostnames. Unset = any host. Strongly recommended for a shared/demo instance so it can't be abused as an open relay. |
| `PROXY_REQUIRE_HTTPS_TARGET` | `true` | Reject non-`https` target URLs. |
| `PROXY_ALLOW_PRIVATE_TARGETS`| `false` | When `false`, targets that resolve to loopback/link-local/private/unique-local addresses (e.g. `169.254.169.254`, `127.0.0.1`) are rejected. This is the SSRF guard — leave it off in production. |

## Run locally

```bash
# From the repository root:
./gradlew :server:run
# Proxy on http://localhost:8088 (allows any origin/target — dev defaults)

# In the web app's Settings → Proxy server, use: http://localhost:8088
```

## Build a container

The image builds from the **repository root** (the module depends on `:shared`):

```bash
docker build -f server/Dockerfile -t spectacled-proxy .
docker run --rm -p 8088:8080 \
-e PROXY_ALLOWED_ORIGINS=https://spectacled.techbee.at \
-e PROXY_ALLOWED_TARGET_HOSTS=your-caldav.example \
spectacled-proxy
```

A pre-built image is published to GitHub Container Registry on `proxy-v*` tags
(see `.github/workflows/publish-proxy-image.yml`):

```bash
docker run --rm -p 8088:8080 \
-e PROXY_ALLOWED_ORIGINS=https://your-web-app.example \
ghcr.io/techbeeat/spectacled-proxy:latest
```

## Deploy to Fly.io (recommended)

[`fly.toml`](../fly.toml) in the repo root is a template. From the repository root:

```bash
fly launch --copy-config --no-deploy # or: fly apps create <your-app-name>
# Edit fly.toml: set a unique `app` name and your PROXY_ALLOWED_ORIGINS
fly deploy
```

Fly builds `server/Dockerfile`, injects `PORT`, and terminates TLS for you. The template scales to
zero (`auto_stop_machines`) to keep idle cost near nothing; expect a brief cold start on the first
request after idle. A `shared-cpu-1x` / 512 MB machine is enough for this JVM app.

Other container hosts (Render, Koyeb, Railway, …) work the same way — point them at
`server/Dockerfile`, set `PROXY_ALLOWED_ORIGINS`, and let the platform provide `PORT`.

## Tests

```bash
./gradlew :server:test
```

Covers the health endpoint, missing/rejected targets (host allow-list, https-only, private-address
SSRF guard), CORS preflight, and a full proxied round-trip.
Loading
Loading