Skip to content

Commit baa71b3

Browse files
committed
feat: Add Calendar Proxy application with Docker support
- Implemented a FastAPI application for a calendar proxy that allows browser-only calendar links to be used by calendar clients. - Added Dockerfile for building the application with Tailwind CSS for styling. - Created docker-compose files for development and production environments with security hardening. - Added .gitignore to exclude sensitive environment files. - Included README.md with instructions for self-hosting, environment variables, and usage examples. - Implemented health check endpoint and static file serving for a simple UI. - Added input validation, rate limiting, and logging features. - Integrated Redis for optional rate limiting in production. - Included Tailwind CSS for styling the UI. - Added example requirements.txt for Python dependencies.
0 parents  commit baa71b3

File tree

11 files changed

+1181
-0
lines changed

11 files changed

+1181
-0
lines changed
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
name: Build and publish Docker image
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
8+
permissions:
9+
contents: read
10+
packages: write
11+
12+
jobs:
13+
build-and-publish:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- name: Checkout repo
17+
uses: actions/checkout@v4
18+
19+
- name: Set up QEMU (for multi-arch builds)
20+
uses: docker/setup-qemu-action@v2
21+
22+
- name: Set up Docker Buildx
23+
uses: docker/setup-buildx-action@v2
24+
25+
- name: Log in to GitHub Container Registry
26+
uses: docker/login-action@v2
27+
with:
28+
registry: ghcr.io
29+
username: ${{ github.actor }}
30+
password: ${{ secrets.GITHUB_TOKEN }}
31+
32+
- name: Build and push Docker image (multi-arch)
33+
uses: docker/build-push-action@v4
34+
with:
35+
context: .
36+
push: true
37+
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6,linux/386
38+
tags: |
39+
ghcr.io/${{ github.repository_owner }}/calendar-proxy:latest
40+
ghcr.io/${{ github.repository_owner }}/calendar-proxy:${{ github.sha }}
41+
file: Dockerfile

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.env

Dockerfile

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Build stage - for CSS generation only
2+
FROM python:3.13.7-slim AS builder
3+
4+
# Install build dependencies for Tailwind CSS
5+
RUN apt-get update && apt-get install -y --no-install-recommends \
6+
curl ca-certificates \
7+
&& rm -rf /var/lib/apt/lists/*
8+
9+
# Tailwind CSS version (renovate: datasource=github-releases depName=tailwindlabs/tailwindcss)
10+
ARG TAILWIND_VERSION=v4.1.12
11+
12+
# Install architecture-specific Tailwind CSS
13+
RUN case "$(uname -m)" in \
14+
x86_64) \
15+
curl -L https://github.com/tailwindlabs/tailwindcss/releases/download/${TAILWIND_VERSION}/tailwindcss-linux-x64 -o /usr/local/bin/tailwindcss ;; \
16+
aarch64) \
17+
curl -L https://github.com/tailwindlabs/tailwindcss/releases/download/${TAILWIND_VERSION}/tailwindcss-linux-arm64 -o /usr/local/bin/tailwindcss ;; \
18+
*) \
19+
echo "Unsupported architecture: $(uname -m)" && exit 1 ;; \
20+
esac && \
21+
chmod +x /usr/local/bin/tailwindcss
22+
23+
WORKDIR /app
24+
25+
# Build Tailwind CSS
26+
COPY src/index.html src/input.css ./
27+
RUN mkdir -p static && tailwindcss -i ./input.css --content ./index.html -o ./static/style.css --minify
28+
29+
# Runtime stage - minimal Python environment
30+
FROM python:3.13.7-slim AS runtime
31+
32+
# Create non-root user for security
33+
RUN groupadd --gid 1000 appuser && \
34+
useradd --uid 1000 --gid appuser --shell /bin/bash --create-home appuser
35+
36+
# Install only runtime Python dependencies (no build tools)
37+
COPY src/requirements.txt ./
38+
RUN pip install --no-cache-dir -r requirements.txt && rm requirements.txt
39+
40+
# Create app directory and set ownership
41+
WORKDIR /app
42+
RUN chown -R appuser:appuser /app
43+
44+
# Switch to non-root user
45+
USER appuser
46+
47+
# Copy application files (as non-root user)
48+
COPY --chown=appuser:appuser src/app.py src/index.html ./
49+
50+
# Copy built CSS from builder stage (as non-root user)
51+
COPY --from=builder --chown=appuser:appuser /app/static ./static
52+
53+
# Runtime configuration
54+
ENV PYTHONUNBUFFERED=1
55+
EXPOSE 8000
56+
57+
# Health check
58+
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=5 \
59+
CMD curl -f http://localhost:8000/healthz
60+
61+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

README.md

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# Calendar-Proxy
2+
3+
A tiny proxy to make browser-only calendar links (for example Office 365 `reachcalendar.ics`) usable by calendar clients that can't authenticate or set custom User-Agent headers.
4+
5+
## Self-hosting
6+
7+
Run a single container (secure defaults):
8+
9+
```bash
10+
docker run -d --restart=unless-stopped \
11+
--name calendar-proxy \
12+
-p 8000:8000 \
13+
--read-only \
14+
--cap-drop=ALL \
15+
--security-opt=no-new-privileges:true \
16+
--memory=512m \
17+
--cpus=0.5 \
18+
--tmpfs=/tmp:noexec,nosuid,size=100m \
19+
--tmpfs=/var/tmp:noexec,nosuid,size=50m \
20+
--env-file .env \
21+
calendar-proxy
22+
```
23+
24+
`.env` (example, keep this file private):
25+
26+
```
27+
PROXY_TOKENS=longtoken123
28+
ALLOWED_HOSTS=example.com,calendar.example.org
29+
LOG_LEVEL=WARNING
30+
DISABLE_ACCESS_LOG=true
31+
```
32+
33+
Notes:
34+
- Prefer `--env-file` over inline `-e` to avoid leaking secrets in process lists or shell history.
35+
- Container runs as non-root user (UID 1000) for enhanced security.
36+
- Hardened with `--read-only`, capability drops, `no-new-privileges`, resource limits, and temporary filesystems.
37+
- Use `REDIS_URL` when running multiple containers for shared rate-limiting.
38+
- For production, consider using `docker-compose.production.yml` which includes additional security measures.
39+
40+
## Required / recommended env vars
41+
42+
- `PROXY_TOKENS` (required): comma-separated secret tokens used in subscription URLs.
43+
- `ALLOWED_HOSTS` (recommended): comma-separated allowed upstream hostnames.
44+
- `REDIS_URL` (optional): `redis://...` for cross-process rate limiting.
45+
- `RATE_LIMIT_PER_MIN` (default `60`): per-token request limit per minute.
46+
- `UPSTREAM_USER_AGENT` (default: Safari on macOS): User-Agent header sent to upstream servers.
47+
- `MAX_RESPONSE_BYTES` (default: `5242880` bytes / 5MB): maximum response size to prevent memory exhaustion.
48+
- `CONNECT_TIMEOUT` (default: `10` seconds): timeout for establishing upstream connections.
49+
- `READ_TIMEOUT` (default: `20` seconds): timeout for reading upstream response data.
50+
- `ALLOWED_CONTENT_TYPES` (default: `text/calendar,text/plain,application/octet-stream`): comma-separated allowed upstream content types.
51+
- `LOG_LEVEL` (default: `INFO`): application log level (DEBUG, INFO, WARNING, ERROR).
52+
- `DISABLE_ACCESS_LOG` (default: `true`): set to `false` to enable logging of URLs and accesses.
53+
54+
## Build from source
55+
56+
```bash
57+
docker build -t calendar-proxy .
58+
```
59+
60+
## Production deployment
61+
62+
For production use, a hardened `docker-compose.production.yml` is provided:
63+
64+
```bash
65+
# Copy and customize the production compose file
66+
cp docker-compose.production.yml docker-compose.yml
67+
68+
# Create secure environment file
69+
echo "PROXY_TOKENS=$(openssl rand -hex 40)" > .env
70+
71+
# Deploy with enhanced security
72+
docker-compose up -d
73+
```
74+
75+
The production configuration includes:
76+
77+
- **Non-root execution**: Runs as UID/GID 1000
78+
- **Read-only filesystem**: Prevents runtime modifications
79+
- **Capability restrictions**: Drops all capabilities except essential ones
80+
- **Resource limits**: Memory (512MB) and CPU (0.5 cores) constraints
81+
- **No new privileges**: Prevents privilege escalation
82+
- **Temporary filesystems**: Secure `/tmp` and `/var/tmp` mounts
83+
- **Enhanced logging**: Production-ready log levels
84+
85+
## How the proxy works (short)
86+
87+
- Endpoint: `GET /sub/{token}/{b64url}/{name}.ics`
88+
- `token`: secret in the path.
89+
- `b64url`: URL-safe base64 (no padding) of the full upstream URL.
90+
- `name.ics`: final filename for clients.
91+
- Optional `ua` query param: override User-Agent used to fetch upstream.
92+
93+
Security & runtime behaviour:
94+
- **Container security**: Runs as non-root user, read-only filesystem, dropped capabilities, resource limits.
95+
- Validates `token` against `PROXY_TOKENS`.
96+
- Resolves `b64url` hostname and refuses private/loopback/link-local/multicast/reserved IPs (SSRF protection).
97+
- Enforces `ALLOWED_HOSTS` when set.
98+
- Validates upstream content types against `ALLOWED_CONTENT_TYPES`.
99+
- Per-token rate limiting (Redis if `REDIS_URL` set, otherwise in-process fallback with automatic cleanup).
100+
- Streams upstream response, enforces timeouts and a maximum response byte cap.
101+
- Strips client cookies and Authorization; forwards only safe upstream headers (e.g. `Content-Type`, `Content-Disposition`).
102+
- Sanitizes URLs in logs to prevent exposure of sensitive information (when `DISABLE_ACCESS_LOG=true`).
103+
104+
## Why macOS Calendar can't directly subscribe to Microsoft Office 365 links (very short)
105+
106+
1) Office 365 shared URLs are browser-centric
107+
- Shared `reachcalendar.ics` links are designed to be opened by a browser, which may provide cookies or other auth automatically; macOS Calendar does not.
108+
109+
2) User-Agent restrictions
110+
- Microsoft checks User-Agent and rejects unsupported clients. Requests from macOS `CalendarAgent` often get a "Outlook is not supported on this browser" page instead of an ICS.
111+
112+
3) No auth flow support
113+
- Some shared calendars require Microsoft authentication (cookies/OAuth). macOS Calendar cannot perform those interactive browser flows.
114+
115+
Workarounds: use this proxy (fetch with a browser-like UA), import into a service that fetches server-side, or download & import manually.
116+
117+
## Quick examples
118+
119+
Generate a URL-safe base64 without padding (shell):
120+
121+
```bash
122+
python - <<'PY'
123+
import base64
124+
u='https://example.com/cal.ics'
125+
print(base64.urlsafe_b64encode(u.encode()).decode().rstrip('='))
126+
PY
127+
```
128+
129+
Or without Python (using base64 + tr):
130+
131+
```bash
132+
echo -n 'https://example.com/cal.ics' | base64 -w0 | tr '+/' '-_' | tr -d '='
133+
```
134+
135+
Subscription URL:
136+
137+
```
138+
https://proxy.example.com/sub/<token>/<b64url>/Work.ics
139+
```
140+
141+
Open `http://<host>:8000/` in a browser to use the included `index.html` UI for building links.
142+
143+
## Endpoints
144+
145+
- `GET /sub/{token}/{b64url}/{name}.ics` — subscription proxy endpoint.
146+
- `GET /healthz` — health check JSON response.
147+
- `GET /` — web UI for building calendar subscription URLs.
148+
- `GET /static/{path}` — static files (CSS, etc.) for the web UI.
149+
150+
If you want an admin UI for token management, Redis-backed caching, or a `docker-compose.yml` with Redis, tell me which and I'll add it.

docker-compose.production.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
services:
2+
app:
3+
image: calendar-proxy:latest
4+
restart: unless-stopped
5+
expose:
6+
- 8000 # Assuming the app is behind a reverse proxy
7+
environment:
8+
# Required settings
9+
# - PROXY_TOKENS=test-token-123,debug-token-456 # Should be set in .env; Generate it using: openssl rand -hex 40
10+
# Optional settings
11+
- ALLOWED_HOSTS=outlook.office365.com,calendar.google.com
12+
- LOG_LEVEL=WARNING
13+
- DISABLE_ACCESS_LOG=true
14+
env_file: .env
15+
16+
# Security hardening
17+
read_only: true
18+
cap_drop:
19+
- ALL
20+
security_opt:
21+
- no-new-privileges:true
22+
23+
# Resource limits
24+
deploy:
25+
resources:
26+
limits:
27+
memory: 512M
28+
cpus: '0.5'
29+
30+
# Temporary filesystems for read-only container
31+
tmpfs:
32+
- /tmp:noexec,nosuid,size=100m
33+
- /var/tmp:noexec,nosuid,size=50m
34+
35+
healthcheck:
36+
test: ["CMD", "curl", "-f", "http://localhost:8000/healthz"]
37+
interval: 30s
38+
timeout: 10s
39+
retries: 5
40+
start_period: 5s

docker-compose.yml

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
services:
2+
app:
3+
image: calendar-proxy:latest
4+
build:
5+
context: .
6+
dockerfile: Dockerfile
7+
restart: unless-stopped
8+
ports:
9+
- 8000:8000
10+
11+
# For development - mount source for live reload (uncomment if needed)
12+
# volumes:
13+
# - ./src:/app:ro
14+
15+
environment:
16+
# Required settings
17+
- PROXY_TOKENS=test-token-123,test-token-456
18+
19+
# Optional settings for testing
20+
- ALLOWED_HOSTS=outlook.office365.com,calendar.google.com
21+
- RATE_LIMIT_PER_MIN=60
22+
- UPSTREAM_USER_AGENT=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15
23+
- MAX_RESPONSE_BYTES=10485760
24+
- CONNECT_TIMEOUT=10
25+
- READ_TIMEOUT=30
26+
- ALLOWED_CONTENT_TYPES=text/calendar,text/plain,text/html,application/octet-stream
27+
28+
# Logging settings
29+
- LOG_LEVEL=DEBUG # Options: DEBUG, INFO, WARNING, ERROR
30+
- DISABLE_ACCESS_LOG=false # Set to 'true' to disable uvicorn access logs entirely
31+
32+
# Security hardening
33+
read_only: true
34+
cap_drop:
35+
- ALL
36+
security_opt:
37+
- no-new-privileges:true
38+
39+
# Resource limits
40+
deploy:
41+
resources:
42+
limits:
43+
memory: 512M
44+
cpus: '0.5'
45+
46+
# Temporary filesystems for read-only container
47+
tmpfs:
48+
- /tmp:noexec,nosuid,size=100m
49+
- /var/tmp:noexec,nosuid,size=50m
50+
51+
# Health check
52+
healthcheck:
53+
test: ["CMD", "curl", "-f", "http://localhost:8000/healthz"]
54+
interval: 30s
55+
timeout: 10s
56+
retries: 5
57+
start_period: 5s
58+
59+
# Optional: Redis for rate limiting in production
60+
# Uncomment if you want to test with Redis
61+
# redis:
62+
# image: redis:7-alpine
63+
# ports:
64+
# - "6379:6379"
65+
# command: redis-server --appendonly yes
66+
# volumes:
67+
# - redis_data:/data
68+
# restart: unless-stopped
69+
70+
# volumes:
71+
# redis_data:

renovate.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
3+
"extends": [
4+
"config:recommended"
5+
],
6+
7+
"reviewers": [
8+
"OidaTiftla"
9+
],
10+
11+
"packageRules": [
12+
{
13+
"matchUpdateTypes": ["minor", "patch"],
14+
"matchCurrentVersion": "!/^0/",
15+
"ignoreTests": true,
16+
"automerge": true
17+
}
18+
],
19+
20+
"timezone": "Europe/Berlin",
21+
"schedule": ["before 5am"]
22+
}

0 commit comments

Comments
 (0)