Skip to content
Merged
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
51 changes: 51 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: Docker Publish

on:
release:
types: [published]

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

steps:
- uses: actions/checkout@v4

- name: Set up QEMU
uses: docker/setup-qemu-action@v3

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

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

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

- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
77 changes: 73 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# ☕ Watari

> [!NOTE]
> 🤖 This project has been **vibe-coded** with [Claude](https://claude.ai). Read bottom section to learn more.

**Watari** is a web GUI frontend for [rustypaste](https://github.com/orhun/rustypaste).

The project name comes from the japanese word **渡り** (`watari`, also written as **ワタリ** in [Katakana](https://en.wikipedia.org/wiki/Katakana)) which means ["crossing, passage, transit"](https://jisho.org/search/%22watari%22) and symbolizes the relationship with **rustypaste**.
Expand All @@ -18,30 +21,86 @@ The project name comes from the japanese word **渡り** (`watari`, also written

On top of providing a GUI, it comes with some additional features:

- 🔐 [OpenID Connect](https://openid.net/developers/how-connect-works/) Single sign-on (tested against [Zitadel](https://zitadel.com/))
- 🔐 [OpenID Connect](https://openid.net/developers/how-connect-works/) Single sign-on (tested against [Zitadel](https://zitadel.com/) and [Microsoft Entra ID)](https://www.microsoft.com/en-us/security/business/identity-access/microsoft-entra-id))
- 🗂️ Per-group token mapping
- 🔒 Optional client-side ([WebCrypto](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API)) password encryption

All built with a ***based*** technical stack: [axum](https://github.com/tokio-rs/axum) + [Askama](https://github.com/askama-rs/askama) + [HTMX](https://htmx.org/) + [SQLite](https://sqlite.org)

This project has been **vibe-scaffolded** with [Claude](https://claude.ai), you can find the full design here: [`watari.md`](./watari.md)

<p align="center">
<img alt="Watari UI screenshot" src="assets/watari-ui-screenshot.webp" />
</p>

## Table of contents

- [Running with Docker Compose (recommended)](#running-with-docker-compose-recommended)
- [OIDC groups setup](#oidc-groups-setup)
- [Running locally for development](#running-locally-for-development)
- [Project layout](#project-layout)
- [License](#license)
- [AI usage / vibe coding](#ai-usage--vibe-coding)

## Running with Docker Compose (recommended)

1. `cp env.example .env` and fill in `SESSION_SECRET` (`openssl rand -hex 32`),
`OIDC_CLIENT_SECRET`, and two distinct `RUSTYPASTE_TOKEN_*` secrets.
2. `cp rustypaste-config.example.toml rustypaste-config.toml` and paste the
same two rustypaste token values into `auth_tokens`/`delete_tokens`.
3. `cp token-bindings.example.yaml token-bindings.yaml` and adjust the
`groups` to match your IdP.
`groups` to match your IdP (see "OIDC groups setup" below).
4. Edit `docker-compose.yml`'s `OIDC_ISSUER_URL`, `OIDC_CLIENT_ID`,
`APP_BASE_URL`/`RUSTYPASTE_PUBLIC_URL` for your deployment.
5. `docker compose up -d --build`

### OIDC groups setup

Watari maps OIDC groups to rustypaste tokens via `token-bindings.yaml`
(§7 in `watari.md`), so the groups claim must actually be present in the
ID token. Two env vars control this: `OIDC_GROUPS_CLAIM` (which claim to
read groups from) and `OIDC_GROUPS_SCOPE` (an extra scope some IdPs need
requested before they'll populate that claim).

**Zitadel** — groups come from [project
roles](https://zitadel.com/docs/guides/integrate/service-users/authenticate-service-users),
not org groups:

1. In your Zitadel project, define roles under **Roles**, then grant them
to users (directly or via an org role grant).
2. Either enable **"Assert Roles on Authentication"** in the project's
general settings, or request the roles scope explicitly — Watari does
the latter:

```
OIDC_GROUPS_CLAIM=urn:zitadel:iam:org:project:roles
OIDC_GROUPS_SCOPE=urn:zitadel:iam:org:project:roles
```

3. `token-bindings.yaml`'s `groups` entries should match the role keys
you defined (e.g. `admin`, `user`).

**Microsoft Entra ID** — groups come from the token configuration, no
extra scope needed:

1. In the app registration, go to **Token configuration** → **Add
groups claim**, and pick Security groups (or All groups). Add it to
the ID token.
2. Leave `OIDC_GROUPS_SCOPE` unset; set:

```
OIDC_GROUPS_CLAIM=groups
```

3. By default Entra ID emits group **object IDs** (GUIDs), not names —
`token-bindings.yaml`'s `groups` entries need to be those GUIDs.
Alternatively, use **App roles** instead of security groups (assign
users/groups to roles in the app registration) and set
`OIDC_GROUPS_CLAIM=roles`, which emits the human-readable role value
strings instead.
4. Entra ID caps the groups claim at 200 groups per token ("[overage](https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference#groups-overage-claim)")
— above that it omits `groups` entirely and expects a Graph API call
instead, which Watari does not do. Prefer App roles if a user could
belong to that many groups.

## Running locally for development

Requires Rust (edition 2024, so a recent stable toolchain) and no external
Expand Down Expand Up @@ -77,3 +136,13 @@ Database migrations run automatically at startup (`DATABASE_PATH`, default
## License

AGPLv3 — see [`LICENSE`](./LICENSE).

## AI usage / vibe coding

The initial project scaffolding was done via a [technical spec](./watari.md) generated by Claude and further features/fixes have been done with Claude as well.

I have spent some time reading through the code, testing and troubleshooting things myself but far less
than I should have given the current project size.

I would like to reduce my AI usage and take back ownership of the code but I have honestly haven't had the time
to do so.
43 changes: 18 additions & 25 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,44 +1,37 @@
services:
rustypaste:
image: ghcr.io/orhun/rustypaste:latest
restart: unless-stopped
volumes:
- rustypaste-data:/app/upload
- ./rustypaste-config.toml:/app/config.toml:ro
networks: [internal]
# No published ports — only reachable from watari over the internal network.

watari:
build: .
restart: unless-stopped
environment:
OIDC_ISSUER_URL: "https://your-idp.example.com"
OIDC_CLIENT_ID: "watari"
OIDC_ISSUER_URL: "${OIDC_ISSUER_URL:?set in .env}"
OIDC_CLIENT_ID: "${OIDC_CLIENT_ID:?set in .env}"
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET:?set in .env}"
OIDC_REDIRECT_URI: "https://paste.example.com/auth/callback"
OIDC_GROUPS_CLAIM: "groups"
OIDC_REDIRECT_URI: "${OIDC_REDIRECT_URI:?set in .env}"
OIDC_GROUPS_CLAIM: "${OIDC_GROUPS_CLAIM:?set in .env}"
OIDC_GROUPS_SCOPE: "${OIDC_GROUPS_SCOPE:-}"
SESSION_SECRET: "${SESSION_SECRET:?set in .env}"
RUSTYPASTE_INTERNAL_URL: "http://rustypaste:8000"
RUSTYPASTE_PUBLIC_URL: "https://paste.example.com"
RUSTYPASTE_TOKEN_READONLY: "${RUSTYPASTE_TOKEN_READONLY:?set in .env}"
RUSTYPASTE_INTERNAL_URL: "${RUSTYPASTE_INTERNAL_URL:?set in .env}"
RUSTYPASTE_PUBLIC_URL: "${RUSTYPASTE_PUBLIC_URL:?set in .env}"
RUSTYPASTE_TOKEN_USER: "${RUSTYPASTE_TOKEN_USER:?set in .env}"
RUSTYPASTE_TOKEN_ADMIN: "${RUSTYPASTE_TOKEN_ADMIN:?set in .env}"
APP_BASE_URL: "https://paste.example.com"
APP_PORT: "3000"
APP_BASE_URL: "${APP_BASE_URL:?set in .env}"
DATABASE_PATH: "/data/app.db"
TOKEN_BINDINGS_PATH: "/app/token-bindings.yaml"
PBKDF2_ITERATIONS: "310000"
volumes:
- watari-data:/data
- ./token-bindings.yaml:/app/token-bindings.yaml:ro
depends_on: [rustypaste]
networks: [internal, public]
ports:
- "3000:3000"
networks: [traefik-network]
labels:
traefik.enable: true
traefik.http.routers.watari.rule: Host(`${DOMAIN}`)
traefik.http.routers.watari.entrypoints: websecure
traefik.http.routers.watari.tls.certresolver: letsencrypt
traefik.http.services.watari.loadbalancer.server.port: 3000

networks:
internal:
public:
traefik-network:
external: true

volumes:
rustypaste-data:
watari-data:
26 changes: 25 additions & 1 deletion env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,33 @@
# 32+ bytes of hex-encoded random data, e.g. `openssl rand -hex 32`.
SESSION_SECRET=

OIDC_ISSUER_URL=https://auth.example.com
OIDC_REDIRECT_URI=https://watari.example.com/auth/callback
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=

# Claim (and, if the IdP needs it, scope) that carries group membership.
# See README.md "OIDC groups setup" for Zitadel / Microsoft Entra ID specifics.
#
# Zitadel:
# When using Zitadel as OIDC provider:
# OIDC_GROUPS_CLAIM=urn:zitadel:iam:org:project:roles
# OIDC_GROUPS_SCOPE=urn:zitadel:iam:org:project:roles
#
# Microsoft Entra ID (leave OIDC_GROUPS_SCOPE unset):
# OIDC_GROUPS_CLAIM=groups
OIDC_GROUPS_CLAIM=
OIDC_GROUPS_SCOPE=

# Must exactly match the values in rustypaste-config.toml's auth_tokens
# (and, for the admin one, delete_tokens) — see rustypaste-config.example.toml.
RUSTYPASTE_TOKEN_READONLY=
RUSTYPASTE_INTERNAL_URL=https://rustypaste.example.com/
RUSTYPASTE_PUBLIC_URL=https://rustypaste.example.com/
RUSTYPASTE_TOKEN_USER=
RUSTYPASTE_TOKEN_ADMIN=

DOMAIN=watari.example.com
APP_BASE_URL=https://watari.example.com
APP_PORT=3000

DATABASE_PATH=./dev.db
Loading