diff --git a/CHANGELOG.md b/CHANGELOG.md index dfd20f4..4043933 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ Versioning: CalVer (`YY.N`) — same scheme as the core Furtka repo. ## [Unreleased] +## [26.14-alpha] - 2026-10-23 + +### Added + +- **Vaultwarden** (v1.0.0, image `vaultwarden/server:1.37.4`, multi-arch). + Bitwarden-compatible password manager on port 8222, one Docker volume + (`data`). Install form asks for `SIGNUPS_ALLOWED` (default `true`; turn it + off after creating your accounts). The web vault and clients need HTTPS, so + use it through Furtka Gateway or the box's local HTTPS. `/admin` stays + disabled (no `ADMIN_TOKEN`). Declares `internet.viable` on 8222. +- **Gateway-ready apps:** `it-tools` (1.1.0, port 8080), `uptime-kuma` + (1.1.0, 3001) and `jellyfin` (1.1.0, 8096) declare + `"internet": {"viable": true, "port": …}`, so core 26.21-alpha can expose + them through Furtka Gateway (Apps → ⋯ → Expose to Internet). All three have + their own login or hold no data. Not exposable on purpose: fileshare (SMB), + mosquitto, zigbee2mqtt, and home-assistant (needs `trusted_proxies` first). + +### Changed + +- `scripts/vendor/furtka_manifest.py` synced with core 26.21-alpha (adds the + `internet` block). + ## [26.13-alpha] - 2026-08-23 ### Added @@ -142,7 +164,8 @@ Versioning: CalVer (`YY.N`) — same scheme as the core Furtka repo. the vendored `furtka.manifest.load_manifest` + cross-checks compose volume references). -[Unreleased]: https://forgejo.sourcegate.online/daniel/furtka-apps/compare/26.13-alpha...HEAD +[Unreleased]: https://forgejo.sourcegate.online/daniel/furtka-apps/compare/26.14-alpha...HEAD +[26.14-alpha]: https://forgejo.sourcegate.online/daniel/furtka-apps/releases/tag/26.14-alpha [26.13-alpha]: https://forgejo.sourcegate.online/daniel/furtka-apps/releases/tag/26.13-alpha [26.12-alpha]: https://forgejo.sourcegate.online/daniel/furtka-apps/releases/tag/26.12-alpha [26.11-alpha]: https://forgejo.sourcegate.online/daniel/furtka-apps/releases/tag/26.11-alpha diff --git a/apps/it-tools/manifest.json b/apps/it-tools/manifest.json index 4772eb8..7fafef1 100644 --- a/apps/it-tools/manifest.json +++ b/apps/it-tools/manifest.json @@ -1,11 +1,17 @@ { "name": "it-tools", "display_name": "IT-Tools", - "version": "1.0.0", + "version": "1.1.0", "description": "Collection of small developer and admin utilities in one web UI.", "description_long": "Werkzeugkasten für den Browser: Passwörter generieren, Hashes berechnen, JSON/YAML formatieren, Base64 en- und dekodieren, JWTs lesen, Regex testen, UUIDs oder QR-Codes erzeugen und vieles mehr. Läuft komplett im Browser — es werden keine Daten an externe Dienste gesendet.", "volumes": [], - "ports": [8080], + "ports": [ + 8080 + ], "icon": "icon.svg", - "open_url": "http://{host}:8080/" + "open_url": "http://{host}:8080/", + "internet": { + "viable": true, + "port": 8080 + } } diff --git a/apps/jellyfin/manifest.json b/apps/jellyfin/manifest.json index d47c27b..85de2de 100644 --- a/apps/jellyfin/manifest.json +++ b/apps/jellyfin/manifest.json @@ -1,11 +1,16 @@ { "name": "jellyfin", "display_name": "Jellyfin", - "version": "1.0.0", + "version": "1.1.0", "description": "Media server for movies, shows, and music on the LAN.", "description_long": "Ein eigener Streaming-Server für deine Filme, Serien und Musik. Apps für Smart-TVs, Handy und Web-Browser verfügbar. Admin-Konto wird beim ersten Besuch im Browser angelegt. Medien werden nur gelesen, nicht verändert.", - "volumes": ["config", "cache"], - "ports": [8096], + "volumes": [ + "config", + "cache" + ], + "ports": [ + 8096 + ], "icon": "icon.svg", "open_url": "http://{host}:8096/", "settings": [ @@ -16,5 +21,9 @@ "type": "path", "required": true } - ] + ], + "internet": { + "viable": true, + "port": 8096 + } } diff --git a/apps/uptime-kuma/manifest.json b/apps/uptime-kuma/manifest.json index f0278be..69a159f 100644 --- a/apps/uptime-kuma/manifest.json +++ b/apps/uptime-kuma/manifest.json @@ -1,11 +1,19 @@ { "name": "uptime-kuma", "display_name": "Uptime Kuma", - "version": "1.0.1", + "version": "1.1.0", "description": "Self-hosted status monitor for services, ports, and devices on the LAN.", "description_long": "Überwacht andere Geräte und Dienste im Netzwerk und schickt dir Bescheid, wenn etwas ausfällt. Nach der Installation den Admin-Account direkt im Browser unter http://furtka.local:3001 anlegen, dann Monitore (Ping, Webseite, Port, Docker-Container) hinzufügen.", - "volumes": ["data"], - "ports": [3001], + "volumes": [ + "data" + ], + "ports": [ + 3001 + ], "icon": "icon.svg", - "open_url": "http://{host}:3001/" + "open_url": "http://{host}:3001/", + "internet": { + "viable": true, + "port": 3001 + } } diff --git a/apps/vaultwarden/.env.example b/apps/vaultwarden/.env.example new file mode 100644 index 0000000..5bd749f --- /dev/null +++ b/apps/vaultwarden/.env.example @@ -0,0 +1 @@ +SIGNUPS_ALLOWED=true diff --git a/apps/vaultwarden/docker-compose.yaml b/apps/vaultwarden/docker-compose.yaml new file mode 100644 index 0000000..b008d64 --- /dev/null +++ b/apps/vaultwarden/docker-compose.yaml @@ -0,0 +1,32 @@ +# Furtka Vaultwarden — Bitwarden-compatible password manager. +# +# One Docker-managed volume (data) holds the SQLite vault, attachments and +# the RSA keys. The volume `furtka_vaultwarden_data` is created by the Furtka +# reconciler from the manifest's "volumes" list before compose comes up; +# `external: true` keeps compose from managing its lifecycle. +# +# HTTPS: the Bitwarden web vault and clients need a secure context, so plain +# http://:8222 only shows a warning page. Reach it through Furtka +# Gateway (manifest `internet.viable`) or the box's local HTTPS. +# +# No ADMIN_TOKEN: the /admin panel stays disabled. Account creation is +# controlled by SIGNUPS_ALLOWED from the install form; the user turns it off +# after creating their accounts. +# +# Image pin: 1.37.4 (multi-arch, x86_64 + arm64). Bump deliberately. + +services: + vaultwarden: + image: vaultwarden/server:1.37.4 + restart: unless-stopped + ports: + - "8222:80" + environment: + - TZ=Europe/Berlin + - SIGNUPS_ALLOWED=${SIGNUPS_ALLOWED:-true} + volumes: + - furtka_vaultwarden_data:/data + +volumes: + furtka_vaultwarden_data: + external: true diff --git a/apps/vaultwarden/icon.svg b/apps/vaultwarden/icon.svg new file mode 100644 index 0000000..3d41944 --- /dev/null +++ b/apps/vaultwarden/icon.svg @@ -0,0 +1,6 @@ + diff --git a/apps/vaultwarden/manifest.json b/apps/vaultwarden/manifest.json new file mode 100644 index 0000000..f5046e3 --- /dev/null +++ b/apps/vaultwarden/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "vaultwarden", + "display_name": "Vaultwarden", + "version": "1.0.0", + "description": "Password manager compatible with the Bitwarden apps and browser extensions.", + "description_long": "Dein eigener Passwortmanager, kompatibel mit den offiziellen Bitwarden-Apps für Handy, Browser und PC. Wichtig: Der Web-Tresor und die Apps brauchen HTTPS. Am einfachsten über Furtka Gateway (Apps → ⋯ → Expose to Internet) oder mit eingeschaltetem lokalem HTTPS. Erstes Konto im Browser anlegen, danach in den Einstellungen \"Neue Konten erlauben\" auf false stellen, damit sich niemand Fremdes registriert.", + "volumes": ["data"], + "ports": [8222], + "icon": "icon.svg", + "open_url": "http://{host}:8222/", + "internet": { "viable": true, "port": 8222 }, + "settings": [ + { + "name": "SIGNUPS_ALLOWED", + "label": "Neue Konten erlauben", + "description": "true, solange du dein Konto (und das deiner Familie) anlegst. Danach auf false setzen, besonders wenn Vaultwarden über das Gateway aus dem Internet erreichbar ist.", + "type": "text", + "required": true, + "default": "true" + } + ] +} diff --git a/scripts/vendor/furtka_manifest.py b/scripts/vendor/furtka_manifest.py index 03a4c73..7d0e5d8 100644 --- a/scripts/vendor/furtka_manifest.py +++ b/scripts/vendor/furtka_manifest.py @@ -32,6 +32,21 @@ class Setting: default: str | None +@dataclass(frozen=True) +class Internet: + # Whether this app is safe to expose to the internet through a + # furtka-gateway at all. Only ever a catalog author's self-declaration — + # the gateway independently re-validates the port it's given at publish + # time regardless of what this says (see furtka-gateway's routes.py). + viable: bool + # Which declared `ports` entry is the exposable web UI. Required when + # viable is True — an app declaring several ports (e.g. a DNS service + # exposing both its resolver port and an admin UI port) must say which + # one is actually meant to be reachable from the internet; there is no + # way to infer that from `ports` alone. + port: int | None + + @dataclass(frozen=True) class Requirement: app: str # name of the required app — must resolve in installed/catalog/bundled @@ -62,6 +77,7 @@ class Manifest: # no frontend (CLI-only, background workers) leave this empty. open_url: str = "" requires: tuple[Requirement, ...] = field(default_factory=tuple) + internet: Internet = field(default_factory=lambda: Internet(viable=False, port=None)) def volume_name(self, short: str) -> str: # Namespace volume names so two apps can each declare e.g. "data" @@ -155,6 +171,27 @@ def _parse_requires(raw: object, manifest_path: Path, self_name: str) -> tuple[R return tuple(out) +def _parse_internet(raw: object, manifest_path: Path, ports: list[int]) -> Internet: + if raw is None: + return Internet(viable=False, port=None) + if not isinstance(raw, dict): + raise ManifestError(f"{manifest_path}: internet must be an object") + + viable = bool(raw.get("viable", False)) + port = raw.get("port") + + if not viable: + return Internet(viable=False, port=None) + + if not isinstance(port, int) or isinstance(port, bool): + raise ManifestError(f"{manifest_path}: internet.port must be an integer when viable") + if port not in ports: + raise ManifestError( + f"{manifest_path}: internet.port {port} must be one of the declared ports {ports}" + ) + return Internet(viable=True, port=port) + + def load_manifest(path: Path, expected_name: str | None = None) -> Manifest: """Parse and validate a manifest.json. @@ -175,8 +212,10 @@ def load_manifest(path: Path, expected_name: str | None = None) -> Manifest: raise ManifestError(f"{path}: missing required fields: {', '.join(missing)}") name = raw["name"] - if not isinstance(name, str) or not name: - raise ManifestError(f"{path}: name must be a non-empty string") + if not isinstance(name, str) or not APP_NAME_RE.fullmatch(name): + raise ManifestError( + f"{path}: name must be lowercase letters, digits, '-' or '_' (got {name!r})" + ) if expected_name is not None and name != expected_name: raise ManifestError(f"{path}: name {name!r} must equal {expected_name!r}") @@ -190,6 +229,7 @@ def load_manifest(path: Path, expected_name: str | None = None) -> Manifest: settings = _parse_settings(raw.get("settings"), path) requires = _parse_requires(raw.get("requires"), path, name) + internet = _parse_internet(raw.get("internet"), path, ports) open_url_raw = raw.get("open_url", "") if not isinstance(open_url_raw, str): @@ -207,4 +247,5 @@ def load_manifest(path: Path, expected_name: str | None = None) -> Manifest: settings=settings, open_url=open_url_raw, requires=requires, + internet=internet, )