From 3d38c507e8a921b1ecb245313b840ca0544eddb0 Mon Sep 17 00:00:00 2001 From: reachableceo Date: Tue, 1 Sep 2026 19:28:06 -0500 Subject: [PATCH] feat: add Database-Gateway Cloudron package (Infrastructure) [#639] Database Gateway 0.24.0 as the 12th package: multi-stage Go build (CGO required by the libpg_query parser, hence an alpine:3.23 runtime matching upstream), native OIDC wired to the Cloudron platform identity provider, postgresql addon storage with goose migrations applied at start, jq-generated config + OPA policy seeded to /app/data. Verified end-to-end against a throwaway postgres (migrations, policy compile, startup to the OIDC handoff). Docs gardened to 12 packages. Ticket: https://projects.knownelement.com/issues/639 --- JOURNAL.md | 75 ++++++++++- .../database-gateway/.env.example | 30 +++++ .../database-gateway/CHANGELOG.md | 18 +++ .../database-gateway/CloudronManifest.json | 24 ++++ .../database-gateway/Dockerfile | 65 ++++++++++ .../Infrastructure/database-gateway/README.md | 85 +++++++++++++ .../Infrastructure/database-gateway/logo.png | Bin 0 -> 19729 bytes .../Infrastructure/database-gateway/start.sh | 120 ++++++++++++++++++ README.md | 11 +- STATUS.md | 16 ++- 10 files changed, 431 insertions(+), 13 deletions(-) create mode 100644 Package-Workspace/Infrastructure/database-gateway/.env.example create mode 100644 Package-Workspace/Infrastructure/database-gateway/CHANGELOG.md create mode 100644 Package-Workspace/Infrastructure/database-gateway/CloudronManifest.json create mode 100644 Package-Workspace/Infrastructure/database-gateway/Dockerfile create mode 100644 Package-Workspace/Infrastructure/database-gateway/README.md create mode 100644 Package-Workspace/Infrastructure/database-gateway/logo.png create mode 100755 Package-Workspace/Infrastructure/database-gateway/start.sh diff --git a/JOURNAL.md b/JOURNAL.md index 8f6e5ae..e1cf981 100644 --- a/JOURNAL.md +++ b/JOURNAL.md @@ -4,7 +4,7 @@ **Project**: TSYSDevStack-SupportStack-Cloudron **Goal**: Package ~57 applications for Cloudron PaaS platform **Start Date**: 2025-01-24 -**Current Status**: 11/~57 packages completed (~19%) +**Current Status**: 12/~57 packages completed (~21%) ## Completed Packages @@ -893,6 +893,79 @@ traversal (frp/ngrok class, Rust); this package runs the **server** side **Commit**: `feat: add Rathole Cloudron package (Infrastructure) [#650]` +### 12. Database Gateway (Infrastructure) ✅ +**Date**: 2026-09-01 +**Application**: Database Gateway (dbgw) — policy-checked web gateway to +PostgreSQL databases (OIDC login, OPA-authorized queries, cached results) +**Package Size**: 93.7MB (alpine runtime; smallest package so far) +**Port**: 8080 (httpPort, single listener — web UI + LRPC API) +**Addons**: localstorage, postgresql (16) + +**Key Learnings**: +- **Auth gate verdict**: NATIVE OIDC, the preferred row — no local users, + no LDAP, the only login path is an OIDC provider. Perfect fit for the + Cloudron platform provider: `CLOUDRON_OIDC_ISSUER` / `CLIENT_ID` / + `CLIENT_SECRET` are written into `users.*` of the seeded config; + redirect URL derives from `CLOUDRON_APP_ORIGIN` + `/auth/callback`; + roles map from the `groups` claim (Cloudron `admins` → admin, + `users` → user). First package in the set to consume the platform + OIDC env vars end-to-end +- **cgo is load-bearing**: the SQL parser is a libpg_query cgo binding — + `CGO_ENABLED=0` fails to compile (`undefined: pg.Parse`). The binary + therefore links musl and will not exec on Ubuntu/glibc bases (fails + with a misleading "no such file or directory") → runtime base is + alpine:3.23, same as upstream's own image, with bash / jq / openssl / + postgresql16-client apk-installed for start.sh (the mirror image of the + Rathole glibc trap) +- **Config is a file, not env**: the app takes one JSON config (`-c`), + validated with required `role_mapping` and `policy.path`. start.sh + builds it with `jq -n --arg` on first run so secrets are escaped + safely into JSON, then admins edit `/app/data/config.json` (targets, + role mapping) and `/app/data/opa/simple.rego` (query policy) with the + Cloudron file manager; restart applies +- **Migrations**: goose SQL embedded in the binary; `migrate-up` subcommand + is run explicitly in start.sh (the app also self-migrates on `run` — + belt and suspenders, both idempotent) +- **Frontend is pre-embedded**: upstream commits `internal/facade/ui/dist` + (`go:embed`), so the Go build ships the web UI — no Node stage needed +- **Validate() quirks**: targets list may be empty (seed `[]`), but every + `tables[].table` must be schema-qualified (`public.foo`) and every + role_mapping value must be exactly `admin` or `user` + +**Build Process**: +- Upstream `kazhuravlev/database-gateway` v0.24.0 (GPL-3.0, Go 1.26.1) + built from the cloned repo (multi-stage pattern #2): golang:1.26-alpine + builder mirrors the upstream Dockerfile (CGO_ENABLED=1 + ldflags + version stamp) → alpine:3.23 runtime +- Logo: upstream `frontend/src/favicon-96x96.png` copied directly +- start.sh: psql wait loop on the postgresql addon → seed `.cookie_secret` + (openssl rand -hex 32), `opa/simple.rego` (admins-only default), + `config.json` (jq template) → `migrate-up` → `exec run` + +**Validation**: +- `docker build --cgroup-parent ukrrs-batch.slice -t + database-gateway-cloudron:test` → green +- `--version` smoke in the image → `gateway version v0.24.0` +- End-to-end vs a throwaway postgres:16-alpine on a docker network with + fake CLOUDRON_* env: PG wait loop, config + policy seeding, all 5 + goose migrations applied (bookmarks / query_results tables verified + with psql), rego policy compiled, gateway reached the OIDC discovery + step and failed only on the bogus issuer (expected off-Cloudron; the + real platform issuer resolves at install time) + +**Files Created**: +- Dockerfile (multi-stage Go, CGO, alpine:3.23 runtime) +- CloudronManifest.json (manifestVersion 2, httpPort 8080, localstorage + + postgresql 16) +- start.sh (DB wait, jq config seed, policy seed, migrate-up, exec) — + committed executable +- README.md (auth story, config guide, target walkthrough) +- CHANGELOG.md +- .env.example (Cloudron-provided env documented) +- logo.png (96x96, upstream favicon) + +**Commit**: `feat: add Database-Gateway Cloudron package (Infrastructure) [#639]` + --- ## Packaging Pattern: Download Pre-Compiled Binaries diff --git a/Package-Workspace/Infrastructure/database-gateway/.env.example b/Package-Workspace/Infrastructure/database-gateway/.env.example new file mode 100644 index 0000000..3b9abd9 --- /dev/null +++ b/Package-Workspace/Infrastructure/database-gateway/.env.example @@ -0,0 +1,30 @@ +# Environment knobs for the Database Gateway Cloudron package. +# +# The app itself is configured through /app/data/config.json (seeded on +# first start; see README.md). The variables below are consumed by +# start.sh at seed time and are provided automatically by Cloudron — +# do NOT set them by hand. + +# --- Cloudron platform OIDC provider (injected by Cloudron) ------------------- +# Written into users.* of /app/data/config.json on first start. +#CLOUDRON_OIDC_ISSUER=https://my.example.com/openid +#CLOUDRON_OIDC_CLIENT_ID= +#CLOUDRON_OIDC_CLIENT_SECRET= +#CLOUDRON_OIDC_TOKEN_SIGNATURE_ALGORITHM=RS256 + +# --- App origin (injected by Cloudron) ---------------------------------------- +# Redirect URL is derived as ${CLOUDRON_APP_ORIGIN}/auth/callback. +#CLOUDRON_APP_ORIGIN=https://dbgw.example.com + +# --- PostgreSQL addon (injected by Cloudron) ---------------------------------- +# Gateway storage: profiles, bookmarks, query results, migrations. +#CLOUDRON_POSTGRESQL_HOST= +#CLOUDRON_POSTGRESQL_PORT=5432 +#CLOUDRON_POSTGRESQL_DATABASE= +#CLOUDRON_POSTGRESQL_USERNAME= +#CLOUDRON_POSTGRESQL_PASSWORD= + +# --- Operator-editable files in /app/data ------------------------------------- +# config.json database targets + role mapping (restart after edit) +# opa/simple.rego query authorization policy (restart after edit) +# .cookie_secret session cookie secret (rotate = logout everyone) diff --git a/Package-Workspace/Infrastructure/database-gateway/CHANGELOG.md b/Package-Workspace/Infrastructure/database-gateway/CHANGELOG.md new file mode 100644 index 0000000..492e74b --- /dev/null +++ b/Package-Workspace/Infrastructure/database-gateway/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +## [0.24.0] - 2026-09-01 + +Initial Cloudron package for +[Database Gateway](https://github.com/kazhuravlev/database-gateway) v0.24.0 +(ticket [#639](https://projects.knownelement.com/issues/639)). + +- Multi-stage Go build (golang:1.26-alpine → cloudron/base:3.2.0), frontend + embedded by upstream via `go:embed`. +- Native OIDC login wired to the Cloudron platform identity provider + (`CLOUDRON_OIDC_ISSUER` / `CLIENT_ID` / `CLIENT_SECRET`); roles mapped from + the `groups` claim (`admins` → admin, `users` → user). +- `postgresql` addon (16) for the gateway's own storage; migrations applied + on every start via `migrate-up`. +- `/app/data/config.json` + `/app/data/opa/simple.rego` seeded on first run; + operators add database targets and OPA policy rules via the file manager. +- Single HTTP listener on port 8080 (`httpPort`). diff --git a/Package-Workspace/Infrastructure/database-gateway/CloudronManifest.json b/Package-Workspace/Infrastructure/database-gateway/CloudronManifest.json new file mode 100644 index 0000000..74e0d2a --- /dev/null +++ b/Package-Workspace/Infrastructure/database-gateway/CloudronManifest.json @@ -0,0 +1,24 @@ +{ + "manifestVersion": 2, + "type": "app", + "id": "io.cloudron.dbgateway", + "title": "Database Gateway", + "description": "Web gateway for secure, policy-controlled access to PostgreSQL databases. Users authenticate via OIDC (Cloudron single sign-on) and run queries that are parsed and authorized by embedded OPA policies per user, target, operation and table. Query results are stored with shareable links for debugging and auditing.", + "author": "Kirill Zhuravlev", + "website": "https://github.com/kazhuravlev/database-gateway", + "contactEmail": "cloudron@tsys.dev", + "tagline": "Policy-checked SQL gateway for your databases", + "version": "0.24.0", + "healthCheckPath": "/", + "httpPort": 8080, + "memoryLimit": 512, + "addons": { + "localstorage": true, + "postgresql": { + "version": "16" + } + }, + "mediaLinks": [], + "changelog": "Initial Cloudron package for Database Gateway 0.24.0. Native OIDC login wired to the Cloudron platform identity provider (admins group -> gateway admin, users group -> gateway user); local PostgreSQL addon stores profiles, bookmarks and query results; OPA policies and database targets are configured from /app/data. Single HTTP listener on port 8080.", + "icon": "file://logo.png" +} diff --git a/Package-Workspace/Infrastructure/database-gateway/Dockerfile b/Package-Workspace/Infrastructure/database-gateway/Dockerfile new file mode 100644 index 0000000..14aed34 --- /dev/null +++ b/Package-Workspace/Infrastructure/database-gateway/Dockerfile @@ -0,0 +1,65 @@ +# Database Gateway Cloudron Package +# +# Database Gateway (dbgw) is a web gateway for secure, policy-controlled +# access to PostgreSQL databases: users log in via OIDC and every query is +# parsed and authorized by embedded OPA policies (per user, target, +# operation and table) before it reaches a remote database. Query results +# are stored in a local PostgreSQL storage with shareable links. +# +# Upstream: https://github.com/kazhuravlev/database-gateway (v0.24.0, Go 1.26, Echo) +# - Single Go binary; the frontend is already built and embedded in the +# repo (internal/facade/ui/dist via go:embed), so no Node stage needed +# - Listens on 0.0.0.0:8080; config file passed with `-c config.json` +# - Storage schema applied via the `migrate-up` subcommand (embedded SQL) +# +# Authentication: NATIVE OIDC (preferred). start.sh wires the Cloudron +# platform OIDC provider (CLOUDRON_OIDC_ISSUER / CLIENT_ID / CLIENT_SECRET) +# into /app/data/config.json on first run; roles map from the Cloudron +# `groups` claim (admins -> admin, users -> user). +# +# NOTE on base images: the SQL parser is a cgo binding (libpg_query), so the +# binary MUST be built with CGO_ENABLED=1 — a static CGO_ENABLED=0 build +# fails with `undefined: pg.Parse`. A musl-linked binary then needs a musl +# runtime, hence alpine:3.23 (same as upstream's own image) instead of the +# usual cloudron/base (Ubuntu/glibc). +FROM golang:1.26-alpine AS builder + +ARG VERSION=v0.24.0 + +WORKDIR /src + +# Mirrors the upstream Dockerfile build dependencies (git for module +# fetches, gcc/musl-dev for the CGO-enabled build). +RUN apk add --no-cache ca-certificates git gcc musl-dev + +# Cache dependency downloads separately from source changes. +COPY repo/go.mod repo/go.sum ./ +RUN go mod download + +COPY repo/ . +RUN CGO_ENABLED=1 go build \ + -ldflags "-s -w -X github.com/dev-services42/version.version=${VERSION}" \ + -o /out/database-gateway \ + ./cmd/gateway + +# Same runtime base as the upstream image. The tools back start.sh: +# bash (script), postgresql16-client (wait for the Cloudron PG addon), +# jq (seed config.json), openssl (cookie secret). +FROM alpine:3.23 + +RUN apk add --no-cache bash ca-certificates jq openssl postgresql16-client + +COPY --from=builder /out/database-gateway /usr/local/bin/database-gateway + +# start.sh waits for the postgresql addon, seeds /app/data/config.json and +# /app/data/opa/ on first run, applies migrations, then execs the gateway. +# Made executable on the host, not at build time (Cloudron builds hit +# permission errors on RUN chmod). +COPY start.sh /app/start.sh + +ENV WORKDIR=/app/data +WORKDIR ${WORKDIR} + +EXPOSE 8080 + +CMD ["/bin/bash", "/app/start.sh"] diff --git a/Package-Workspace/Infrastructure/database-gateway/README.md b/Package-Workspace/Infrastructure/database-gateway/README.md new file mode 100644 index 0000000..b6958f6 --- /dev/null +++ b/Package-Workspace/Infrastructure/database-gateway/README.md @@ -0,0 +1,85 @@ +# Database Gateway — Cloudron Package + +[Database Gateway](https://github.com/kazhuravlev/database-gateway) (dbgw) is +a web gateway for secure, controlled access to PostgreSQL databases: users +log in via OIDC and run SQL through a unified web UI, with every query +parsed and authorized by embedded **OPA policies** (per user, target, +operation and table). Results are stored in a local PostgreSQL with +shareable links for debugging and auditing. + +## Packaging overview + +| Aspect | Choice | +|--------|--------| +| Pattern | Multi-stage Go build (JOURNAL pattern #2) | +| Builder | `golang:1.26-alpine` (upstream's own build recipe, CGO on) | +| Base image | `alpine:3.23` (musl runtime required — see below) | +| Upstream | v0.24.0 (Go 1.26.1, Echo; frontend pre-built and embedded via `go:embed`) | +| Addons | `localstorage` + `postgresql` (16) — storage for profiles, bookmarks, query results | +| Auth | **Native OIDC (preferred)** wired to the Cloudron platform identity provider | +| Runtime | `start.sh` waits for Postgres, seeds config + OPA policy, `migrate-up`, execs `run` | + +Why alpine instead of `cloudron/base`: the SQL parser is a cgo binding +(libpg_query) — the binary must be built with `CGO_ENABLED=1` (a static +build fails with `undefined: pg.Parse`) and therefore links musl, which +does not exec on Ubuntu/glibc bases. Alpine runtime matches upstream's own +image; bash/psql/jq/openssl are apk-installed for `start.sh`. + +Why not the official image: upstream publishes `ghcr.io/kazhuravlev/ +database-gateway:latest` but tagging is `:latest`-only; building v0.24.0 +from the cloned source pins the version and lets the ldflags carry the +release tag into `/healthcheck`-style version output. + +## Authentication (auth gate verdict: OIDC, preferred) + +Database Gateway authenticates **only** via OIDC — no local user database, +no LDAP. This is the preferred row of the auth matrix: + +- `CLOUDRON_OIDC_ISSUER`, `CLOUDRON_OIDC_CLIENT_ID`, + `CLOUDRON_OIDC_CLIENT_SECRET` (injected by Cloudron's platform OIDC + provider, manifestVersion 2) are written into `/app/data/config.json` on + first start. +- Redirect URL: `${CLOUDRON_APP_ORIGIN}/auth/callback`. +- Scopes: `openid profile email groups`; `role_claim` is `groups`, mapped + `admins` → `admin`, `users` → `user` (Cloudron's built-in groups). +- Access policy is then enforced per query by OPA (see below), not just at + login. + +## Ports + +| Port | Type | Purpose | +|------|------|---------| +| 8080 | `httpPort` | Web UI + LRPC API (only listener) | + +## Configuration (persistent, in `/app/data`) + +- `config.json` — seeded once on first start (OIDC + storage from Cloudron + env). **Add your database targets here** with the Cloudron file manager; + restart the app after editing. See + [upstream example](https://github.com/kazhuravlev/database-gateway/blob/master/example/config.json) + for the full target format (connection, `default_schema`, + `tables[].table` must be schema-qualified, `fields` allowlist). +- `opa/simple.rego` — seeded default: Cloudron `admins` see all targets and + may run any op; everyone else denied until an operator extends the policy + (input subjects contain `user:` and `role:`). +- `.cookie_secret` — random 32-byte hex, generated once; rotating it + invalidates sessions. + +The **postgresql addon** database is the gateway's own storage (users, +bookmarks, cached results) — not a query target. Targets point at the +*other* PostgreSQL instances you want to reach. + +## Build & test + +```bash +docker build --cgroup-parent ukrrs-batch.slice -t database-gateway-cloudron:test Package-Workspace/Infrastructure/database-gateway/ +docker run --rm --entrypoint /usr/local/bin/database-gateway database-gateway-cloudron:test --version +``` + +## Files + +- `Dockerfile` — multi-stage Go build (golang:1.26-alpine → alpine:3.23, musl) +- `CloudronManifest.json` — manifestVersion 2, httpPort 8080, localstorage + postgresql addons +- `start.sh` — DB wait, config/policy seeding, migrations, exec gateway +- `.env.example` — environment knobs consumed at seed time +- `logo.png` — from upstream `frontend/src/favicon-96x96.png` diff --git a/Package-Workspace/Infrastructure/database-gateway/logo.png b/Package-Workspace/Infrastructure/database-gateway/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..38af8b5b6914c94cea1e899174e797af3212aa6f GIT binary patch literal 19729 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 literal 0 HcmV?d00001 diff --git a/Package-Workspace/Infrastructure/database-gateway/start.sh b/Package-Workspace/Infrastructure/database-gateway/start.sh new file mode 100755 index 0000000..1569087 --- /dev/null +++ b/Package-Workspace/Infrastructure/database-gateway/start.sh @@ -0,0 +1,120 @@ +#!/bin/bash +set -euo pipefail + +# Database Gateway runtime setup: +# 1. wait for the Cloudron postgresql addon (storage for profiles/bookmarks/ +# query results) +# 2. seed /app/data/config.json + /app/data/opa/ on first run +# 3. apply the embedded schema migrations (migrate-up) +# 4. exec the gateway (web UI + LRPC API on 0.0.0.0:8080) + +CONFIG_PATH="/app/data/config.json" +OPA_DIR="/app/data/opa" +COOKIE_SECRET_PATH="/app/data/.cookie_secret" + +# --- 1. wait for PostgreSQL ------------------------------------------------- +DB_HOST=${CLOUDRON_POSTGRESQL_HOST:-127.0.0.1} +DB_PORT=${CLOUDRON_POSTGRESQL_PORT:-5432} +DB_NAME=${CLOUDRON_POSTGRESQL_DATABASE:-dbgw} +DB_USER=${CLOUDRON_POSTGRESQL_USERNAME:-dbgw} +DB_PASSWORD=${CLOUDRON_POSTGRESQL_PASSWORD} + +echo "Waiting for PostgreSQL at ${DB_HOST}:${DB_PORT} ..." +until PGPASSWORD="${DB_PASSWORD}" psql -h "${DB_HOST}" -p "${DB_PORT}" -U "${DB_USER}" -d "${DB_NAME}" -c '\q' >/dev/null 2>&1; do + echo "PostgreSQL is unavailable - sleeping" + sleep 2 +done +echo "PostgreSQL is up" + +# --- 2. seed persistent configuration --------------------------------------- +# Persistent cookie secret (rotating it would invalidate live sessions). +if [[ ! -f "${COOKIE_SECRET_PATH}" ]]; then + umask 077 + openssl rand -hex 32 > "${COOKIE_SECRET_PATH}" + echo "Generated new cookie secret" +fi +COOKIE_SECRET=$(cat "${COOKIE_SECRET_PATH}") + +# Default OPA policy: Cloudron "admins" see every target and may run any op; +# everyone else is denied until an operator extends the policy. Edit with the +# Cloudron file manager; changes apply on restart. +mkdir -p "${OPA_DIR}" +if [[ ! -f "${OPA_DIR}/simple.rego" ]]; then + cat > "${OPA_DIR}/simple.rego" <<'EOF' +package gateway + +# Default Cloudron policy: the admins group is unrestricted; everyone else +# is denied until an operator edits this file (input.subjects contains +# user: and role: principals). +default allow_target := false +default allow_query := false + +allow_target if { + "role:admin" in input.subjects +} + +allow_query if { + "role:admin" in input.subjects +} +EOF + echo "Seeded default OPA policy at ${OPA_DIR}/simple.rego" +fi + +# Seed the app config once (jq handles escaping of secrets into JSON). OIDC +# comes from the Cloudron platform identity provider; targets (the databases +# this gateway may reach) are added by the operator by editing +# ${CONFIG_PATH} with the Cloudron file manager. +if [[ ! -f "${CONFIG_PATH}" ]]; then + jq -n \ + --arg db_host "${DB_HOST}" \ + --arg db_name "${DB_NAME}" \ + --arg db_user "${DB_USER}" \ + --arg db_password "${DB_PASSWORD}" \ + --argjson db_port "${DB_PORT}" \ + --arg issuer "${CLOUDRON_OIDC_ISSUER}" \ + --arg client_id "${CLOUDRON_OIDC_CLIENT_ID}" \ + --arg client_secret "${CLOUDRON_OIDC_CLIENT_SECRET}" \ + --arg origin "${CLOUDRON_APP_ORIGIN}" \ + --arg cookie_secret "${COOKIE_SECRET}" \ + --arg opa_dir "${OPA_DIR}" \ + '{ + targets: [], + users: { + client_id: $client_id, + client_secret: $client_secret, + issuer_url: $issuer, + redirect_url: ($origin + "/auth/callback"), + scopes: ["openid", "profile", "email", "groups"], + role_claim: "groups", + role_mapping: { + admins: "admin", + users: "user" + } + }, + policy: { path: $opa_dir }, + facade: { + port: 8080, + cookie_secret: $cookie_secret, + unsafe_cors_allow_all: false + }, + storage: { + host: $db_host, + port: $db_port, + database: $db_name, + username: $db_user, + password: $db_password, + use_ssl: false, + max_pool_size: 16 + } + }' > "${CONFIG_PATH}" + chmod 600 "${CONFIG_PATH}" + echo "Seeded config at ${CONFIG_PATH}" +fi + +# --- 3. migrations ----------------------------------------------------------- +echo "Applying database migrations ..." +/usr/local/bin/database-gateway -c "${CONFIG_PATH}" migrate-up + +# --- 4. run -------------------------------------------------------------------- +echo "Starting Database Gateway on :8080 ..." +exec /usr/local/bin/database-gateway -c "${CONFIG_PATH}" run diff --git a/README.md b/README.md index d41f8d3..57413cf 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,9 @@ The Cloudron component focuses on packaging upstream free/libre/open application ### 📊 Current Progress - **Total Applications**: ~57 (see [GitUrlList.txt](GitUrlList.txt)) -- **Completed Packages**: 11/~57 (~19%) +- **Completed Packages**: 12/~57 (~21%) - **Packaging Templates**: Created ✅ -- **Packages Committed & Pushed**: 11 ✅ +- **Packages Committed & Pushed**: 12 ✅ - **Build Tickets**: 46 filed (#633-#678, umbrella [#632](https://projects.knownelement.com/issues/632), Redmine project 55); grist-core excluded (packaged upstream) @@ -33,6 +33,7 @@ The Cloudron component focuses on packaging upstream free/libre/open application | 9 | Windmill | Automation | ~2GB | 8000 | localstorage, postgresql | ✅ Committed | | 10 | Easy-Gate | Infrastructure | 3.18GB | 8080 | localstorage (auth proxy) | ✅ Committed | | 11 | Rathole | Infrastructure | 3.51GB | 8000, 2333, 5200-5299 | localstorage (auth proxy) | ✅ Committed | +| 12 | Database Gateway | Infrastructure | 93.7MB | 8080 | localstorage, postgresql | ✅ Committed | ### 📦 Packages in Development @@ -66,7 +67,7 @@ None currently in development. ### ⚡ Productivity Metrics -- **Packages Completed**: 11/~57 (~19%) +- **Packages Completed**: 12/~57 (~21%) - **Average Package Time**: ~30 minutes - **Success Rate**: 100% (all packages built successfully) - **Commits Pushed**: 100% (all packages pushed to remote) @@ -91,7 +92,7 @@ Applications are organized by function rather than programming language: | **Documentation-Tools** | Documentation and diagramming tools | 3 | 1/3 (33%) | | **Financial-Payments** | Payment processing and financial infrastructure | 1 | 0/1 (0%) | | **Financial-Trading** | Trading and financial algorithm platforms | 1 | 0/1 (0%) | -| **Infrastructure** | Infrastructure and networking tools | 6 | 2/6 (33%) | +| **Infrastructure** | Infrastructure and networking tools | 6 | 3/6 (50%) | | **Legal** | Legal and compliance applications | 1 | 0/1 (0%) | | **Low-Code** | Low-code and no-code platforms | 3 | 1/3 (33%) | | **Monitoring** | Monitoring and observability tools | 6 | 1/6 (17%) | @@ -113,7 +114,7 @@ Applications are organized by function rather than programming language: | [DataHub](https://github.com/datahub-project/datahub) | [GitHub](https://github.com/datahub-project/datahub) | Modern data stack for end-to-end data management | Data-Management | | [Docassemble](https://github.com/jhpyle/docassemble) | [GitHub](https://github.com/jhpyle/docassemble) | Open-source expert system for guided interviews | Legal | | [Pimcore](https://github.com/pimcore/pimcore) | [GitHub](https://github.com/pimcore/pimcore) | Open-source digital experience platform | Business-Apps | -| [Database-Gateway](https://github.com/kazhuravlev/database-gateway) | [GitHub](https://github.com/kazhuravlev/database-gateway) | Database gateway and connection management | Infrastructure | +| [Database-Gateway](https://github.com/kazhuravlev/database-gateway) | [GitHub](https://github.com/kazhuravlev/database-gateway) | Database gateway and connection management | Infrastructure | ✅ Packaged | | [Webhook](https://github.com/adnanh/webhook) | [GitHub](https://github.com/adnanh/webhook) | Lightweight webhook receiver | API-Gateway | ✅ Packaged | | [FX](https://github.com/metrue/fx) | [GitHub](https://github.com/metrue/fx) | Function as a Service platform | DevOps-Tools | | [Fonoster](https://github.com/fonoster/fonoster) | [GitHub](https://github.com/fonoster/fonoster) | Open-source CPaaS for communications | Communication | diff --git a/STATUS.md b/STATUS.md index 78eabfc..558f28f 100644 --- a/STATUS.md +++ b/STATUS.md @@ -3,20 +3,20 @@ > **Human read-only. Agents maintain this file automatically after each work > session.** Do not edit by hand — the next agent run will overwrite it. > -> **Last updated:** 2026-09-01 by Crush (GLM-5.2) — Rathole packaged -> (#650, Infrastructure, 11th package); auth gate verdict: no user concept, -> status page behind the Cloudron auth proxy, tunnels token-authenticated. +> **Last updated:** 2026-09-01 by Crush (GLM-5.2) — Database Gateway +> packaged (#639, Infrastructure, 12th package); auth gate verdict: native +> OIDC (preferred) — platform provider wired via CLOUDRON_OIDC_* env. ## Current State: STABLE (packaging phase, ongoing) -Cloudron packaging pipeline is operational. 11 of ~57 upstream applications are +Cloudron packaging pipeline is operational. 12 of ~57 upstream applications are packaged, committed, and pushed. Packaging templates exist for the core patterns. The gardening protocol (this file + AGENTS.md) keeps docs in sync. All remaining apps now carry build tickets (#633-#678) under umbrella [#632](https://projects.knownelement.com/issues/632) in Redmine project 55 — ready for the sequential grind-driver pattern. -## Completed Packages (11) +## Completed Packages (12) | # | Application | Category | Pattern | Port(s) | Addons | |---|-------------|----------|---------|---------|--------| @@ -31,6 +31,7 @@ ready for the sequential grind-driver pattern. | 9 | Windmill | Automation | Official-image wrapper + start.sh | 8000 | localstorage, postgresql | | 10 | Easy-Gate | Infrastructure | Multi-stage (Go) + auth proxy | 8080 | localstorage | | 11 | Rathole | Infrastructure | Pre-compiled binaries + auth proxy | 8000, 2333, 5200-5299 | localstorage | +| 12 | Database Gateway | Infrastructure | Multi-stage (Go, CGO) | 8080 | localstorage, postgresql | Each package lives in `Package-Workspace///` and contains a `Dockerfile`, `CloudronManifest.json`, `README.md`, `CHANGELOG.md`, `logo.png`, @@ -119,7 +120,7 @@ Full write-ups of each pattern + challenges are in [`JOURNAL.md`](JOURNAL.md). | DevOps-Tools | 1 | 0/1 | | | Financial-Payments | 1 | 0/1 | | | Financial-Trading | 1 | 0/1 | | -| Infrastructure | 6 | 2/6 | easy-gate, rathole done | +| Infrastructure | 6 | 3/6 | easy-gate, rathole, database-gateway done | | Legal | 1 | 0/1 | | | Project-Management | 1 | 0/1 | | | Scientific-Computing | 2 | 0/2 | | @@ -133,7 +134,7 @@ Auth capability is a hard gate before packaging (see LDAP acceptable (risk flag), 🔄 = auth-proxy (no users), ❌ = local-only (unacceptable / blocked-on-auth). -### Completed packages (11) +### Completed packages (12) | App | OIDC | LDAP | Verdict | Note | |-----|------|------|---------|------| @@ -148,6 +149,7 @@ LDAP acceptable (risk flag), 🔄 = auth-proxy (no users), ❌ = local-only | Windmill | yes | no | ✅ preferred | **Packaged**; OIDC configured via Admin Settings UI (no env vars) | | Easy-Gate | n/a | n/a | 🔄 proxy | **Packaged** with `httpAuth.type=proxy` (no user concept; IP-subnet groups only) | | Rathole | n/a | n/a | 🔄 proxy | **Packaged** with `httpAuth.type=proxy` on the status page; tunnels secured by mandatory per-service tokens (Noise/TLS optional) | +| Database Gateway | yes | no | ✅ preferred | **Packaged**; native OIDC-only app — platform provider env (`CLOUDRON_OIDC_*`) seeded into config.json; roles from the `groups` claim | ### Candidates researched