From 0349239da118a511667d05f7c91fb8782a4848fa Mon Sep 17 00:00:00 2001 From: cyberb Date: Sun, 28 Jun 2026 22:26:25 +0100 Subject: [PATCH 01/12] Package Navidrome as a Syncloud app Navidrome music streaming server (Subsonic/OpenSubsonic compatible), tracking syncloud/platform#741. A Go gateway (backend/) fronts navidrome and bridges Syncloud auth to navidrome's externalized (Remote-User header) auth: - Web UI: OIDC against the platform Authelia, signed session cookie. - Subsonic /rest/*: LDAP bind of the client-supplied credentials against the platform slapd, so mobile clients log in with Syncloud credentials; token-auth clients fall through to navidrome native auth. navidrome listens on a unix socket with ND_EXTAUTH_USERHEADER=Remote-User and ND_EXTAUTH_TRUSTEDSOURCES=@; only the gateway talks to it. Packaging follows the modern reference apps (files/audiobookshelf): meta/snap.yaml, cobra cli hooks, static nginx, store-publisher publish, bookworm+buster matrix, pytest integration tests and a Playwright SSO test. Upstream pinned to v0.62.0. --- .drone.jsonnet | 141 ++++++++++++++++ .gitignore | 10 ++ LICENSE | 25 +++ README.md | 53 ++++++ backend/auth/ldap.go | 98 +++++++++++ backend/auth/oidc.go | 259 +++++++++++++++++++++++++++++ backend/build.sh | 13 ++ backend/cmd/backend/main.go | 107 ++++++++++++ backend/cmd/backend/proxy.go | 92 ++++++++++ backend/cmd/backend/subsonic.go | 44 +++++ backend/config/config.go | 62 +++++++ backend/go.mod | 21 +++ backend/go.sum | 52 ++++++ bin/service.backend.sh | 7 + bin/service.navidrome.sh | 15 ++ bin/service.nginx.sh | 4 + cli/build.sh | 16 ++ cli/cmd/cli/main.go | 68 ++++++++ cli/cmd/configure/main.go | 24 +++ cli/cmd/install/main.go | 25 +++ cli/cmd/post-refresh/main.go | 24 +++ cli/cmd/pre-refresh/main.go | 24 +++ cli/go.mod | 17 ++ cli/go.sum | 33 ++++ cli/installer/installer.go | 229 +++++++++++++++++++++++++ cli/log/logger.go | 21 +++ cli/test.sh | 7 + config/nginx.conf | 53 ++++++ config/oidc.env | 6 + meta/gui/icon.png | Bin 0 -> 11583 bytes meta/snap.yaml | 46 +++++ navidrome/build.sh | 31 ++++ navidrome/test.sh | 8 + nginx/bin/nginx.sh | 5 + nginx/build.sh | 13 ++ nginx/test.sh | 7 + package.sh | 38 +++++ test/__init__.py | 0 test/ci-test.sh | 18 ++ test/conftest.py | 9 + test/deps.sh | 9 + test/pytest.ini | 3 + test/requirements.txt | 5 + test/test.py | 113 +++++++++++++ web/e2e/ci-ui.sh | 18 ++ web/e2e/helpers/auth.ts | 14 ++ web/e2e/helpers/screenshot.ts | 6 + web/e2e/package.json | 13 ++ web/e2e/playwright.config.ts | 22 +++ web/e2e/specs/01-navidrome.spec.ts | 12 ++ web/e2e/tsconfig.json | 11 ++ 51 files changed, 1951 insertions(+) create mode 100644 .drone.jsonnet create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 backend/auth/ldap.go create mode 100644 backend/auth/oidc.go create mode 100755 backend/build.sh create mode 100644 backend/cmd/backend/main.go create mode 100644 backend/cmd/backend/proxy.go create mode 100644 backend/cmd/backend/subsonic.go create mode 100644 backend/config/config.go create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100755 bin/service.backend.sh create mode 100755 bin/service.navidrome.sh create mode 100755 bin/service.nginx.sh create mode 100755 cli/build.sh create mode 100644 cli/cmd/cli/main.go create mode 100644 cli/cmd/configure/main.go create mode 100644 cli/cmd/install/main.go create mode 100644 cli/cmd/post-refresh/main.go create mode 100644 cli/cmd/pre-refresh/main.go create mode 100644 cli/go.mod create mode 100644 cli/go.sum create mode 100644 cli/installer/installer.go create mode 100644 cli/log/logger.go create mode 100755 cli/test.sh create mode 100644 config/nginx.conf create mode 100644 config/oidc.env create mode 100644 meta/gui/icon.png create mode 100644 meta/snap.yaml create mode 100755 navidrome/build.sh create mode 100755 navidrome/test.sh create mode 100755 nginx/bin/nginx.sh create mode 100755 nginx/build.sh create mode 100755 nginx/test.sh create mode 100755 package.sh create mode 100644 test/__init__.py create mode 100755 test/ci-test.sh create mode 100644 test/conftest.py create mode 100755 test/deps.sh create mode 100644 test/pytest.ini create mode 100644 test/requirements.txt create mode 100644 test/test.py create mode 100755 web/e2e/ci-ui.sh create mode 100644 web/e2e/helpers/auth.ts create mode 100644 web/e2e/helpers/screenshot.ts create mode 100644 web/e2e/package.json create mode 100644 web/e2e/playwright.config.ts create mode 100644 web/e2e/specs/01-navidrome.spec.ts create mode 100644 web/e2e/tsconfig.json diff --git a/.drone.jsonnet b/.drone.jsonnet new file mode 100644 index 0000000..73b5d99 --- /dev/null +++ b/.drone.jsonnet @@ -0,0 +1,141 @@ +local name = 'navidrome'; +local version = '0.62.0'; +local go = '1.25'; +local nginx = '1.24.0'; +local python = '3.12-slim-bookworm'; +local platform = '26.06.01'; +local playwright = 'v1.59.1-jammy'; +local store_publisher = 'stable-303'; +local distros = ['bookworm', 'buster']; + +local platform_image(distro, arch) = + 'syncloud/platform-' + distro + '-' + arch + ':' + platform; + +local build(arch, ui) = [{ + kind: 'pipeline', + type: 'docker', + name: arch, + platform: { + os: 'linux', + arch: arch, + }, + steps: [ + { + name: 'nginx', + image: 'nginx:' + nginx, + commands: ['./nginx/build.sh'], + }, + ] + [ + { + name: 'nginx test ' + distro, + image: platform_image(distro, arch), + commands: ['./nginx/test.sh'], + } + for distro in distros + ] + [ + { + name: 'navidrome', + image: 'debian:bookworm-slim', + commands: ['./navidrome/build.sh ' + version], + }, + ] + [ + { + name: 'navidrome test ' + distro, + image: platform_image(distro, arch), + commands: ['./navidrome/test.sh'], + } + for distro in distros + ] + [ + { + name: 'backend', + image: 'golang:' + go, + commands: ['./backend/build.sh'], + }, + { + name: 'cli', + image: 'golang:' + go, + commands: ['./cli/build.sh'], + }, + ] + [ + { + name: 'cli test ' + distro, + image: platform_image(distro, arch), + commands: ['./cli/test.sh'], + } + for distro in distros + ] + [ + { + name: 'package', + image: 'debian:bookworm-slim', + commands: ['./package.sh ' + name + ' $DRONE_BUILD_NUMBER'], + }, + ] + [ + { + name: 'test ' + distro, + image: 'python:' + python, + commands: ['./test/ci-test.sh ' + distro + ' ' + arch], + } + for distro in distros + ] + (if ui then [ + { + name: 'test-ui-' + project, + image: 'mcr.microsoft.com/playwright:' + playwright, + commands: ['./web/e2e/ci-ui.sh ' + project], + } + for project in ['desktop'] + ] else []) + [ + { + name: 'publish', + image: 'syncloud/store-publisher:' + store_publisher, + environment: { + SYNCLOUD_TOKEN: { from_secret: 'SYNCLOUD_TOKEN' }, + }, + command: ['snap', '-c', '${DRONE_BRANCH}'], + when: { + branch: ['master', 'stable'], + event: ['push'], + }, + }, + { + name: 'artifact', + image: 'appleboy/drone-scp:1.6.4', + settings: { + host: { from_secret: 'artifact_host' }, + username: 'artifact', + key: { from_secret: 'artifact_key' }, + timeout: '2m', + command_timeout: '2m', + target: '/home/artifact/repo/' + name + '/${DRONE_BUILD_NUMBER}-' + arch, + source: 'artifact/*', + strip_components: 1, + }, + when: { + status: ['failure', 'success'], + event: ['push'], + }, + }, + ], + trigger: { + event: ['push'], + }, + services: [ + { + name: name + '.' + distro + '.com', + image: platform_image(distro, arch), + privileged: true, + entrypoint: ['/bin/sh', '-c', "mkdir -p /etc/systemd/system/snapd.service.d && printf '[Service]\\nExecStartPost=/bin/sh -c \"/usr/bin/snap set system refresh.hold=2099-01-01T00:00:00Z\"\\n' > /etc/systemd/system/snapd.service.d/disable-refresh.conf && exec /sbin/init"], + volumes: [ + { name: 'dbus', path: '/var/run/dbus' }, + { name: 'dev', path: '/dev' }, + ], + } + for distro in distros + ], + volumes: [ + { name: 'dbus', host: { path: '/var/run/dbus' } }, + { name: 'dev', host: { path: '/dev' } }, + ], +}]; + +build('amd64', true) + +build('arm64', false) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1d4c10 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.idea +build/ +artifact/ +*.snap +version +package.name +node_modules/ +test-results/ +cli/bt-* +backend/bin-test-backend diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cde4c81 --- /dev/null +++ b/LICENSE @@ -0,0 +1,25 @@ +MIT License + +Copyright (c) 2026 Syncloud + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +This repository packages Navidrome (https://github.com/navidrome/navidrome), +which is distributed under the GNU GPLv3. The bundled Navidrome binary remains +under its own license. diff --git a/README.md b/README.md new file mode 100644 index 0000000..71c5d7b --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# Navidrome for Syncloud + +[Navidrome](https://github.com/navidrome/navidrome) music streaming server packaged as a +[Syncloud](https://syncloud.org) app. Subsonic/OpenSubsonic API compatible, so it works with +the existing client ecosystem (Symfonium, Amperfy, play:Sub, DSub, Feishin, …). + +Tracks [syncloud/platform#741](https://github.com/syncloud/platform/issues/741). + +## Architecture + +A small Go gateway (`backend/`) sits in front of Navidrome and bridges Syncloud authentication +to Navidrome's externalized (reverse-proxy header) auth. Navidrome listens on a unix socket with +`ND_EXTAUTH_USERHEADER=Remote-User` and `ND_EXTAUTH_TRUSTEDSOURCES=@`; the gateway is the only +thing that talks to it and injects a trusted `Remote-User` header after authenticating the caller. + +``` +platform nginx ──▶ web.socket ──▶ nginx ──▶ backend.sock ─┬─▶ navidrome.sock + │ (Remote-User: ) + Web UI (/...) → OIDC (Authelia) → session cookie ─┘ + Subsonic (/rest/*) → LDAP bind (platform slapd) of the + client-supplied user+password ────┘ (token-auth clients fall + through to Navidrome native) +``` + +- **Web UI**: OpenID Connect against the platform's Authelia. Seamless single sign-on with the + rest of the Syncloud dashboard. +- **Subsonic / mobile apps**: the gateway validates the username/password the client sends against + the platform LDAP, so users log in with their **Syncloud credentials**. Set the mobile client to + send the password as plaintext / BasicAuth (the platform terminates HTTPS) — the Subsonic *token* + scheme (`md5(password+salt)`) can't be verified against LDAP and falls through to Navidrome's + native auth. + +## Layout + +| Path | What | +|---|---| +| `backend/` | Go auth gateway (OIDC + LDAP-Subsonic + reverse proxy) | +| `cli/` | Cobra install/configure/refresh hooks + `bin/cli` lifecycle commands | +| `navidrome/` | downloads & vendors the upstream Navidrome binary | +| `nginx/` | static nginx build, fronts `web.socket` | +| `config/` | templated `nginx.conf`, `oidc.env` | +| `test/` | pytest integration tests | +| `web/e2e/` | Playwright UI tests | + +## Upstream version + +Pinned in `.drone.jsonnet` (`local version = '...'`). + +## Build + +CI builds via `.drone.jsonnet` on Drone. Locally each step is a script: `./nginx/build.sh`, +`./navidrome/build.sh `, `./backend/build.sh`, `./cli/build.sh`, then +`./package.sh navidrome `. diff --git a/backend/auth/ldap.go b/backend/auth/ldap.go new file mode 100644 index 0000000..509855e --- /dev/null +++ b/backend/auth/ldap.go @@ -0,0 +1,98 @@ +package auth + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "sync" + "time" + + "github.com/go-ldap/ldap/v3" + "go.uber.org/zap" +) + +type LDAP struct { + URL string + Logger *zap.Logger + + cacheTTL time.Duration + mu sync.Mutex + cache map[string]time.Time +} + +func NewLDAP(url string, logger *zap.Logger) *LDAP { + return &LDAP{ + URL: url, + Logger: logger, + cacheTTL: 5 * time.Minute, + cache: map[string]time.Time{}, + } +} + +func (l *LDAP) Authenticate(username, password string) bool { + if !validUsername(username) || password == "" { + return false + } + key := cacheKey(username, password) + if l.cached(key) { + return true + } + + conn, err := ldap.DialURL(l.URL) + if err != nil { + l.Logger.Error("ldap dial", zap.Error(err)) + return false + } + defer conn.Close() + + dn := fmt.Sprintf("cn=%s,ou=users,dc=syncloud,dc=org", username) + if err := conn.Bind(dn, password); err != nil { + l.Logger.Warn("ldap bind failed", zap.String("user", username), zap.Error(err)) + return false + } + + l.store(key) + return true +} + +func (l *LDAP) cached(key string) bool { + l.mu.Lock() + defer l.mu.Unlock() + exp, ok := l.cache[key] + if !ok { + return false + } + if time.Now().After(exp) { + delete(l.cache, key) + return false + } + return true +} + +func (l *LDAP) store(key string) { + l.mu.Lock() + defer l.mu.Unlock() + l.cache[key] = time.Now().Add(l.cacheTTL) +} + +func validUsername(username string) bool { + if username == "" || len(username) > 64 { + return false + } + for _, r := range username { + switch { + case r >= 'a' && r <= 'z': + case r >= 'A' && r <= 'Z': + case r >= '0' && r <= '9': + case r == '.' || r == '_' || r == '-' || r == '@': + default: + return false + } + } + return true +} + +func cacheKey(username, password string) string { + h := sha256.Sum256([]byte(username + "\x00" + password)) + return username + ":" + hex.EncodeToString(h[:]) +} diff --git a/backend/auth/oidc.go b/backend/auth/oidc.go new file mode 100644 index 0000000..92d9d91 --- /dev/null +++ b/backend/auth/oidc.go @@ -0,0 +1,259 @@ +package auth + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "go.uber.org/zap" + "golang.org/x/oauth2" +) + +type OIDC struct { + IssuerURL string + ClientID string + ClientSecret string + RedirectURL string + CookieSecret []byte + Logger *zap.Logger + + provider *oidc.Provider + verifier *oidc.IDTokenVerifier + oauth2Config oauth2.Config +} + +func (o *OIDC) Init(ctx context.Context) error { + provider, err := oidc.NewProvider(ctx, o.IssuerURL) + if err != nil { + return fmt.Errorf("oidc provider discovery: %w", err) + } + o.provider = provider + o.verifier = provider.Verifier(&oidc.Config{ClientID: o.ClientID}) + o.oauth2Config = oauth2.Config{ + ClientID: o.ClientID, + ClientSecret: o.ClientSecret, + RedirectURL: o.RedirectURL, + Endpoint: provider.Endpoint(), + Scopes: []string{oidc.ScopeOpenID, "profile", "email", "groups"}, + } + return nil +} + +const ( + stateCookie = "navidrome_oidc_state" + verifierCookie = "navidrome_oidc_verifier" + sessionCookie = "navidrome_session" + sessionTTL = 12 * time.Hour +) + +func (o *OIDC) Login(w http.ResponseWriter, r *http.Request) { + state, err := randBase64(16) + if err != nil { + http.Error(w, "state", http.StatusInternalServerError) + return + } + verifier, err := randBase64(32) + if err != nil { + http.Error(w, "verifier", http.StatusInternalServerError) + return + } + + setCookie(w, stateCookie, state, 10*time.Minute) + setCookie(w, verifierCookie, verifier, 10*time.Minute) + + authURL := o.oauth2Config.AuthCodeURL( + state, + oauth2.AccessTypeOnline, + oauth2.SetAuthURLParam("code_challenge", pkceChallenge(verifier)), + oauth2.SetAuthURLParam("code_challenge_method", "S256"), + ) + http.Redirect(w, r, authURL, http.StatusFound) +} + +func (o *OIDC) Callback(w http.ResponseWriter, r *http.Request) { + stateCk, err := r.Cookie(stateCookie) + if err != nil { + http.Error(w, "state cookie missing", http.StatusBadRequest) + return + } + if r.URL.Query().Get("state") != stateCk.Value { + http.Error(w, "state mismatch", http.StatusBadRequest) + return + } + verifierCk, err := r.Cookie(verifierCookie) + if err != nil { + http.Error(w, "verifier cookie missing", http.StatusBadRequest) + return + } + + ctx := r.Context() + token, err := o.oauth2Config.Exchange( + ctx, r.URL.Query().Get("code"), + oauth2.SetAuthURLParam("code_verifier", verifierCk.Value), + ) + if err != nil { + o.Logger.Error("oauth2 exchange", zap.Error(err)) + http.Error(w, "exchange failed", http.StatusBadGateway) + return + } + + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + http.Error(w, "id_token missing", http.StatusBadGateway) + return + } + idToken, err := o.verifier.Verify(ctx, rawIDToken) + if err != nil { + o.Logger.Error("id token verify", zap.Error(err)) + http.Error(w, "id token invalid", http.StatusUnauthorized) + return + } + + var claims struct { + Sub string `json:"sub"` + Email string `json:"email"` + PreferredUsername string `json:"preferred_username"` + } + if err := idToken.Claims(&claims); err != nil { + http.Error(w, "claims", http.StatusBadGateway) + return + } + + username := claims.PreferredUsername + if username == "" { + userInfo, err := o.provider.UserInfo(ctx, oauth2.StaticTokenSource(token)) + if err != nil { + o.Logger.Error("userinfo", zap.Error(err)) + http.Error(w, "userinfo failed", http.StatusBadGateway) + return + } + var ui struct { + PreferredUsername string `json:"preferred_username"` + } + if err := userInfo.Claims(&ui); err != nil { + http.Error(w, "userinfo claims", http.StatusBadGateway) + return + } + username = ui.PreferredUsername + } + if username == "" { + username = claims.Email + } + if username == "" { + o.Logger.Error("no username claim in token") + http.Error(w, "no username", http.StatusBadGateway) + return + } + + sess := session{User: username, Exp: time.Now().Add(sessionTTL).Unix()} + cookieVal, err := o.encodeSession(sess) + if err != nil { + http.Error(w, "encode session", http.StatusInternalServerError) + return + } + + clearCookie(w, stateCookie) + clearCookie(w, verifierCookie) + setCookie(w, sessionCookie, cookieVal, sessionTTL) + http.Redirect(w, r, "/", http.StatusFound) +} + +func (o *OIDC) Logout(w http.ResponseWriter, r *http.Request) { + clearCookie(w, sessionCookie) + http.Redirect(w, r, "/", http.StatusFound) +} + +func (o *OIDC) SessionUser(r *http.Request) (string, bool) { + ck, err := r.Cookie(sessionCookie) + if err != nil { + return "", false + } + return o.validSession(ck.Value) +} + +type session struct { + User string `json:"user"` + Exp int64 `json:"exp"` +} + +func (o *OIDC) encodeSession(s session) (string, error) { + body, err := json.Marshal(s) + if err != nil { + return "", err + } + mac := hmac.New(sha256.New, o.CookieSecret) + mac.Write(body) + sig := mac.Sum(nil) + return base64.URLEncoding.EncodeToString(body) + "." + base64.URLEncoding.EncodeToString(sig), nil +} + +func (o *OIDC) validSession(raw string) (string, bool) { + parts := strings.SplitN(raw, ".", 2) + if len(parts) != 2 { + return "", false + } + body, err := base64.URLEncoding.DecodeString(parts[0]) + if err != nil { + return "", false + } + sig, err := base64.URLEncoding.DecodeString(parts[1]) + if err != nil { + return "", false + } + mac := hmac.New(sha256.New, o.CookieSecret) + mac.Write(body) + if !hmac.Equal(sig, mac.Sum(nil)) { + return "", false + } + var s session + if err := json.Unmarshal(body, &s); err != nil { + return "", false + } + if time.Now().Unix() >= s.Exp { + return "", false + } + return s.User, true +} + +func setCookie(w http.ResponseWriter, name, value string, ttl time.Duration) { + http.SetCookie(w, &http.Cookie{ + Name: name, + Value: value, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + MaxAge: int(ttl.Seconds()), + }) +} + +func clearCookie(w http.ResponseWriter, name string) { + http.SetCookie(w, &http.Cookie{ + Name: name, + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: true, + }) +} + +func randBase64(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func pkceChallenge(verifier string) string { + h := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(h[:]) +} diff --git a/backend/build.sh b/backend/build.sh new file mode 100755 index 0000000..bce1757 --- /dev/null +++ b/backend/build.sh @@ -0,0 +1,13 @@ +#!/bin/bash -ex + +DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) +ROOT=$( cd "${DIR}/.." && pwd ) +BUILD_DIR=${ROOT}/build/snap/backend + +mkdir -p ${BUILD_DIR} +cd ${DIR} + +go vet ./... + +export CGO_ENABLED=0 +go build -trimpath -buildvcs=false -ldflags '-s -w' -o ${BUILD_DIR}/backend ./cmd/backend diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go new file mode 100644 index 0000000..1614d4a --- /dev/null +++ b/backend/cmd/backend/main.go @@ -0,0 +1,107 @@ +package main + +import ( + "context" + "fmt" + "net" + "net/http" + "os" + + "github.com/spf13/cobra" + "go.uber.org/zap" + + "backend/auth" + "backend/config" +) + +const ( + app = "navidrome" + dataDir = "/var/snap/" + app + "/current" + backendSock = dataDir + "/backend.sock" + navidromeSock = dataDir + "/navidrome.sock" + secretPath = dataDir + "/.secret" + ldapURL = "ldap://localhost:389" + userHeader = "Remote-User" +) + +type ctxKey int + +const userKey ctxKey = 0 + +func withUser(r *http.Request, user string) *http.Request { + return r.WithContext(context.WithValue(r.Context(), userKey, user)) +} + +func main() { + cmd := &cobra.Command{ + Use: "backend", + Short: "Navidrome Syncloud auth gateway — OIDC web SSO + LDAP Subsonic auth in front of navidrome", + SilenceUsage: true, + RunE: func(_ *cobra.Command, _ []string) error { + logger, err := buildLogger() + if err != nil { + return err + } + return run(logger) + }, + } + if err := cmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(logger *zap.Logger) error { + cfg := &config.Config{DataDir: dataDir} + if err := cfg.Load(); err != nil { + return fmt.Errorf("load config: %w", err) + } + + cookieSecret, err := os.ReadFile(secretPath) + if err != nil { + return fmt.Errorf("read cookie secret: %w", err) + } + + oidc := &auth.OIDC{ + IssuerURL: cfg.AuthBaseURL, + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + RedirectURL: cfg.RedirectURI, + CookieSecret: cookieSecret, + Logger: logger, + } + if err := oidc.Init(context.Background()); err != nil { + return fmt.Errorf("oidc init: %w", err) + } + + ldap := auth.NewLDAP(ldapURL, logger) + proxy := newNavidromeProxy(navidromeSock, logger) + + mux := http.NewServeMux() + mux.HandleFunc("GET /syncloud-oidc/login", oidc.Login) + mux.HandleFunc("GET /syncloud-oidc/callback", oidc.Callback) + mux.HandleFunc("GET /syncloud-oidc/logout", oidc.Logout) + mux.Handle("/rest/", subsonicHandler(ldap, proxy)) + mux.Handle("/", webHandler(oidc, proxy)) + + _ = os.Remove(backendSock) + listener, err := net.Listen("unix", backendSock) + if err != nil { + return fmt.Errorf("listen %s: %w", backendSock, err) + } + if err := os.Chmod(backendSock, 0666); err != nil { + return fmt.Errorf("chmod socket: %w", err) + } + + logger.Info("backend listening", zap.String("socket", backendSock)) + return (&http.Server{Handler: mux}).Serve(listener) +} + +func buildLogger() (*zap.Logger, error) { + c := zap.NewProductionConfig() + c.Encoding = "console" + c.EncoderConfig.TimeKey = "" + c.OutputPaths = []string{"stdout"} + c.ErrorOutputPaths = []string{"stderr"} + return c.Build() +} diff --git a/backend/cmd/backend/proxy.go b/backend/cmd/backend/proxy.go new file mode 100644 index 0000000..3675f93 --- /dev/null +++ b/backend/cmd/backend/proxy.go @@ -0,0 +1,92 @@ +package main + +import ( + "context" + "net" + "net/http" + "net/http/httputil" + "net/url" + + "go.uber.org/zap" + + "backend/auth" +) + +func newNavidromeProxy(socket string, logger *zap.Logger) *httputil.ReverseProxy { + target, _ := url.Parse("http://navidrome") + proxy := httputil.NewSingleHostReverseProxy(target) + proxy.Transport = &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", socket) + }, + } + proxy.FlushInterval = -1 + + orig := proxy.Director + proxy.Director = func(r *http.Request) { + orig(r) + r.Header.Del(userHeader) + if user, ok := r.Context().Value(userKey).(string); ok && user != "" { + r.Header.Set(userHeader, user) + } + } + proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, err error) { + logger.Error("proxy to navidrome failed", zap.Error(err)) + http.Error(w, "navidrome unavailable", http.StatusBadGateway) + } + return proxy +} + +func webHandler(oidc *auth.OIDC, proxy http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, ok := oidc.SessionUser(r) + if !ok { + if isBrowserNavigation(r) { + http.Redirect(w, r, "/syncloud-oidc/login", http.StatusFound) + return + } + http.Error(w, "unauthenticated", http.StatusUnauthorized) + return + } + proxy.ServeHTTP(w, withUser(r, user)) + }) +} + +func isBrowserNavigation(r *http.Request) bool { + if r.Method != http.MethodGet { + return false + } + return wantsHTML(r.Header.Get("Accept")) +} + +func wantsHTML(accept string) bool { + for _, part := range splitComma(accept) { + if part == "text/html" || part == "application/xhtml+xml" { + return true + } + } + return false +} + +func splitComma(s string) []string { + var out []string + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == ',' || s[i] == ';' { + out = append(out, trimSpace(s[start:i])) + start = i + 1 + } + } + out = append(out, trimSpace(s[start:])) + return out +} + +func trimSpace(s string) string { + for len(s) > 0 && (s[0] == ' ' || s[0] == '\t') { + s = s[1:] + } + for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t') { + s = s[:len(s)-1] + } + return s +} diff --git a/backend/cmd/backend/subsonic.go b/backend/cmd/backend/subsonic.go new file mode 100644 index 0000000..4b580df --- /dev/null +++ b/backend/cmd/backend/subsonic.go @@ -0,0 +1,44 @@ +package main + +import ( + "encoding/hex" + "net/http" + "strings" + + "backend/auth" +) + +func subsonicHandler(ldap *auth.LDAP, proxy http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, pass, ok := subsonicCredentials(r) + if ok && ldap.Authenticate(user, pass) { + proxy.ServeHTTP(w, withUser(r, user)) + return + } + proxy.ServeHTTP(w, r) + }) +} + +func subsonicCredentials(r *http.Request) (string, string, bool) { + if user, pass, ok := r.BasicAuth(); ok && pass != "" { + return user, pass, true + } + + q := r.URL.Query() + user := q.Get("u") + if user == "" { + return "", "", false + } + pass := q.Get("p") + if pass == "" { + return "", "", false + } + if decoded, ok := strings.CutPrefix(pass, "enc:"); ok { + b, err := hex.DecodeString(decoded) + if err != nil { + return "", "", false + } + pass = string(b) + } + return user, pass, true +} diff --git a/backend/config/config.go b/backend/config/config.go new file mode 100644 index 0000000..e8222ca --- /dev/null +++ b/backend/config/config.go @@ -0,0 +1,62 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +type Config struct { + DataDir string + + AppUrl string + ClientID string + ClientSecret string + AuthBaseURL string + RedirectURI string +} + +func (c *Config) Load() error { + kv, err := loadKV(filepath.Join(c.DataDir, "config", "oidc.env")) + if err != nil { + return fmt.Errorf("oidc.env: %w", err) + } + c.AppUrl = kv["APP_URL"] + c.ClientID = kv["OIDC_CLIENT_ID"] + c.ClientSecret = kv["OIDC_CLIENT_SECRET"] + c.AuthBaseURL = kv["OIDC_AUTH_BASE_URL"] + c.RedirectURI = kv["OIDC_REDIRECT_URI"] + + for k, v := range map[string]string{ + "OIDC_CLIENT_ID": c.ClientID, + "OIDC_CLIENT_SECRET": c.ClientSecret, + "OIDC_AUTH_BASE_URL": c.AuthBaseURL, + "OIDC_REDIRECT_URI": c.RedirectURI, + } { + if v == "" { + return fmt.Errorf("%s is empty in oidc.env", k) + } + } + return nil +} + +func loadKV(path string) (map[string]string, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + out := map[string]string{} + for _, line := range strings.Split(string(b), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + out[strings.TrimSpace(k)] = strings.Trim(strings.TrimSpace(v), `"`) + } + return out, nil +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..9d9f7f3 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,21 @@ +module backend + +go 1.23 + +require ( + github.com/coreos/go-oidc/v3 v3.11.0 + github.com/go-ldap/ldap/v3 v3.4.4 + github.com/spf13/cobra v1.7.0 + go.uber.org/zap v1.25.0 + golang.org/x/oauth2 v0.23.0 +) + +require ( + github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e // indirect + github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect + github.com/go-jose/go-jose/v4 v4.0.2 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/crypto v0.25.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..e8c787e --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,52 @@ +github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e h1:NeAW1fUYUEWhft7pkxDf6WoUvEZJ/uOKsvtpjLnn8MU= +github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= +github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= +github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI= +github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-asn1-ber/asn1-ber v1.5.4 h1:vXT6d/FNDiELJnLb6hGNa309LMsrCoYFvpwHDF0+Y1A= +github.com/go-asn1-ber/asn1-ber v1.5.4/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk= +github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= +github.com/go-ldap/ldap/v3 v3.4.4 h1:qPjipEpt+qDa6SI/h1fzuGWoRUY+qqQ9sOZq67/PYUs= +github.com/go-ldap/ldap/v3 v3.4.4/go.mod h1:fe1MsuN5eJJ1FeLT/LEBVdWfNWKh459R7aXgXtJC+aI= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= +go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.25.0 h1:4Hvk6GtkucQ790dqmj7l1eEnRdKm3k3ZUrUMS2d5+5c= +go.uber.org/zap v1.25.0/go.mod h1:JIAUzQIH94IC4fOJQm7gMmBJP5k7wQfdcnYdPoEXJYk= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= +golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= +golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/bin/service.backend.sh b/bin/service.backend.sh new file mode 100755 index 0000000..692b43d --- /dev/null +++ b/bin/service.backend.sh @@ -0,0 +1,7 @@ +#!/bin/bash -e + +export SSL_CERT_FILE=/var/snap/platform/current/syncloud.ca.crt + +/bin/rm -f ${SNAP_DATA}/backend.sock + +exec ${SNAP}/backend/backend diff --git a/bin/service.navidrome.sh b/bin/service.navidrome.sh new file mode 100755 index 0000000..a7a3cc7 --- /dev/null +++ b/bin/service.navidrome.sh @@ -0,0 +1,15 @@ +#!/bin/bash -e + +/bin/rm -f ${SNAP_DATA}/navidrome.sock + +export ND_ADDRESS=unix:${SNAP_DATA}/navidrome.sock +export ND_UNIXSOCKETPERM=0660 +export ND_DATAFOLDER=${SNAP_DATA}/data +export ND_CACHEFOLDER=${SNAP_DATA}/cache +export ND_MUSICFOLDER=/data/navidrome +export ND_EXTAUTH_USERHEADER=Remote-User +export ND_EXTAUTH_TRUSTEDSOURCES=@ +export ND_LOGLEVEL=info +export ND_ENABLEINSIGHTSCOLLECTOR=false + +exec ${SNAP}/navidrome/navidrome diff --git a/bin/service.nginx.sh b/bin/service.nginx.sh new file mode 100755 index 0000000..579ee85 --- /dev/null +++ b/bin/service.nginx.sh @@ -0,0 +1,4 @@ +#!/bin/bash -e + +/bin/rm -f ${SNAP_COMMON}/web.socket +exec ${SNAP}/nginx/bin/nginx.sh -c ${SNAP_DATA}/config/nginx.conf -p ${SNAP}/nginx -e stderr diff --git a/cli/build.sh b/cli/build.sh new file mode 100755 index 0000000..ad9f469 --- /dev/null +++ b/cli/build.sh @@ -0,0 +1,16 @@ +#!/bin/bash -ex + +DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) +cd ${DIR} + +BIN_OUT=${DIR}/../build/snap/bin +HOOKS_OUT=${DIR}/../build/snap/meta/hooks +mkdir -p ${BIN_OUT} ${HOOKS_OUT} + +go vet ./... + +CGO_ENABLED=0 go build -buildvcs=false -o ${HOOKS_OUT}/install ./cmd/install +CGO_ENABLED=0 go build -buildvcs=false -o ${HOOKS_OUT}/configure ./cmd/configure +CGO_ENABLED=0 go build -buildvcs=false -o ${HOOKS_OUT}/pre-refresh ./cmd/pre-refresh +CGO_ENABLED=0 go build -buildvcs=false -o ${HOOKS_OUT}/post-refresh ./cmd/post-refresh +CGO_ENABLED=0 go build -buildvcs=false -o ${BIN_OUT}/cli ./cmd/cli diff --git a/cli/cmd/cli/main.go b/cli/cmd/cli/main.go new file mode 100644 index 0000000..47c2c0e --- /dev/null +++ b/cli/cmd/cli/main.go @@ -0,0 +1,68 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "go.uber.org/zap" + "hooks/installer" + "hooks/log" +) + +func main() { + cmd := &cobra.Command{ + Use: "cli", + SilenceUsage: true, + } + + cmd.AddCommand(&cobra.Command{ + Use: "storage-change", + RunE: func(cmd *cobra.Command, args []string) error { + logger := log.Logger(zap.DebugLevel) + logger.Info("storage-change") + return installer.New(logger).StorageChange() + }, + }) + + cmd.AddCommand(&cobra.Command{ + Use: "access-change", + RunE: func(cmd *cobra.Command, args []string) error { + logger := log.Logger(zap.DebugLevel) + logger.Info("access-change") + return installer.New(logger).AccessChange() + }, + }) + + cmd.AddCommand(&cobra.Command{ + Use: "backup-pre-stop", + RunE: func(cmd *cobra.Command, args []string) error { + logger := log.Logger(zap.DebugLevel) + logger.Info("backup-pre-stop") + return installer.New(logger).BackupPreStop() + }, + }) + + cmd.AddCommand(&cobra.Command{ + Use: "restore-pre-start", + RunE: func(cmd *cobra.Command, args []string) error { + logger := log.Logger(zap.DebugLevel) + logger.Info("restore-pre-start") + return installer.New(logger).RestorePreStart() + }, + }) + + cmd.AddCommand(&cobra.Command{ + Use: "restore-post-start", + RunE: func(cmd *cobra.Command, args []string) error { + logger := log.Logger(zap.DebugLevel) + logger.Info("restore-post-start") + return installer.New(logger).RestorePostStart() + }, + }) + + if err := cmd.Execute(); err != nil { + fmt.Print(err) + os.Exit(1) + } +} diff --git a/cli/cmd/configure/main.go b/cli/cmd/configure/main.go new file mode 100644 index 0000000..091c7e8 --- /dev/null +++ b/cli/cmd/configure/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "go.uber.org/zap" + "hooks/installer" + "hooks/log" +) + +func main() { + rootCmd := &cobra.Command{ + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return installer.New(log.Logger(zap.DebugLevel)).Configure() + }, + } + if err := rootCmd.Execute(); err != nil { + fmt.Print(err) + os.Exit(1) + } +} diff --git a/cli/cmd/install/main.go b/cli/cmd/install/main.go new file mode 100644 index 0000000..587cd7a --- /dev/null +++ b/cli/cmd/install/main.go @@ -0,0 +1,25 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "go.uber.org/zap" + "hooks/installer" + "hooks/log" +) + +func main() { + rootCmd := &cobra.Command{ + Use: "install", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return installer.New(log.Logger(zap.DebugLevel)).Install() + }, + } + if err := rootCmd.Execute(); err != nil { + fmt.Print(err) + os.Exit(1) + } +} diff --git a/cli/cmd/post-refresh/main.go b/cli/cmd/post-refresh/main.go new file mode 100644 index 0000000..1be7c8e --- /dev/null +++ b/cli/cmd/post-refresh/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "go.uber.org/zap" + "hooks/installer" + "hooks/log" +) + +func main() { + rootCmd := &cobra.Command{ + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return installer.New(log.Logger(zap.DebugLevel)).PostRefresh() + }, + } + if err := rootCmd.Execute(); err != nil { + fmt.Print(err) + os.Exit(1) + } +} diff --git a/cli/cmd/pre-refresh/main.go b/cli/cmd/pre-refresh/main.go new file mode 100644 index 0000000..d674202 --- /dev/null +++ b/cli/cmd/pre-refresh/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "go.uber.org/zap" + "hooks/installer" + "hooks/log" +) + +func main() { + rootCmd := &cobra.Command{ + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return installer.New(log.Logger(zap.DebugLevel)).PreRefresh() + }, + } + if err := rootCmd.Execute(); err != nil { + fmt.Print(err) + os.Exit(1) + } +} diff --git a/cli/go.mod b/cli/go.mod new file mode 100644 index 0000000..a2efcea --- /dev/null +++ b/cli/go.mod @@ -0,0 +1,17 @@ +module hooks + +go 1.23 + +require ( + github.com/otiai10/copy v1.12.0 + github.com/spf13/cobra v1.7.0 + github.com/syncloud/golib v1.1.15 + go.uber.org/zap v1.25.0 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect +) diff --git a/cli/go.sum b/cli/go.sum new file mode 100644 index 0000000..27e145b --- /dev/null +++ b/cli/go.sum @@ -0,0 +1,33 @@ +github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= +github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/otiai10/copy v1.12.0 h1:cLMgSQnXBs1eehF0Wy/FAGsgDTDmAqFR7rQylBb1nDY= +github.com/otiai10/copy v1.12.0/go.mod h1:rSaLseMUsZFFbsFGc7wCJnnkTAvdc5L6VWxPE4308Ww= +github.com/otiai10/mint v1.5.1 h1:XaPLeE+9vGbuyEHem1JNk3bYc7KKqyI/na0/mLd/Kks= +github.com/otiai10/mint v1.5.1/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/syncloud/golib v1.1.15 h1:NuWw/BOJ+0maoU775HxrlpvXdAWrFvRnn5X2gPhRBxA= +github.com/syncloud/golib v1.1.15/go.mod h1:XmmoqNuegLnl27FbmzLj60dTR/tN7c1X7Qp3uUPRC88= +go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= +go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.25.0 h1:4Hvk6GtkucQ790dqmj7l1eEnRdKm3k3ZUrUMS2d5+5c= +go.uber.org/zap v1.25.0/go.mod h1:JIAUzQIH94IC4fOJQm7gMmBJP5k7wQfdcnYdPoEXJYk= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cli/installer/installer.go b/cli/installer/installer.go new file mode 100644 index 0000000..ee55a7f --- /dev/null +++ b/cli/installer/installer.go @@ -0,0 +1,229 @@ +package installer + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "path" + + cp "github.com/otiai10/copy" + "github.com/syncloud/golib/config" + "github.com/syncloud/golib/linux" + "github.com/syncloud/golib/platform" + "go.uber.org/zap" +) + +type Variables struct { + App string + AppDir string + DataDir string + CommonDir string + StorageDir string + AppDomain string + AppUrl string + AuthUrl string + OIDCClientID string + OIDCClientSecret string + OIDCRedirectURI string + Socket string +} + +const ( + App = "navidrome" + AppDir = "/snap/navidrome/current" + DataDir = "/var/snap/navidrome/current" + CommonDir = "/var/snap/navidrome/common" + OIDCCallback = "/syncloud-oidc/callback" +) + +type Installer struct { + newVersionFile string + currentVersionFile string + platformClient *platform.Client + installFile string + secretFile string + logger *zap.Logger +} + +func New(logger *zap.Logger) *Installer { + return &Installer{ + newVersionFile: path.Join(AppDir, "version"), + currentVersionFile: path.Join(DataDir, "version"), + platformClient: platform.New(), + installFile: path.Join(CommonDir, "installed"), + secretFile: path.Join(DataDir, ".secret"), + logger: logger, + } +} + +func (i *Installer) Install() error { + return i.UpdateConfigs() +} + +func (i *Installer) Configure() error { + if i.IsInstalled() { + return i.Upgrade() + } + return i.Initialize() +} + +func (i *Installer) IsInstalled() bool { + _, err := os.Stat(i.installFile) + return err == nil +} + +func (i *Installer) Initialize() error { + if err := i.StorageChange(); err != nil { + return err + } + if err := os.WriteFile(i.installFile, []byte("installed"), 0644); err != nil { + return err + } + return i.UpdateVersion() +} + +func (i *Installer) Upgrade() error { + if err := i.StorageChange(); err != nil { + return err + } + return i.UpdateVersion() +} + +func (i *Installer) PreRefresh() error { + return nil +} + +func (i *Installer) PostRefresh() error { + if err := i.UpdateConfigs(); err != nil { + return err + } + if err := i.ClearVersion(); err != nil { + return err + } + return i.FixPermissions() +} + +func (i *Installer) AccessChange() error { + return i.UpdateConfigs() +} + +func (i *Installer) StorageChange() error { + storageDir, err := i.platformClient.InitStorage(App, App) + if err != nil { + return err + } + return linux.Chown(storageDir, App) +} + +func (i *Installer) ClearVersion() error { + return os.RemoveAll(i.currentVersionFile) +} + +func (i *Installer) UpdateVersion() error { + return cp.Copy(i.newVersionFile, i.currentVersionFile) +} + +func (i *Installer) UpdateConfigs() error { + if err := linux.CreateUser(App); err != nil { + return err + } + if err := i.StorageChange(); err != nil { + return err + } + if err := linux.CreateMissingDirs( + path.Join(DataDir, "config"), + path.Join(DataDir, "nginx"), + path.Join(DataDir, "data"), + path.Join(DataDir, "cache"), + ); err != nil { + return err + } + + storageDir, err := i.platformClient.InitStorage(App, App) + if err != nil { + return err + } + if err := i.GenerateConfig(storageDir); err != nil { + return fmt.Errorf("generate config: %w", err) + } + + return i.FixPermissions() +} + +func (i *Installer) GenerateConfig(storageDir string) error { + oidcSecret, err := i.platformClient.RegisterOIDCClient(App, OIDCCallback, true, "client_secret_basic") + if err != nil { + return fmt.Errorf("register oidc client: %w", err) + } + authUrl, err := i.platformClient.GetAppUrl("auth") + if err != nil { + return err + } + appUrl, err := i.platformClient.GetAppUrl(App) + if err != nil { + return err + } + appDomain, err := i.platformClient.GetAppDomainName(App) + if err != nil { + return err + } + if _, err := i.cookieSecret(); err != nil { + return err + } + + variables := Variables{ + App: App, + AppDir: AppDir, + DataDir: DataDir, + CommonDir: CommonDir, + StorageDir: storageDir, + AppDomain: appDomain, + AppUrl: appUrl, + AuthUrl: authUrl, + OIDCClientID: App, + OIDCClientSecret: oidcSecret, + OIDCRedirectURI: trimRightSlash(appUrl) + OIDCCallback, + Socket: path.Join(DataDir, "backend.sock"), + } + + return config.Generate( + path.Join(AppDir, "config"), + path.Join(DataDir, "config"), + variables, + ) +} + +func (i *Installer) cookieSecret() (string, error) { + existing, err := os.ReadFile(i.secretFile) + if err == nil && len(existing) > 0 { + return string(existing), nil + } + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + secret := hex.EncodeToString(buf) + if err := os.WriteFile(i.secretFile, []byte(secret), 0640); err != nil { + return "", err + } + return secret, nil +} + +func (i *Installer) FixPermissions() error { + if err := linux.Chown(DataDir, App); err != nil { + return err + } + return linux.Chown(CommonDir, App) +} + +func (i *Installer) BackupPreStop() error { return i.PreRefresh() } +func (i *Installer) RestorePreStart() error { return i.PostRefresh() } +func (i *Installer) RestorePostStart() error { return i.Configure() } + +func trimRightSlash(s string) string { + for len(s) > 0 && s[len(s)-1] == '/' { + s = s[:len(s)-1] + } + return s +} diff --git a/cli/log/logger.go b/cli/log/logger.go new file mode 100644 index 0000000..41f61c6 --- /dev/null +++ b/cli/log/logger.go @@ -0,0 +1,21 @@ +package log + +import ( + "fmt" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +func Logger(level zapcore.Level) *zap.Logger { + logConfig := zap.NewProductionConfig() + logConfig.Encoding = "console" + logConfig.EncoderConfig.TimeKey = "" + logConfig.EncoderConfig.ConsoleSeparator = " " + logConfig.Level = zap.NewAtomicLevelAt(level) + logger, err := logConfig.Build() + if err != nil { + panic(fmt.Sprintf("can't initialize zap logger: %v", err)) + } + return logger +} diff --git a/cli/test.sh b/cli/test.sh new file mode 100755 index 0000000..902bab8 --- /dev/null +++ b/cli/test.sh @@ -0,0 +1,7 @@ +#!/bin/sh -ex + +DIR=$( cd "$( dirname "$0" )" && pwd ) +cd ${DIR} + +BUILD_DIR=${DIR}/../build/snap +${BUILD_DIR}/bin/cli --help diff --git a/config/nginx.conf b/config/nginx.conf new file mode 100644 index 0000000..cb12def --- /dev/null +++ b/config/nginx.conf @@ -0,0 +1,53 @@ +pid {{ .DataDir }}/nginx.pid; +daemon off; +worker_processes auto; + +error_log syslog:server=unix:/dev/log warn; + +events { + worker_connections 1024; +} + +http { + access_log syslog:server=unix:/dev/log; + include {{ .AppDir }}/nginx/etc/nginx/mime.types; + + client_body_temp_path {{ .DataDir }}/nginx/client_body_temp; + proxy_temp_path {{ .DataDir }}/nginx/proxy_temp; + fastcgi_temp_path {{ .DataDir }}/nginx/fastcgi_temp; + uwsgi_temp_path {{ .DataDir }}/nginx/uwsgi_temp; + scgi_temp_path {{ .DataDir }}/nginx/scgi_temp; + + client_max_body_size 0; + proxy_request_buffering off; + + map $http_x_forwarded_proto $forwarded_proto { + default $http_x_forwarded_proto; + '' https; + } + + upstream backend { + server unix:{{ .Socket }}; + } + + server { + listen unix:{{ .CommonDir }}/web.socket; + + set_real_ip_from unix:; + server_name localhost; + + location / { + proxy_pass http://backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $forwarded_proto; + proxy_set_header X-Forwarded-Host $host; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_redirect off; + proxy_buffering off; + } + } +} diff --git a/config/oidc.env b/config/oidc.env new file mode 100644 index 0000000..9aaf431 --- /dev/null +++ b/config/oidc.env @@ -0,0 +1,6 @@ +APP_DOMAIN={{ .AppDomain }} +APP_URL={{ .AppUrl }} +OIDC_CLIENT_ID={{ .OIDCClientID }} +OIDC_CLIENT_SECRET={{ .OIDCClientSecret }} +OIDC_AUTH_BASE_URL={{ .AuthUrl }} +OIDC_REDIRECT_URI={{ .OIDCRedirectURI }} diff --git a/meta/gui/icon.png b/meta/gui/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a136a6a94c75ea6b8477f80f0cf34642dfc3902d GIT binary patch literal 11583 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 /dev/null +echo "navidrome binary ok" diff --git a/nginx/bin/nginx.sh b/nginx/bin/nginx.sh new file mode 100755 index 0000000..2aeb1c7 --- /dev/null +++ b/nginx/bin/nginx.sh @@ -0,0 +1,5 @@ +#!/bin/bash -e +DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && cd .. && pwd ) +LIBS=$(echo ${DIR}/lib/*-linux-gnu*) +LIBS=$LIBS:$(echo ${DIR}/usr/lib/*-linux-gnu*) +${DIR}/lib/*-linux*/ld-*.so --library-path $LIBS ${DIR}/usr/sbin/nginx "$@" diff --git a/nginx/build.sh b/nginx/build.sh new file mode 100755 index 0000000..42c103b --- /dev/null +++ b/nginx/build.sh @@ -0,0 +1,13 @@ +#!/bin/sh -ex + +DIR=$( cd "$( dirname "$0" )" && pwd ) +cd ${DIR} + +BUILD_DIR=${DIR}/../build/snap/nginx +mkdir -p ${BUILD_DIR} +cp -r /etc ${BUILD_DIR} +cp -r /opt ${BUILD_DIR} +cp -r /usr ${BUILD_DIR} +cp -r /bin ${BUILD_DIR} +cp -r /lib ${BUILD_DIR} +cp -r ${DIR}/bin/* ${BUILD_DIR}/bin diff --git a/nginx/test.sh b/nginx/test.sh new file mode 100755 index 0000000..dccd169 --- /dev/null +++ b/nginx/test.sh @@ -0,0 +1,7 @@ +#!/bin/sh -ex + +DIR=$( cd "$( dirname "$0" )" && pwd ) +cd ${DIR} + +BUILD_DIR=${DIR}/../build/snap/nginx +${BUILD_DIR}/bin/nginx.sh -version diff --git a/package.sh b/package.sh new file mode 100755 index 0000000..ad2e003 --- /dev/null +++ b/package.sh @@ -0,0 +1,38 @@ +#!/bin/bash -ex + +DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) +cd ${DIR} + +if [[ -z "$2" ]]; then + echo "usage $0 app version" + exit 1 +fi + +NAME=$1 +VERSION=$2 +echo $VERSION > ${DIR}/version +ARCH=$(dpkg --print-architecture) + +SNAP_DIR=${DIR}/build/snap + +apt update +apt -y install squashfs-tools + +cp -r ${DIR}/bin ${SNAP_DIR} +cp -r ${DIR}/config ${SNAP_DIR} +mkdir -p ${SNAP_DIR}/meta +cp ${DIR}/meta/snap.yaml ${SNAP_DIR}/meta/snap.yaml +cp -r ${DIR}/meta/gui ${SNAP_DIR}/meta/gui + +echo "version: $VERSION" >> ${SNAP_DIR}/meta/snap.yaml +echo "architectures:" >> ${SNAP_DIR}/meta/snap.yaml +echo "- ${ARCH}" >> ${SNAP_DIR}/meta/snap.yaml +echo $VERSION > ${SNAP_DIR}/version + +du -d2 -h $SNAP_DIR | sort -h | tail -50 + +PACKAGE=${NAME}_${VERSION}_${ARCH}.snap +echo ${PACKAGE} > ${DIR}/package.name +mksquashfs ${SNAP_DIR} ${DIR}/${PACKAGE} -noappend -comp xz -no-xattrs -all-root +mkdir ${DIR}/artifact +cp ${DIR}/${PACKAGE} ${DIR}/artifact diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/ci-test.sh b/test/ci-test.sh new file mode 100755 index 0000000..6045031 --- /dev/null +++ b/test/ci-test.sh @@ -0,0 +1,18 @@ +#!/bin/bash -ex + +DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && cd .. && pwd ) + +DISTRO=$1 +ARCH=$2 +NAME=navidrome +DOMAIN="${DISTRO}.com" +APP_DOMAIN="${NAME}.${DOMAIN}" + +getent hosts $APP_DOMAIN | sed "s/$APP_DOMAIN/auth.$DOMAIN/g" | tee -a /etc/hosts +cat /etc/hosts + +APP_ARCHIVE_PATH=$(realpath $(cat ${DIR}/package.name)) + +cd ${DIR}/test +./deps.sh +py.test -x -s test.py --distro=$DISTRO --app-archive-path=$APP_ARCHIVE_PATH --app=$NAME --arch=$ARCH diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..406a2f2 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,9 @@ +from os.path import dirname, join +from syncloudlib.integration.conftest import * + +DIR = dirname(__file__) + + +@pytest.fixture(scope="session") +def project_dir(): + return join(DIR, '..') diff --git a/test/deps.sh b/test/deps.sh new file mode 100755 index 0000000..d9175f2 --- /dev/null +++ b/test/deps.sh @@ -0,0 +1,9 @@ +#!/bin/bash -e +DIR=$( cd "$( dirname "$0" )" && pwd ) + +while ! apt-get update; do + sleep 1 + echo "retry" +done +apt-get install -y sshpass openssh-client wget +pip install -r requirements.txt diff --git a/test/pytest.ini b/test/pytest.ini new file mode 100644 index 0000000..dbcb406 --- /dev/null +++ b/test/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +log_cli = True +log_level=INFO diff --git a/test/requirements.txt b/test/requirements.txt new file mode 100644 index 0000000..f5cef53 --- /dev/null +++ b/test/requirements.txt @@ -0,0 +1,5 @@ +pytest==8.4.1 +requests==2.32.3 +syncloud-lib==365 +retry==0.9.2 +pytest-retry==1.6.3 diff --git a/test/test.py b/test/test.py new file mode 100644 index 0000000..e1ad1f5 --- /dev/null +++ b/test/test.py @@ -0,0 +1,113 @@ +import time +from subprocess import check_output + +import pytest +import requests +from requests.packages.urllib3.exceptions import InsecureRequestWarning +from syncloudlib.integration.hosts import add_host_alias +from syncloudlib.integration.installer import local_install + +TMP_DIR = '/tmp/syncloud' +APP = 'navidrome' + +requests.packages.urllib3.disable_warnings(InsecureRequestWarning) + + +@pytest.fixture(scope="session") +def module_setup(request, device, artifact_dir): + def module_teardown(): + device.run_ssh('ls -la /var/snap/{0}/current > {1}/var.current.ls.log'.format(APP, TMP_DIR), throw=False) + device.run_ssh('cat /var/snap/{0}/current/config/nginx.conf > {1}/nginx.conf.log'.format(APP, TMP_DIR), throw=False) + device.run_ssh('cat /var/snap/{0}/current/config/oidc.env > {1}/oidc.env.log'.format(APP, TMP_DIR), throw=False) + device.run_ssh('journalctl -u snap.{0}.navidrome --no-pager | tail -2000 > {1}/navidrome.log'.format(APP, TMP_DIR), throw=False) + device.run_ssh('journalctl -u snap.{0}.backend --no-pager | tail -2000 > {1}/backend.log'.format(APP, TMP_DIR), throw=False) + device.run_ssh('journalctl -u snap.{0}.nginx --no-pager | tail -2000 > {1}/nginx.log'.format(APP, TMP_DIR), throw=False) + device.run_ssh('journalctl --no-pager | tail -3000 > {0}/journalctl.log'.format(TMP_DIR), throw=False) + device.scp_from_device('{0}/*'.format(TMP_DIR), artifact_dir) + check_output('chmod -R a+r {0}'.format(artifact_dir), shell=True) + + request.addfinalizer(module_teardown) + + +def subsonic_ping_ok(app_domain, user, password): + session = requests.session() + url = "https://{0}/rest/ping".format(app_domain) + params = {'u': user, 'p': password, 'v': '1.16.1', 'c': 'syncloud-test', 'f': 'json'} + last = None + for _ in range(60): + try: + r = session.get(url, params=params, verify=False, timeout=10) + last = r.text + if r.status_code == 200: + resp = r.json().get('subsonic-response', {}) + if resp.get('status') == 'ok': + return True + except Exception as e: + last = str(e) + time.sleep(2) + return False, last + + +def test_start(module_setup, device, device_host, app, domain): + add_host_alias(app, device_host, domain) + device.run_ssh('date', retries=100) + device.run_ssh('mkdir {0}'.format(TMP_DIR)) + + +@pytest.mark.flaky(retries=50, delay=10) +def test_activate_device(device): + device.run_ssh('rm -f /var/snap/platform/current/syncloud.crt', throw=False) + response = device.activate_custom() + assert response.status_code == 200, response.text + + +def test_install(app_archive_path, device_host, device_password): + local_install(device_host, device_password, app_archive_path) + + +def test_sockets(device): + device.run_ssh('test -S /var/snap/navidrome/current/navidrome.sock', retries=30) + device.run_ssh('test -S /var/snap/navidrome/current/backend.sock', retries=30) + + +def test_web_redirects_to_sso(app_domain): + session = requests.session() + last = None + for _ in range(60): + r = session.get("https://{0}/".format(app_domain), verify=False, + allow_redirects=False, headers={'Accept': 'text/html'}, timeout=10) + last = r.status_code + if r.status_code in (302, 303): + location = r.headers.get('Location', '') + assert '/syncloud-oidc/login' in location or 'auth.' in location, location + return + time.sleep(2) + assert False, "expected redirect to SSO, last status {0}".format(last) + + +def test_subsonic_login_via_ldap(app_domain, device_user, device_password): + result = subsonic_ping_ok(app_domain, device_user, device_password) + assert result is True, "subsonic ping with syncloud credentials failed: {0}".format(result) + + +def test_subsonic_rejects_wrong_password(app_domain, device_user): + session = requests.session() + url = "https://{0}/rest/ping".format(app_domain) + params = {'u': device_user, 'p': 'definitely-wrong', 'v': '1.16.1', 'c': 'syncloud-test', 'f': 'json'} + r = session.get(url, params=params, verify=False, timeout=10) + assert r.status_code == 200, r.text + assert r.json().get('subsonic-response', {}).get('status') == 'failed', r.text + + +def test_remove(device, app): + response = device.app_remove(app) + assert response.status_code == 200, response.text + + +def test_reinstall(app_archive_path, device_host, device_password): + local_install(device_host, device_password, app_archive_path) + + +def test_subsonic_after_reinstall(app_domain, device_user, device_password): + result = subsonic_ping_ok(app_domain, device_user, device_password) + assert result is True, "subsonic ping after reinstall failed: {0}".format(result) diff --git a/web/e2e/ci-ui.sh b/web/e2e/ci-ui.sh new file mode 100755 index 0000000..25642ff --- /dev/null +++ b/web/e2e/ci-ui.sh @@ -0,0 +1,18 @@ +#!/bin/bash -ex + +DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) + +PROJECT="${1:-desktop}" +NAME=navidrome +export PLAYWRIGHT_DOMAIN="${PLAYWRIGHT_DOMAIN:-bookworm.com}" +export PLAYWRIGHT_USER="${PLAYWRIGHT_USER:-user}" +export PLAYWRIGHT_PASSWORD="${PLAYWRIGHT_PASSWORD:-Password1}" + +DOMAIN="$PLAYWRIGHT_DOMAIN" +APP_DOMAIN="${NAME}.${DOMAIN}" +getent hosts $APP_DOMAIN | sed "s/$APP_DOMAIN/auth.$DOMAIN/g" | tee -a /etc/hosts +cat /etc/hosts + +cd ${DIR} +npm install --no-audit --no-fund +npx playwright test --project="${PROJECT}" diff --git a/web/e2e/helpers/auth.ts b/web/e2e/helpers/auth.ts new file mode 100644 index 0000000..1acab9d --- /dev/null +++ b/web/e2e/helpers/auth.ts @@ -0,0 +1,14 @@ +import { Page } from '@playwright/test' + +export async function loginViaAuthelia (page: Page, baseURL: string, username: string, password: string) { + const appHost = new URL(baseURL).host + + await page.goto(baseURL) + await page.waitForURL((url) => url.host.startsWith('auth.')) + + await page.locator('input#username-textfield').fill(username) + await page.locator('input#password-textfield').fill(password) + await page.locator('button#sign-in-button').click() + + await page.waitForURL((url) => url.host === appHost) +} diff --git a/web/e2e/helpers/screenshot.ts b/web/e2e/helpers/screenshot.ts new file mode 100644 index 0000000..a11d6a9 --- /dev/null +++ b/web/e2e/helpers/screenshot.ts @@ -0,0 +1,6 @@ +import { Page, TestInfo } from '@playwright/test' + +export async function shoot (page: Page, info: TestInfo, name: string) { + await page.waitForTimeout(500) + await page.screenshot({ path: info.outputPath(`${name}.png`), fullPage: false }) +} diff --git a/web/e2e/package.json b/web/e2e/package.json new file mode 100644 index 0000000..4a305f2 --- /dev/null +++ b/web/e2e/package.json @@ -0,0 +1,13 @@ +{ + "name": "navidrome-e2e", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.59.1", + "typescript": "^5.4.0" + } +} diff --git a/web/e2e/playwright.config.ts b/web/e2e/playwright.config.ts new file mode 100644 index 0000000..caa3f51 --- /dev/null +++ b/web/e2e/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, devices } from '@playwright/test' + +const baseURL = `https://navidrome.${process.env.PLAYWRIGHT_DOMAIN}` + +export default defineConfig({ + testDir: './specs', + timeout: 120_000, + expect: { timeout: 20_000 }, + workers: 1, + retries: process.env.CI ? 1 : 0, + reporter: [['list']], + use: { + baseURL, + ignoreHTTPSErrors: true, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'on' + }, + projects: [ + { name: 'desktop', use: { ...devices['Desktop Chrome'], baseURL, ignoreHTTPSErrors: true } } + ] +}) diff --git a/web/e2e/specs/01-navidrome.spec.ts b/web/e2e/specs/01-navidrome.spec.ts new file mode 100644 index 0000000..123c926 --- /dev/null +++ b/web/e2e/specs/01-navidrome.spec.ts @@ -0,0 +1,12 @@ +import { test, expect } from '@playwright/test' +import { shoot } from '../helpers/screenshot' +import { loginViaAuthelia } from '../helpers/auth' + +const username = process.env.PLAYWRIGHT_USER! +const password = process.env.PLAYWRIGHT_PASSWORD! + +test('logs in via Syncloud SSO and lands in Navidrome', async ({ page, baseURL }, info) => { + await loginViaAuthelia(page, baseURL!, username, password) + await expect(page).toHaveTitle(/navidrome/i, { timeout: 45_000 }) + await shoot(page, info, 'navidrome-home') +}) diff --git a/web/e2e/tsconfig.json b/web/e2e/tsconfig.json new file mode 100644 index 0000000..5ca48bf --- /dev/null +++ b/web/e2e/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["**/*.ts"] +} From 83c3b5477dbbcc9743ae405e703df51b23d64a95 Mon Sep 17 00:00:00 2001 From: cyberb Date: Sun, 28 Jun 2026 22:37:49 +0100 Subject: [PATCH 02/12] ci: trigger build From eeb37dca47562202f6ef497134f3522e5326a5a5 Mon Sep 17 00:00:00 2001 From: cyberb Date: Sun, 28 Jun 2026 22:41:19 +0100 Subject: [PATCH 03/12] ci: allow custom event to trigger builds (manual/API runs while inbound webhooks are down) --- .drone.jsonnet | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.drone.jsonnet b/.drone.jsonnet index 73b5d99..adcd543 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -116,7 +116,7 @@ local build(arch, ui) = [{ }, ], trigger: { - event: ['push'], + event: ['push', 'custom'], }, services: [ { From cbb3aa40a470996fde8e92b1b124aaacd16814db Mon Sep 17 00:00:00 2001 From: cyberb Date: Sun, 28 Jun 2026 22:52:30 +0100 Subject: [PATCH 04/12] test: drop requests pin conflicting with syncloud-lib (requests==2.30.0) --- test/requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/test/requirements.txt b/test/requirements.txt index f5cef53..74ddc47 100644 --- a/test/requirements.txt +++ b/test/requirements.txt @@ -1,5 +1,4 @@ pytest==8.4.1 -requests==2.32.3 syncloud-lib==365 retry==0.9.2 pytest-retry==1.6.3 From f2a7ccb042a61ae6b5b04496d3e5ac44c49f0a09 Mon Sep 17 00:00:00 2001 From: cyberb Date: Sun, 28 Jun 2026 23:04:19 +0100 Subject: [PATCH 05/12] test: add selenium (syncloudlib conftest imports it) --- test/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/test/requirements.txt b/test/requirements.txt index 74ddc47..7ea651d 100644 --- a/test/requirements.txt +++ b/test/requirements.txt @@ -1,4 +1,5 @@ pytest==8.4.1 +selenium==4.21.0 syncloud-lib==365 retry==0.9.2 pytest-retry==1.6.3 From 22fd3a1f02493bba91aecb8362b408f77aac510e Mon Sep 17 00:00:00 2001 From: cyberb Date: Sun, 28 Jun 2026 23:14:04 +0100 Subject: [PATCH 06/12] test: dump backend/navidrome journals + direct socket probes on teardown for diagnosis --- test/test.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/test.py b/test/test.py index e1ad1f5..53d69c2 100644 --- a/test/test.py +++ b/test/test.py @@ -13,9 +13,28 @@ requests.packages.urllib3.disable_warnings(InsecureRequestWarning) +def _diag(device): + print("\n========== BACKEND JOURNAL ==========") + print(device.run_ssh('journalctl -u snap.navidrome.backend --no-pager | tail -120', throw=False)) + print("\n========== NAVIDROME JOURNAL ==========") + print(device.run_ssh('journalctl -u snap.navidrome.navidrome --no-pager | tail -120', throw=False)) + print("\n========== DIRECT navidrome.sock + Remote-User ==========") + print(device.run_ssh( + 'curl -s -m 10 -H "Remote-User: user" --unix-socket /var/snap/navidrome/current/navidrome.sock ' + '"http://localhost/rest/ping?v=1.16.1&c=diag&f=json"', throw=False)) + print("\n========== DIRECT navidrome.sock native u/p ==========") + print(device.run_ssh( + 'curl -s -m 10 --unix-socket /var/snap/navidrome/current/navidrome.sock ' + '"http://localhost/rest/ping?u=user&p=Password1&v=1.16.1&c=diag&f=json"', throw=False)) + print("\n========== LDAP bind probe (ldapwhoami) ==========") + print(device.run_ssh( + 'ldapwhoami -x -H ldap://localhost:389 -D "cn=user,ou=users,dc=syncloud,dc=org" -w Password1 2>&1 || echo "ldapwhoami failed/absent"', throw=False)) + + @pytest.fixture(scope="session") def module_setup(request, device, artifact_dir): def module_teardown(): + _diag(device) device.run_ssh('ls -la /var/snap/{0}/current > {1}/var.current.ls.log'.format(APP, TMP_DIR), throw=False) device.run_ssh('cat /var/snap/{0}/current/config/nginx.conf > {1}/nginx.conf.log'.format(APP, TMP_DIR), throw=False) device.run_ssh('cat /var/snap/{0}/current/config/oidc.env > {1}/oidc.env.log'.format(APP, TMP_DIR), throw=False) From a3a17632a48b8b7be23e5592cabe16b082d9d7c8 Mon Sep 17 00:00:00 2001 From: cyberb Date: Sun, 28 Jun 2026 23:23:46 +0100 Subject: [PATCH 07/12] backend: provision navidrome user via index GET before Subsonic proxy Navidrome auto-creates reverse-proxy users only on the web index (serveIndex -> handleLoginFromHeaders), not on the Subsonic endpoint, which returns 'data not found' for unknown users. After a successful LDAP bind, GET /app/ with the Remote-User header once per user to provision them, then proxy /rest. --- backend/cmd/backend/ensure.go | 64 +++++++++++++++++++++++++++++++++ backend/cmd/backend/main.go | 3 +- backend/cmd/backend/subsonic.go | 3 +- 3 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 backend/cmd/backend/ensure.go diff --git a/backend/cmd/backend/ensure.go b/backend/cmd/backend/ensure.go new file mode 100644 index 0000000..0edc10b --- /dev/null +++ b/backend/cmd/backend/ensure.go @@ -0,0 +1,64 @@ +package main + +import ( + "context" + "io" + "net" + "net/http" + "sync" + "time" + + "go.uber.org/zap" +) + +type userEnsurer struct { + client *http.Client + logger *zap.Logger + mu sync.Mutex + seen map[string]bool +} + +func newUserEnsurer(socket string, logger *zap.Logger) *userEnsurer { + return &userEnsurer{ + client: &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", socket) + }, + }, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + logger: logger, + seen: map[string]bool{}, + } +} + +func (e *userEnsurer) ensure(username string) { + e.mu.Lock() + done := e.seen[username] + e.mu.Unlock() + if done { + return + } + + req, err := http.NewRequest(http.MethodGet, "http://navidrome/app/", nil) + if err != nil { + return + } + req.Header.Set(userHeader, username) + resp, err := e.client.Do(req) + if err != nil { + e.logger.Warn("ensure navidrome user", zap.String("user", username), zap.Error(err)) + return + } + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + if resp.StatusCode < http.StatusInternalServerError { + e.mu.Lock() + e.seen[username] = true + e.mu.Unlock() + } +} diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 1614d4a..31f0cd5 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -76,12 +76,13 @@ func run(logger *zap.Logger) error { ldap := auth.NewLDAP(ldapURL, logger) proxy := newNavidromeProxy(navidromeSock, logger) + ensurer := newUserEnsurer(navidromeSock, logger) mux := http.NewServeMux() mux.HandleFunc("GET /syncloud-oidc/login", oidc.Login) mux.HandleFunc("GET /syncloud-oidc/callback", oidc.Callback) mux.HandleFunc("GET /syncloud-oidc/logout", oidc.Logout) - mux.Handle("/rest/", subsonicHandler(ldap, proxy)) + mux.Handle("/rest/", subsonicHandler(ldap, proxy, ensurer)) mux.Handle("/", webHandler(oidc, proxy)) _ = os.Remove(backendSock) diff --git a/backend/cmd/backend/subsonic.go b/backend/cmd/backend/subsonic.go index 4b580df..38533ec 100644 --- a/backend/cmd/backend/subsonic.go +++ b/backend/cmd/backend/subsonic.go @@ -8,10 +8,11 @@ import ( "backend/auth" ) -func subsonicHandler(ldap *auth.LDAP, proxy http.Handler) http.Handler { +func subsonicHandler(ldap *auth.LDAP, proxy http.Handler, ensurer *userEnsurer) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user, pass, ok := subsonicCredentials(r) if ok && ldap.Authenticate(user, pass) { + ensurer.ensure(user) proxy.ServeHTTP(w, withUser(r, user)) return } From 5b4ea2ef2fc9f07f26f17adeec97e1d4fcf4786e Mon Sep 17 00:00:00 2001 From: cyberb Date: Sun, 28 Jun 2026 23:30:39 +0100 Subject: [PATCH 08/12] ci: temporarily build amd64 only (arm64 build runner offline) --- .drone.jsonnet | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.drone.jsonnet b/.drone.jsonnet index adcd543..517dc22 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -137,5 +137,5 @@ local build(arch, ui) = [{ ], }]; -build('amd64', true) + -build('arm64', false) +build('amd64', true) +// + build('arm64', false) // re-enable once the arm64 build runner is back online From deab6915b1ff4fecdba6d2a2f644af5c499bf017 Mon Sep 17 00:00:00 2001 From: cyberb Date: Sun, 28 Jun 2026 23:37:34 +0100 Subject: [PATCH 09/12] test: remove debugging diagnostics, keep artifact-based log collection --- test/test.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/test/test.py b/test/test.py index 53d69c2..e1ad1f5 100644 --- a/test/test.py +++ b/test/test.py @@ -13,28 +13,9 @@ requests.packages.urllib3.disable_warnings(InsecureRequestWarning) -def _diag(device): - print("\n========== BACKEND JOURNAL ==========") - print(device.run_ssh('journalctl -u snap.navidrome.backend --no-pager | tail -120', throw=False)) - print("\n========== NAVIDROME JOURNAL ==========") - print(device.run_ssh('journalctl -u snap.navidrome.navidrome --no-pager | tail -120', throw=False)) - print("\n========== DIRECT navidrome.sock + Remote-User ==========") - print(device.run_ssh( - 'curl -s -m 10 -H "Remote-User: user" --unix-socket /var/snap/navidrome/current/navidrome.sock ' - '"http://localhost/rest/ping?v=1.16.1&c=diag&f=json"', throw=False)) - print("\n========== DIRECT navidrome.sock native u/p ==========") - print(device.run_ssh( - 'curl -s -m 10 --unix-socket /var/snap/navidrome/current/navidrome.sock ' - '"http://localhost/rest/ping?u=user&p=Password1&v=1.16.1&c=diag&f=json"', throw=False)) - print("\n========== LDAP bind probe (ldapwhoami) ==========") - print(device.run_ssh( - 'ldapwhoami -x -H ldap://localhost:389 -D "cn=user,ou=users,dc=syncloud,dc=org" -w Password1 2>&1 || echo "ldapwhoami failed/absent"', throw=False)) - - @pytest.fixture(scope="session") def module_setup(request, device, artifact_dir): def module_teardown(): - _diag(device) device.run_ssh('ls -la /var/snap/{0}/current > {1}/var.current.ls.log'.format(APP, TMP_DIR), throw=False) device.run_ssh('cat /var/snap/{0}/current/config/nginx.conf > {1}/nginx.conf.log'.format(APP, TMP_DIR), throw=False) device.run_ssh('cat /var/snap/{0}/current/config/oidc.env > {1}/oidc.env.log'.format(APP, TMP_DIR), throw=False) From e94c3c962c3c46dde675d277f9d091f2957ade40 Mon Sep 17 00:00:00 2001 From: cyberb Date: Mon, 29 Jun 2026 22:13:43 +0100 Subject: [PATCH 10/12] ci: re-enable arm64 (build runner back online) --- .drone.jsonnet | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.drone.jsonnet b/.drone.jsonnet index 517dc22..adcd543 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -137,5 +137,5 @@ local build(arch, ui) = [{ ], }]; -build('amd64', true) -// + build('arm64', false) // re-enable once the arm64 build runner is back online +build('amd64', true) + +build('arm64', false) From 235ec35e015824ad6666a7ec2a05ff4210d89cc9 Mon Sep 17 00:00:00 2001 From: cyberb Date: Mon, 29 Jun 2026 22:20:29 +0100 Subject: [PATCH 11/12] Use GPL-3.0 license (match sibling apps/upstream); simplify README --- LICENSE | 699 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- README.md | 47 +--- 2 files changed, 681 insertions(+), 65 deletions(-) diff --git a/LICENSE b/LICENSE index cde4c81..9cecc1d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,25 +1,674 @@ -MIT License - -Copyright (c) 2026 Syncloud - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -This repository packages Navidrome (https://github.com/navidrome/navidrome), -which is distributed under the GNU GPLv3. The bundled Navidrome binary remains -under its own license. + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + {one line to give the program's name and a brief idea of what it does.} + Copyright (C) {year} {name of author} + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + {project} Copyright (C) {year} {fullname} + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md index 71c5d7b..ce3ec9f 100644 --- a/README.md +++ b/README.md @@ -6,48 +6,15 @@ the existing client ecosystem (Symfonium, Amperfy, play:Sub, DSub, Feishin, …) Tracks [syncloud/platform#741](https://github.com/syncloud/platform/issues/741). -## Architecture +## Authentication -A small Go gateway (`backend/`) sits in front of Navidrome and bridges Syncloud authentication -to Navidrome's externalized (reverse-proxy header) auth. Navidrome listens on a unix socket with -`ND_EXTAUTH_USERHEADER=Remote-User` and `ND_EXTAUTH_TRUSTEDSOURCES=@`; the gateway is the only -thing that talks to it and injects a trusted `Remote-User` header after authenticating the caller. - -``` -platform nginx ──▶ web.socket ──▶ nginx ──▶ backend.sock ─┬─▶ navidrome.sock - │ (Remote-User: ) - Web UI (/...) → OIDC (Authelia) → session cookie ─┘ - Subsonic (/rest/*) → LDAP bind (platform slapd) of the - client-supplied user+password ────┘ (token-auth clients fall - through to Navidrome native) -``` - -- **Web UI**: OpenID Connect against the platform's Authelia. Seamless single sign-on with the - rest of the Syncloud dashboard. -- **Subsonic / mobile apps**: the gateway validates the username/password the client sends against - the platform LDAP, so users log in with their **Syncloud credentials**. Set the mobile client to - send the password as plaintext / BasicAuth (the platform terminates HTTPS) — the Subsonic *token* - scheme (`md5(password+salt)`) can't be verified against LDAP and falls through to Navidrome's - native auth. - -## Layout - -| Path | What | -|---|---| -| `backend/` | Go auth gateway (OIDC + LDAP-Subsonic + reverse proxy) | -| `cli/` | Cobra install/configure/refresh hooks + `bin/cli` lifecycle commands | -| `navidrome/` | downloads & vendors the upstream Navidrome binary | -| `nginx/` | static nginx build, fronts `web.socket` | -| `config/` | templated `nginx.conf`, `oidc.env` | -| `test/` | pytest integration tests | -| `web/e2e/` | Playwright UI tests | +- **Web UI** — OpenID Connect single sign-on against the platform's Authelia, the same login as + the rest of the Syncloud dashboard. +- **Subsonic / mobile apps** — log in with your **Syncloud username and password** (validated + against the platform LDAP). Set the client to send the password as plaintext / BasicAuth (safe + over the platform's HTTPS); the Subsonic token scheme can't be verified against LDAP and falls + back to Navidrome's native login. ## Upstream version Pinned in `.drone.jsonnet` (`local version = '...'`). - -## Build - -CI builds via `.drone.jsonnet` on Drone. Locally each step is a script: `./nginx/build.sh`, -`./navidrome/build.sh `, `./backend/build.sh`, `./cli/build.sh`, then -`./package.sh navidrome `. From e0a22f172964bb8aa6943e802940c2b7885b4906 Mon Sep 17 00:00:00 2001 From: cyberb Date: Mon, 29 Jun 2026 22:54:37 +0100 Subject: [PATCH 12/12] Replace custom OIDC/LDAP backend with nginx Authelia auth_request Drop the Go auth gateway entirely. nginx now forward-auths every request against the platform Authelia (via GetAuthLocalSocket) and injects Remote-User into Navidrome, the same pattern owntracks uses: - web UI -> session auth_request (authelia-authrequest.conf), redirect to portal - /rest/* -> basic auth_request (authelia-authrequest-basic.conf) for Subsonic Navidrome keeps ND_EXTAUTH_USERHEADER=Remote-User / TRUSTEDSOURCES=@ on its unix socket. Installer only templates AuthUrl + AuthLocalSocket; no OIDC registration, no cookie secret, no LDAP. Subsonic clients use HTTP Basic; first web login provisions the Navidrome user. --- .drone.jsonnet | 5 - README.md | 14 +- backend/auth/ldap.go | 98 ---------- backend/auth/oidc.go | 259 ------------------------- backend/build.sh | 13 -- backend/cmd/backend/ensure.go | 64 ------ backend/cmd/backend/main.go | 108 ----------- backend/cmd/backend/proxy.go | 92 --------- backend/cmd/backend/subsonic.go | 45 ----- backend/config/config.go | 62 ------ backend/go.mod | 21 -- backend/go.sum | 52 ----- bin/service.backend.sh | 7 - cli/go.mod | 2 +- cli/go.sum | 4 +- cli/installer/installer.go | 91 ++------- config/authelia-authrequest-basic.conf | 14 ++ config/authelia-authrequest.conf | 32 +++ config/authelia-location-basic.conf | 37 ++++ config/authelia-location.conf | 32 +++ config/nginx.conf | 37 ++-- config/oidc.env | 6 - config/proxy.conf | 8 + meta/snap.yaml | 8 - test/test.py | 69 ++++--- 25 files changed, 211 insertions(+), 969 deletions(-) delete mode 100644 backend/auth/ldap.go delete mode 100644 backend/auth/oidc.go delete mode 100755 backend/build.sh delete mode 100644 backend/cmd/backend/ensure.go delete mode 100644 backend/cmd/backend/main.go delete mode 100644 backend/cmd/backend/proxy.go delete mode 100644 backend/cmd/backend/subsonic.go delete mode 100644 backend/config/config.go delete mode 100644 backend/go.mod delete mode 100644 backend/go.sum delete mode 100755 bin/service.backend.sh create mode 100644 config/authelia-authrequest-basic.conf create mode 100644 config/authelia-authrequest.conf create mode 100644 config/authelia-location-basic.conf create mode 100644 config/authelia-location.conf delete mode 100644 config/oidc.env create mode 100644 config/proxy.conf diff --git a/.drone.jsonnet b/.drone.jsonnet index adcd543..0928b07 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -46,11 +46,6 @@ local build(arch, ui) = [{ } for distro in distros ] + [ - { - name: 'backend', - image: 'golang:' + go, - commands: ['./backend/build.sh'], - }, { name: 'cli', image: 'golang:' + go, diff --git a/README.md b/README.md index ce3ec9f..4cecdcb 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,14 @@ Tracks [syncloud/platform#741](https://github.com/syncloud/platform/issues/741). ## Authentication -- **Web UI** — OpenID Connect single sign-on against the platform's Authelia, the same login as - the rest of the Syncloud dashboard. -- **Subsonic / mobile apps** — log in with your **Syncloud username and password** (validated - against the platform LDAP). Set the client to send the password as plaintext / BasicAuth (safe - over the platform's HTTPS); the Subsonic token scheme can't be verified against LDAP and falls - back to Navidrome's native login. +nginx authenticates every request against the platform's Authelia (`auth_request`) and passes the +user to Navidrome via the trusted `Remote-User` header — no app-specific auth code. + +- **Web UI** — Authelia single sign-on, the same login as the rest of the Syncloud dashboard. +- **Subsonic / mobile apps** — log in with your **Syncloud username and password** using the + client's **HTTP Basic auth** option (validated by Authelia). Open the Navidrome web UI once so + your account is created, then mobile clients work. The Subsonic *token* scheme can't be verified + by Authelia, so use Basic auth. ## Upstream version diff --git a/backend/auth/ldap.go b/backend/auth/ldap.go deleted file mode 100644 index 509855e..0000000 --- a/backend/auth/ldap.go +++ /dev/null @@ -1,98 +0,0 @@ -package auth - -import ( - "crypto/sha256" - "encoding/hex" - "fmt" - "sync" - "time" - - "github.com/go-ldap/ldap/v3" - "go.uber.org/zap" -) - -type LDAP struct { - URL string - Logger *zap.Logger - - cacheTTL time.Duration - mu sync.Mutex - cache map[string]time.Time -} - -func NewLDAP(url string, logger *zap.Logger) *LDAP { - return &LDAP{ - URL: url, - Logger: logger, - cacheTTL: 5 * time.Minute, - cache: map[string]time.Time{}, - } -} - -func (l *LDAP) Authenticate(username, password string) bool { - if !validUsername(username) || password == "" { - return false - } - key := cacheKey(username, password) - if l.cached(key) { - return true - } - - conn, err := ldap.DialURL(l.URL) - if err != nil { - l.Logger.Error("ldap dial", zap.Error(err)) - return false - } - defer conn.Close() - - dn := fmt.Sprintf("cn=%s,ou=users,dc=syncloud,dc=org", username) - if err := conn.Bind(dn, password); err != nil { - l.Logger.Warn("ldap bind failed", zap.String("user", username), zap.Error(err)) - return false - } - - l.store(key) - return true -} - -func (l *LDAP) cached(key string) bool { - l.mu.Lock() - defer l.mu.Unlock() - exp, ok := l.cache[key] - if !ok { - return false - } - if time.Now().After(exp) { - delete(l.cache, key) - return false - } - return true -} - -func (l *LDAP) store(key string) { - l.mu.Lock() - defer l.mu.Unlock() - l.cache[key] = time.Now().Add(l.cacheTTL) -} - -func validUsername(username string) bool { - if username == "" || len(username) > 64 { - return false - } - for _, r := range username { - switch { - case r >= 'a' && r <= 'z': - case r >= 'A' && r <= 'Z': - case r >= '0' && r <= '9': - case r == '.' || r == '_' || r == '-' || r == '@': - default: - return false - } - } - return true -} - -func cacheKey(username, password string) string { - h := sha256.Sum256([]byte(username + "\x00" + password)) - return username + ":" + hex.EncodeToString(h[:]) -} diff --git a/backend/auth/oidc.go b/backend/auth/oidc.go deleted file mode 100644 index 92d9d91..0000000 --- a/backend/auth/oidc.go +++ /dev/null @@ -1,259 +0,0 @@ -package auth - -import ( - "context" - "crypto/hmac" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "fmt" - "net/http" - "strings" - "time" - - "github.com/coreos/go-oidc/v3/oidc" - "go.uber.org/zap" - "golang.org/x/oauth2" -) - -type OIDC struct { - IssuerURL string - ClientID string - ClientSecret string - RedirectURL string - CookieSecret []byte - Logger *zap.Logger - - provider *oidc.Provider - verifier *oidc.IDTokenVerifier - oauth2Config oauth2.Config -} - -func (o *OIDC) Init(ctx context.Context) error { - provider, err := oidc.NewProvider(ctx, o.IssuerURL) - if err != nil { - return fmt.Errorf("oidc provider discovery: %w", err) - } - o.provider = provider - o.verifier = provider.Verifier(&oidc.Config{ClientID: o.ClientID}) - o.oauth2Config = oauth2.Config{ - ClientID: o.ClientID, - ClientSecret: o.ClientSecret, - RedirectURL: o.RedirectURL, - Endpoint: provider.Endpoint(), - Scopes: []string{oidc.ScopeOpenID, "profile", "email", "groups"}, - } - return nil -} - -const ( - stateCookie = "navidrome_oidc_state" - verifierCookie = "navidrome_oidc_verifier" - sessionCookie = "navidrome_session" - sessionTTL = 12 * time.Hour -) - -func (o *OIDC) Login(w http.ResponseWriter, r *http.Request) { - state, err := randBase64(16) - if err != nil { - http.Error(w, "state", http.StatusInternalServerError) - return - } - verifier, err := randBase64(32) - if err != nil { - http.Error(w, "verifier", http.StatusInternalServerError) - return - } - - setCookie(w, stateCookie, state, 10*time.Minute) - setCookie(w, verifierCookie, verifier, 10*time.Minute) - - authURL := o.oauth2Config.AuthCodeURL( - state, - oauth2.AccessTypeOnline, - oauth2.SetAuthURLParam("code_challenge", pkceChallenge(verifier)), - oauth2.SetAuthURLParam("code_challenge_method", "S256"), - ) - http.Redirect(w, r, authURL, http.StatusFound) -} - -func (o *OIDC) Callback(w http.ResponseWriter, r *http.Request) { - stateCk, err := r.Cookie(stateCookie) - if err != nil { - http.Error(w, "state cookie missing", http.StatusBadRequest) - return - } - if r.URL.Query().Get("state") != stateCk.Value { - http.Error(w, "state mismatch", http.StatusBadRequest) - return - } - verifierCk, err := r.Cookie(verifierCookie) - if err != nil { - http.Error(w, "verifier cookie missing", http.StatusBadRequest) - return - } - - ctx := r.Context() - token, err := o.oauth2Config.Exchange( - ctx, r.URL.Query().Get("code"), - oauth2.SetAuthURLParam("code_verifier", verifierCk.Value), - ) - if err != nil { - o.Logger.Error("oauth2 exchange", zap.Error(err)) - http.Error(w, "exchange failed", http.StatusBadGateway) - return - } - - rawIDToken, ok := token.Extra("id_token").(string) - if !ok { - http.Error(w, "id_token missing", http.StatusBadGateway) - return - } - idToken, err := o.verifier.Verify(ctx, rawIDToken) - if err != nil { - o.Logger.Error("id token verify", zap.Error(err)) - http.Error(w, "id token invalid", http.StatusUnauthorized) - return - } - - var claims struct { - Sub string `json:"sub"` - Email string `json:"email"` - PreferredUsername string `json:"preferred_username"` - } - if err := idToken.Claims(&claims); err != nil { - http.Error(w, "claims", http.StatusBadGateway) - return - } - - username := claims.PreferredUsername - if username == "" { - userInfo, err := o.provider.UserInfo(ctx, oauth2.StaticTokenSource(token)) - if err != nil { - o.Logger.Error("userinfo", zap.Error(err)) - http.Error(w, "userinfo failed", http.StatusBadGateway) - return - } - var ui struct { - PreferredUsername string `json:"preferred_username"` - } - if err := userInfo.Claims(&ui); err != nil { - http.Error(w, "userinfo claims", http.StatusBadGateway) - return - } - username = ui.PreferredUsername - } - if username == "" { - username = claims.Email - } - if username == "" { - o.Logger.Error("no username claim in token") - http.Error(w, "no username", http.StatusBadGateway) - return - } - - sess := session{User: username, Exp: time.Now().Add(sessionTTL).Unix()} - cookieVal, err := o.encodeSession(sess) - if err != nil { - http.Error(w, "encode session", http.StatusInternalServerError) - return - } - - clearCookie(w, stateCookie) - clearCookie(w, verifierCookie) - setCookie(w, sessionCookie, cookieVal, sessionTTL) - http.Redirect(w, r, "/", http.StatusFound) -} - -func (o *OIDC) Logout(w http.ResponseWriter, r *http.Request) { - clearCookie(w, sessionCookie) - http.Redirect(w, r, "/", http.StatusFound) -} - -func (o *OIDC) SessionUser(r *http.Request) (string, bool) { - ck, err := r.Cookie(sessionCookie) - if err != nil { - return "", false - } - return o.validSession(ck.Value) -} - -type session struct { - User string `json:"user"` - Exp int64 `json:"exp"` -} - -func (o *OIDC) encodeSession(s session) (string, error) { - body, err := json.Marshal(s) - if err != nil { - return "", err - } - mac := hmac.New(sha256.New, o.CookieSecret) - mac.Write(body) - sig := mac.Sum(nil) - return base64.URLEncoding.EncodeToString(body) + "." + base64.URLEncoding.EncodeToString(sig), nil -} - -func (o *OIDC) validSession(raw string) (string, bool) { - parts := strings.SplitN(raw, ".", 2) - if len(parts) != 2 { - return "", false - } - body, err := base64.URLEncoding.DecodeString(parts[0]) - if err != nil { - return "", false - } - sig, err := base64.URLEncoding.DecodeString(parts[1]) - if err != nil { - return "", false - } - mac := hmac.New(sha256.New, o.CookieSecret) - mac.Write(body) - if !hmac.Equal(sig, mac.Sum(nil)) { - return "", false - } - var s session - if err := json.Unmarshal(body, &s); err != nil { - return "", false - } - if time.Now().Unix() >= s.Exp { - return "", false - } - return s.User, true -} - -func setCookie(w http.ResponseWriter, name, value string, ttl time.Duration) { - http.SetCookie(w, &http.Cookie{ - Name: name, - Value: value, - Path: "/", - HttpOnly: true, - Secure: true, - SameSite: http.SameSiteLaxMode, - MaxAge: int(ttl.Seconds()), - }) -} - -func clearCookie(w http.ResponseWriter, name string) { - http.SetCookie(w, &http.Cookie{ - Name: name, - Path: "/", - MaxAge: -1, - HttpOnly: true, - Secure: true, - }) -} - -func randBase64(n int) (string, error) { - b := make([]byte, n) - if _, err := rand.Read(b); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(b), nil -} - -func pkceChallenge(verifier string) string { - h := sha256.Sum256([]byte(verifier)) - return base64.RawURLEncoding.EncodeToString(h[:]) -} diff --git a/backend/build.sh b/backend/build.sh deleted file mode 100755 index bce1757..0000000 --- a/backend/build.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash -ex - -DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) -ROOT=$( cd "${DIR}/.." && pwd ) -BUILD_DIR=${ROOT}/build/snap/backend - -mkdir -p ${BUILD_DIR} -cd ${DIR} - -go vet ./... - -export CGO_ENABLED=0 -go build -trimpath -buildvcs=false -ldflags '-s -w' -o ${BUILD_DIR}/backend ./cmd/backend diff --git a/backend/cmd/backend/ensure.go b/backend/cmd/backend/ensure.go deleted file mode 100644 index 0edc10b..0000000 --- a/backend/cmd/backend/ensure.go +++ /dev/null @@ -1,64 +0,0 @@ -package main - -import ( - "context" - "io" - "net" - "net/http" - "sync" - "time" - - "go.uber.org/zap" -) - -type userEnsurer struct { - client *http.Client - logger *zap.Logger - mu sync.Mutex - seen map[string]bool -} - -func newUserEnsurer(socket string, logger *zap.Logger) *userEnsurer { - return &userEnsurer{ - client: &http.Client{ - Timeout: 10 * time.Second, - Transport: &http.Transport{ - DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { - return (&net.Dialer{}).DialContext(ctx, "unix", socket) - }, - }, - CheckRedirect: func(*http.Request, []*http.Request) error { - return http.ErrUseLastResponse - }, - }, - logger: logger, - seen: map[string]bool{}, - } -} - -func (e *userEnsurer) ensure(username string) { - e.mu.Lock() - done := e.seen[username] - e.mu.Unlock() - if done { - return - } - - req, err := http.NewRequest(http.MethodGet, "http://navidrome/app/", nil) - if err != nil { - return - } - req.Header.Set(userHeader, username) - resp, err := e.client.Do(req) - if err != nil { - e.logger.Warn("ensure navidrome user", zap.String("user", username), zap.Error(err)) - return - } - _, _ = io.Copy(io.Discard, resp.Body) - _ = resp.Body.Close() - if resp.StatusCode < http.StatusInternalServerError { - e.mu.Lock() - e.seen[username] = true - e.mu.Unlock() - } -} diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go deleted file mode 100644 index 31f0cd5..0000000 --- a/backend/cmd/backend/main.go +++ /dev/null @@ -1,108 +0,0 @@ -package main - -import ( - "context" - "fmt" - "net" - "net/http" - "os" - - "github.com/spf13/cobra" - "go.uber.org/zap" - - "backend/auth" - "backend/config" -) - -const ( - app = "navidrome" - dataDir = "/var/snap/" + app + "/current" - backendSock = dataDir + "/backend.sock" - navidromeSock = dataDir + "/navidrome.sock" - secretPath = dataDir + "/.secret" - ldapURL = "ldap://localhost:389" - userHeader = "Remote-User" -) - -type ctxKey int - -const userKey ctxKey = 0 - -func withUser(r *http.Request, user string) *http.Request { - return r.WithContext(context.WithValue(r.Context(), userKey, user)) -} - -func main() { - cmd := &cobra.Command{ - Use: "backend", - Short: "Navidrome Syncloud auth gateway — OIDC web SSO + LDAP Subsonic auth in front of navidrome", - SilenceUsage: true, - RunE: func(_ *cobra.Command, _ []string) error { - logger, err := buildLogger() - if err != nil { - return err - } - return run(logger) - }, - } - if err := cmd.Execute(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - -func run(logger *zap.Logger) error { - cfg := &config.Config{DataDir: dataDir} - if err := cfg.Load(); err != nil { - return fmt.Errorf("load config: %w", err) - } - - cookieSecret, err := os.ReadFile(secretPath) - if err != nil { - return fmt.Errorf("read cookie secret: %w", err) - } - - oidc := &auth.OIDC{ - IssuerURL: cfg.AuthBaseURL, - ClientID: cfg.ClientID, - ClientSecret: cfg.ClientSecret, - RedirectURL: cfg.RedirectURI, - CookieSecret: cookieSecret, - Logger: logger, - } - if err := oidc.Init(context.Background()); err != nil { - return fmt.Errorf("oidc init: %w", err) - } - - ldap := auth.NewLDAP(ldapURL, logger) - proxy := newNavidromeProxy(navidromeSock, logger) - ensurer := newUserEnsurer(navidromeSock, logger) - - mux := http.NewServeMux() - mux.HandleFunc("GET /syncloud-oidc/login", oidc.Login) - mux.HandleFunc("GET /syncloud-oidc/callback", oidc.Callback) - mux.HandleFunc("GET /syncloud-oidc/logout", oidc.Logout) - mux.Handle("/rest/", subsonicHandler(ldap, proxy, ensurer)) - mux.Handle("/", webHandler(oidc, proxy)) - - _ = os.Remove(backendSock) - listener, err := net.Listen("unix", backendSock) - if err != nil { - return fmt.Errorf("listen %s: %w", backendSock, err) - } - if err := os.Chmod(backendSock, 0666); err != nil { - return fmt.Errorf("chmod socket: %w", err) - } - - logger.Info("backend listening", zap.String("socket", backendSock)) - return (&http.Server{Handler: mux}).Serve(listener) -} - -func buildLogger() (*zap.Logger, error) { - c := zap.NewProductionConfig() - c.Encoding = "console" - c.EncoderConfig.TimeKey = "" - c.OutputPaths = []string{"stdout"} - c.ErrorOutputPaths = []string{"stderr"} - return c.Build() -} diff --git a/backend/cmd/backend/proxy.go b/backend/cmd/backend/proxy.go deleted file mode 100644 index 3675f93..0000000 --- a/backend/cmd/backend/proxy.go +++ /dev/null @@ -1,92 +0,0 @@ -package main - -import ( - "context" - "net" - "net/http" - "net/http/httputil" - "net/url" - - "go.uber.org/zap" - - "backend/auth" -) - -func newNavidromeProxy(socket string, logger *zap.Logger) *httputil.ReverseProxy { - target, _ := url.Parse("http://navidrome") - proxy := httputil.NewSingleHostReverseProxy(target) - proxy.Transport = &http.Transport{ - DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { - return (&net.Dialer{}).DialContext(ctx, "unix", socket) - }, - } - proxy.FlushInterval = -1 - - orig := proxy.Director - proxy.Director = func(r *http.Request) { - orig(r) - r.Header.Del(userHeader) - if user, ok := r.Context().Value(userKey).(string); ok && user != "" { - r.Header.Set(userHeader, user) - } - } - proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, err error) { - logger.Error("proxy to navidrome failed", zap.Error(err)) - http.Error(w, "navidrome unavailable", http.StatusBadGateway) - } - return proxy -} - -func webHandler(oidc *auth.OIDC, proxy http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user, ok := oidc.SessionUser(r) - if !ok { - if isBrowserNavigation(r) { - http.Redirect(w, r, "/syncloud-oidc/login", http.StatusFound) - return - } - http.Error(w, "unauthenticated", http.StatusUnauthorized) - return - } - proxy.ServeHTTP(w, withUser(r, user)) - }) -} - -func isBrowserNavigation(r *http.Request) bool { - if r.Method != http.MethodGet { - return false - } - return wantsHTML(r.Header.Get("Accept")) -} - -func wantsHTML(accept string) bool { - for _, part := range splitComma(accept) { - if part == "text/html" || part == "application/xhtml+xml" { - return true - } - } - return false -} - -func splitComma(s string) []string { - var out []string - start := 0 - for i := 0; i < len(s); i++ { - if s[i] == ',' || s[i] == ';' { - out = append(out, trimSpace(s[start:i])) - start = i + 1 - } - } - out = append(out, trimSpace(s[start:])) - return out -} - -func trimSpace(s string) string { - for len(s) > 0 && (s[0] == ' ' || s[0] == '\t') { - s = s[1:] - } - for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t') { - s = s[:len(s)-1] - } - return s -} diff --git a/backend/cmd/backend/subsonic.go b/backend/cmd/backend/subsonic.go deleted file mode 100644 index 38533ec..0000000 --- a/backend/cmd/backend/subsonic.go +++ /dev/null @@ -1,45 +0,0 @@ -package main - -import ( - "encoding/hex" - "net/http" - "strings" - - "backend/auth" -) - -func subsonicHandler(ldap *auth.LDAP, proxy http.Handler, ensurer *userEnsurer) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user, pass, ok := subsonicCredentials(r) - if ok && ldap.Authenticate(user, pass) { - ensurer.ensure(user) - proxy.ServeHTTP(w, withUser(r, user)) - return - } - proxy.ServeHTTP(w, r) - }) -} - -func subsonicCredentials(r *http.Request) (string, string, bool) { - if user, pass, ok := r.BasicAuth(); ok && pass != "" { - return user, pass, true - } - - q := r.URL.Query() - user := q.Get("u") - if user == "" { - return "", "", false - } - pass := q.Get("p") - if pass == "" { - return "", "", false - } - if decoded, ok := strings.CutPrefix(pass, "enc:"); ok { - b, err := hex.DecodeString(decoded) - if err != nil { - return "", "", false - } - pass = string(b) - } - return user, pass, true -} diff --git a/backend/config/config.go b/backend/config/config.go deleted file mode 100644 index e8222ca..0000000 --- a/backend/config/config.go +++ /dev/null @@ -1,62 +0,0 @@ -package config - -import ( - "fmt" - "os" - "path/filepath" - "strings" -) - -type Config struct { - DataDir string - - AppUrl string - ClientID string - ClientSecret string - AuthBaseURL string - RedirectURI string -} - -func (c *Config) Load() error { - kv, err := loadKV(filepath.Join(c.DataDir, "config", "oidc.env")) - if err != nil { - return fmt.Errorf("oidc.env: %w", err) - } - c.AppUrl = kv["APP_URL"] - c.ClientID = kv["OIDC_CLIENT_ID"] - c.ClientSecret = kv["OIDC_CLIENT_SECRET"] - c.AuthBaseURL = kv["OIDC_AUTH_BASE_URL"] - c.RedirectURI = kv["OIDC_REDIRECT_URI"] - - for k, v := range map[string]string{ - "OIDC_CLIENT_ID": c.ClientID, - "OIDC_CLIENT_SECRET": c.ClientSecret, - "OIDC_AUTH_BASE_URL": c.AuthBaseURL, - "OIDC_REDIRECT_URI": c.RedirectURI, - } { - if v == "" { - return fmt.Errorf("%s is empty in oidc.env", k) - } - } - return nil -} - -func loadKV(path string) (map[string]string, error) { - b, err := os.ReadFile(path) - if err != nil { - return nil, err - } - out := map[string]string{} - for _, line := range strings.Split(string(b), "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - k, v, ok := strings.Cut(line, "=") - if !ok { - continue - } - out[strings.TrimSpace(k)] = strings.Trim(strings.TrimSpace(v), `"`) - } - return out, nil -} diff --git a/backend/go.mod b/backend/go.mod deleted file mode 100644 index 9d9f7f3..0000000 --- a/backend/go.mod +++ /dev/null @@ -1,21 +0,0 @@ -module backend - -go 1.23 - -require ( - github.com/coreos/go-oidc/v3 v3.11.0 - github.com/go-ldap/ldap/v3 v3.4.4 - github.com/spf13/cobra v1.7.0 - go.uber.org/zap v1.25.0 - golang.org/x/oauth2 v0.23.0 -) - -require ( - github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e // indirect - github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect - github.com/go-jose/go-jose/v4 v4.0.2 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect - go.uber.org/multierr v1.10.0 // indirect - golang.org/x/crypto v0.25.0 // indirect -) diff --git a/backend/go.sum b/backend/go.sum deleted file mode 100644 index e8c787e..0000000 --- a/backend/go.sum +++ /dev/null @@ -1,52 +0,0 @@ -github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e h1:NeAW1fUYUEWhft7pkxDf6WoUvEZJ/uOKsvtpjLnn8MU= -github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= -github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= -github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= -github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI= -github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= -github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-asn1-ber/asn1-ber v1.5.4 h1:vXT6d/FNDiELJnLb6hGNa309LMsrCoYFvpwHDF0+Y1A= -github.com/go-asn1-ber/asn1-ber v1.5.4/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= -github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk= -github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= -github.com/go-ldap/ldap/v3 v3.4.4 h1:qPjipEpt+qDa6SI/h1fzuGWoRUY+qqQ9sOZq67/PYUs= -github.com/go-ldap/ldap/v3 v3.4.4/go.mod h1:fe1MsuN5eJJ1FeLT/LEBVdWfNWKh459R7aXgXtJC+aI= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= -github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= -go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= -go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= -go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.25.0 h1:4Hvk6GtkucQ790dqmj7l1eEnRdKm3k3ZUrUMS2d5+5c= -go.uber.org/zap v1.25.0/go.mod h1:JIAUzQIH94IC4fOJQm7gMmBJP5k7wQfdcnYdPoEXJYk= -golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= -golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= -golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/bin/service.backend.sh b/bin/service.backend.sh deleted file mode 100755 index 692b43d..0000000 --- a/bin/service.backend.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash -e - -export SSL_CERT_FILE=/var/snap/platform/current/syncloud.ca.crt - -/bin/rm -f ${SNAP_DATA}/backend.sock - -exec ${SNAP}/backend/backend diff --git a/cli/go.mod b/cli/go.mod index a2efcea..5009f48 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -5,7 +5,7 @@ go 1.23 require ( github.com/otiai10/copy v1.12.0 github.com/spf13/cobra v1.7.0 - github.com/syncloud/golib v1.1.15 + github.com/syncloud/golib v1.1.17 go.uber.org/zap v1.25.0 ) diff --git a/cli/go.sum b/cli/go.sum index 27e145b..c574fdf 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -18,8 +18,8 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/syncloud/golib v1.1.15 h1:NuWw/BOJ+0maoU775HxrlpvXdAWrFvRnn5X2gPhRBxA= -github.com/syncloud/golib v1.1.15/go.mod h1:XmmoqNuegLnl27FbmzLj60dTR/tN7c1X7Qp3uUPRC88= +github.com/syncloud/golib v1.1.17 h1:/TddxLOGa8ujEb6DahvVC9UP6onDAwfR85+JDqZfSqc= +github.com/syncloud/golib v1.1.17/go.mod h1:XmmoqNuegLnl27FbmzLj60dTR/tN7c1X7Qp3uUPRC88= go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= diff --git a/cli/installer/installer.go b/cli/installer/installer.go index ee55a7f..39954d0 100644 --- a/cli/installer/installer.go +++ b/cli/installer/installer.go @@ -1,8 +1,6 @@ package installer import ( - "crypto/rand" - "encoding/hex" "fmt" "os" "path" @@ -15,26 +13,21 @@ import ( ) type Variables struct { - App string - AppDir string - DataDir string - CommonDir string - StorageDir string - AppDomain string - AppUrl string - AuthUrl string - OIDCClientID string - OIDCClientSecret string - OIDCRedirectURI string - Socket string + App string + AppDir string + DataDir string + CommonDir string + StorageDir string + Socket string + AuthUrl string + AuthLocalSocket string } const ( - App = "navidrome" - AppDir = "/snap/navidrome/current" - DataDir = "/var/snap/navidrome/current" - CommonDir = "/var/snap/navidrome/common" - OIDCCallback = "/syncloud-oidc/callback" + App = "navidrome" + AppDir = "/snap/navidrome/current" + DataDir = "/var/snap/navidrome/current" + CommonDir = "/var/snap/navidrome/common" ) type Installer struct { @@ -42,7 +35,6 @@ type Installer struct { currentVersionFile string platformClient *platform.Client installFile string - secretFile string logger *zap.Logger } @@ -52,7 +44,6 @@ func New(logger *zap.Logger) *Installer { currentVersionFile: path.Join(DataDir, "version"), platformClient: platform.New(), installFile: path.Join(CommonDir, "installed"), - secretFile: path.Join(DataDir, ".secret"), logger: logger, } } @@ -152,39 +143,20 @@ func (i *Installer) UpdateConfigs() error { } func (i *Installer) GenerateConfig(storageDir string) error { - oidcSecret, err := i.platformClient.RegisterOIDCClient(App, OIDCCallback, true, "client_secret_basic") - if err != nil { - return fmt.Errorf("register oidc client: %w", err) - } authUrl, err := i.platformClient.GetAppUrl("auth") if err != nil { return err } - appUrl, err := i.platformClient.GetAppUrl(App) - if err != nil { - return err - } - appDomain, err := i.platformClient.GetAppDomainName(App) - if err != nil { - return err - } - if _, err := i.cookieSecret(); err != nil { - return err - } variables := Variables{ - App: App, - AppDir: AppDir, - DataDir: DataDir, - CommonDir: CommonDir, - StorageDir: storageDir, - AppDomain: appDomain, - AppUrl: appUrl, - AuthUrl: authUrl, - OIDCClientID: App, - OIDCClientSecret: oidcSecret, - OIDCRedirectURI: trimRightSlash(appUrl) + OIDCCallback, - Socket: path.Join(DataDir, "backend.sock"), + App: App, + AppDir: AppDir, + DataDir: DataDir, + CommonDir: CommonDir, + StorageDir: storageDir, + Socket: path.Join(DataDir, "navidrome.sock"), + AuthUrl: authUrl, + AuthLocalSocket: i.platformClient.GetAuthLocalSocket(), } return config.Generate( @@ -194,22 +166,6 @@ func (i *Installer) GenerateConfig(storageDir string) error { ) } -func (i *Installer) cookieSecret() (string, error) { - existing, err := os.ReadFile(i.secretFile) - if err == nil && len(existing) > 0 { - return string(existing), nil - } - buf := make([]byte, 32) - if _, err := rand.Read(buf); err != nil { - return "", err - } - secret := hex.EncodeToString(buf) - if err := os.WriteFile(i.secretFile, []byte(secret), 0640); err != nil { - return "", err - } - return secret, nil -} - func (i *Installer) FixPermissions() error { if err := linux.Chown(DataDir, App); err != nil { return err @@ -220,10 +176,3 @@ func (i *Installer) FixPermissions() error { func (i *Installer) BackupPreStop() error { return i.PreRefresh() } func (i *Installer) RestorePreStart() error { return i.PostRefresh() } func (i *Installer) RestorePostStart() error { return i.Configure() } - -func trimRightSlash(s string) string { - for len(s) > 0 && s[len(s)-1] == '/' { - s = s[:len(s)-1] - } - return s -} diff --git a/config/authelia-authrequest-basic.conf b/config/authelia-authrequest-basic.conf new file mode 100644 index 0000000..aaf7c07 --- /dev/null +++ b/config/authelia-authrequest-basic.conf @@ -0,0 +1,14 @@ +## Send a subrequest to Authelia to verify if the user is authenticated and has permission to access the resource. +auth_request /internal/authelia/authz/basic; + +## Save the upstream response headers from Authelia to variables. +auth_request_set $user $upstream_http_remote_user; +auth_request_set $groups $upstream_http_remote_groups; +auth_request_set $name $upstream_http_remote_name; +auth_request_set $email $upstream_http_remote_email; + +## Inject the response headers from the variables into the request made to the backend. +proxy_set_header Remote-User $user; +proxy_set_header Remote-Groups $groups; +proxy_set_header Remote-Name $name; +proxy_set_header Remote-Email $email; diff --git a/config/authelia-authrequest.conf b/config/authelia-authrequest.conf new file mode 100644 index 0000000..4cd168d --- /dev/null +++ b/config/authelia-authrequest.conf @@ -0,0 +1,32 @@ +## Send a subrequest to Authelia to verify if the user is authenticated and has permission to access the resource. +auth_request /internal/authelia/authz; + +## Save the upstream metadata response headers from Authelia to variables. +auth_request_set $user $upstream_http_remote_user; +auth_request_set $groups $upstream_http_remote_groups; +auth_request_set $name $upstream_http_remote_name; +auth_request_set $email $upstream_http_remote_email; + +## Inject the metadata response headers from the variables into the request made to the backend. +proxy_set_header Remote-User $user; +proxy_set_header Remote-Groups $groups; +proxy_set_header Remote-Email $email; +proxy_set_header Remote-Name $name; + +## Configure the redirection when the authz failure occurs. Lines starting with 'Modern Method' and 'Legacy Method' +## should be commented / uncommented as pairs. The modern method uses the session cookies configuration's authelia_url +## value to determine the redirection URL here. It's much simpler and compatible with the mutli-cookie domain easily. + +## Modern Method: Set the $redirection_url to the Location header of the response to the Authz endpoint. +auth_request_set $redirection_url $upstream_http_location; + +## Modern Method: When there is a 401 response code from the authz endpoint redirect to the $redirection_url. +error_page 401 =302 $redirection_url; + +## Legacy Method: Set $target_url to the original requested URL. +## This requires http_set_misc module, replace 'set_escape_uri' with 'set' if you don't have this module. +# set_escape_uri $target_url $scheme://$http_host$request_uri; + +## Legacy Method: When there is a 401 response code from the authz endpoint redirect to the portal with the 'rd' +## URL parameter set to $target_url. This requires users update 'auth.example.com/' with their external authelia URL. +# error_page 401 =302 {{ .AuthUrl }}/?rd=$target_url; diff --git a/config/authelia-location-basic.conf b/config/authelia-location-basic.conf new file mode 100644 index 0000000..21807c7 --- /dev/null +++ b/config/authelia-location-basic.conf @@ -0,0 +1,37 @@ +#set $upstream_authelia http://authelia:9091/api/authz/auth-request; + +# Virtual endpoint created by nginx to forward auth requests. +location /internal/authelia/authz/basic { + ## Essential Proxy Configuration + internal; + proxy_pass {{ .AuthLocalSocket }}/api/authz/auth-request; + + ## Headers + ## The headers starting with X-* are required. + proxy_set_header X-Original-Method $request_method; + proxy_set_header X-Original-URL https://$http_host$request_uri; + proxy_set_header X-Original-Method $request_method; + proxy_set_header X-Forwarded-Method $request_method; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Forwarded-URI $request_uri; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header Content-Length ""; + proxy_set_header Connection ""; + + ## Basic Proxy Configuration + proxy_pass_request_body off; + proxy_next_upstream error timeout invalid_header http_500 http_502 http_503; # Timeout if the real server is dead + proxy_redirect http:// $scheme://; + proxy_http_version 1.1; + proxy_cache_bypass $cookie_session; + proxy_no_cache $cookie_session; + proxy_buffers 4 32k; + client_body_buffer_size 128k; + + ## Advanced Proxy Configuration + send_timeout 5m; + proxy_read_timeout 240; + proxy_send_timeout 240; + proxy_connect_timeout 240; +} diff --git a/config/authelia-location.conf b/config/authelia-location.conf new file mode 100644 index 0000000..282bb96 --- /dev/null +++ b/config/authelia-location.conf @@ -0,0 +1,32 @@ +# set $upstream_authelia https://authelia/api/authz/auth-request; + +## Virtual endpoint created by nginx to forward auth requests. +location /internal/authelia/authz { + ## Essential Proxy Configuration + internal; + proxy_pass {{ .AuthLocalSocket }}/api/authz/auth-request; + + ## Headers + ## The headers starting with X-* are required. + proxy_set_header X-Original-Method $request_method; + proxy_set_header X-Original-URL https://$http_host$request_uri; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header Content-Length ""; + proxy_set_header Connection ""; + + ## Basic Proxy Configuration + proxy_pass_request_body off; + proxy_next_upstream error timeout invalid_header http_500 http_502 http_503; # Timeout if the real server is dead + proxy_redirect http:// $scheme://; + proxy_http_version 1.1; + proxy_cache_bypass $cookie_session; + proxy_no_cache $cookie_session; + proxy_buffers 4 32k; + client_body_buffer_size 128k; + + ## Advanced Proxy Configuration + send_timeout 5m; + proxy_read_timeout 240; + proxy_send_timeout 240; + proxy_connect_timeout 240; +} diff --git a/config/nginx.conf b/config/nginx.conf index cb12def..e23a9f5 100644 --- a/config/nginx.conf +++ b/config/nginx.conf @@ -19,14 +19,13 @@ http { scgi_temp_path {{ .DataDir }}/nginx/scgi_temp; client_max_body_size 0; - proxy_request_buffering off; - map $http_x_forwarded_proto $forwarded_proto { - default $http_x_forwarded_proto; - '' https; + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; } - upstream backend { + upstream navidrome { server unix:{{ .Socket }}; } @@ -36,18 +35,30 @@ http { set_real_ip_from unix:; server_name localhost; + include {{ .DataDir }}/config/authelia-location.conf; + include {{ .DataDir }}/config/authelia-location-basic.conf; + + location /rest/ { + include {{ .DataDir }}/config/proxy.conf; + include {{ .DataDir }}/config/authelia-authrequest-basic.conf; + + proxy_pass http://navidrome; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_redirect off; + } + location / { - proxy_pass http://backend; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $forwarded_proto; - proxy_set_header X-Forwarded-Host $host; + include {{ .DataDir }}/config/proxy.conf; + include {{ .DataDir }}/config/authelia-authrequest.conf; + + proxy_pass http://navidrome; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_redirect off; + proxy_set_header Connection $connection_upgrade; proxy_buffering off; + proxy_redirect off; } } } diff --git a/config/oidc.env b/config/oidc.env deleted file mode 100644 index 9aaf431..0000000 --- a/config/oidc.env +++ /dev/null @@ -1,6 +0,0 @@ -APP_DOMAIN={{ .AppDomain }} -APP_URL={{ .AppUrl }} -OIDC_CLIENT_ID={{ .OIDCClientID }} -OIDC_CLIENT_SECRET={{ .OIDCClientSecret }} -OIDC_AUTH_BASE_URL={{ .AuthUrl }} -OIDC_REDIRECT_URI={{ .OIDCRedirectURI }} diff --git a/config/proxy.conf b/config/proxy.conf new file mode 100644 index 0000000..04dfb33 --- /dev/null +++ b/config/proxy.conf @@ -0,0 +1,8 @@ +## Headers +proxy_set_header Host $host; +proxy_set_header X-Original-URL $scheme://$http_host$request_uri; +proxy_set_header X-Forwarded-Proto $scheme; +proxy_set_header X-Forwarded-Host $http_host; +proxy_set_header X-Forwarded-URI $request_uri; +proxy_set_header X-Forwarded-Ssl on; +proxy_set_header X-Forwarded-For $remote_addr; \ No newline at end of file diff --git a/meta/snap.yaml b/meta/snap.yaml index 20082f5..9621e8d 100644 --- a/meta/snap.yaml +++ b/meta/snap.yaml @@ -11,14 +11,6 @@ apps: start-timeout: 600s restart-delay: 10s - backend: - user: navidrome - command: bin/service.backend.sh - daemon: simple - restart-condition: always - start-timeout: 600s - restart-delay: 10s - nginx: user: navidrome command: bin/service.nginx.sh diff --git a/test/test.py b/test/test.py index e1ad1f5..f21af46 100644 --- a/test/test.py +++ b/test/test.py @@ -18,9 +18,7 @@ def module_setup(request, device, artifact_dir): def module_teardown(): device.run_ssh('ls -la /var/snap/{0}/current > {1}/var.current.ls.log'.format(APP, TMP_DIR), throw=False) device.run_ssh('cat /var/snap/{0}/current/config/nginx.conf > {1}/nginx.conf.log'.format(APP, TMP_DIR), throw=False) - device.run_ssh('cat /var/snap/{0}/current/config/oidc.env > {1}/oidc.env.log'.format(APP, TMP_DIR), throw=False) device.run_ssh('journalctl -u snap.{0}.navidrome --no-pager | tail -2000 > {1}/navidrome.log'.format(APP, TMP_DIR), throw=False) - device.run_ssh('journalctl -u snap.{0}.backend --no-pager | tail -2000 > {1}/backend.log'.format(APP, TMP_DIR), throw=False) device.run_ssh('journalctl -u snap.{0}.nginx --no-pager | tail -2000 > {1}/nginx.log'.format(APP, TMP_DIR), throw=False) device.run_ssh('journalctl --no-pager | tail -3000 > {0}/journalctl.log'.format(TMP_DIR), throw=False) device.scp_from_device('{0}/*'.format(TMP_DIR), artifact_dir) @@ -29,23 +27,22 @@ def module_teardown(): request.addfinalizer(module_teardown) -def subsonic_ping_ok(app_domain, user, password): +def provision_user(app_domain, user, password): session = requests.session() - url = "https://{0}/rest/ping".format(app_domain) - params = {'u': user, 'p': password, 'v': '1.16.1', 'c': 'syncloud-test', 'f': 'json'} - last = None - for _ in range(60): - try: - r = session.get(url, params=params, verify=False, timeout=10) - last = r.text - if r.status_code == 200: - resp = r.json().get('subsonic-response', {}) - if resp.get('status') == 'ok': - return True - except Exception as e: - last = str(e) + for _ in range(30): + r = session.get("https://{0}/app/".format(app_domain), auth=(user, password), verify=False, timeout=10) + if r.status_code == 200: + return True time.sleep(2) - return False, last + return False + + +def subsonic_ping(app_domain, user, password): + session = requests.session() + return session.get( + "https://{0}/rest/ping".format(app_domain), + params={'v': '1.16.1', 'c': 'syncloud-test', 'f': 'json'}, + auth=(user, password), verify=False, timeout=10) def test_start(module_setup, device, device_host, app, domain): @@ -67,36 +64,33 @@ def test_install(app_archive_path, device_host, device_password): def test_sockets(device): device.run_ssh('test -S /var/snap/navidrome/current/navidrome.sock', retries=30) - device.run_ssh('test -S /var/snap/navidrome/current/backend.sock', retries=30) + device.run_ssh('test -S /var/snap/navidrome/common/web.socket', retries=30) -def test_web_redirects_to_sso(app_domain): +def test_web_requires_auth(app_domain): session = requests.session() last = None for _ in range(60): - r = session.get("https://{0}/".format(app_domain), verify=False, - allow_redirects=False, headers={'Accept': 'text/html'}, timeout=10) + r = session.get("https://{0}/".format(app_domain), verify=False, allow_redirects=False, timeout=10) last = r.status_code - if r.status_code in (302, 303): - location = r.headers.get('Location', '') - assert '/syncloud-oidc/login' in location or 'auth.' in location, location + if r.status_code in (301, 302, 303): + assert 'auth.' in r.headers.get('Location', ''), r.headers.get('Location') return time.sleep(2) - assert False, "expected redirect to SSO, last status {0}".format(last) + assert False, "expected redirect to Authelia portal, last status {0}".format(last) -def test_subsonic_login_via_ldap(app_domain, device_user, device_password): - result = subsonic_ping_ok(app_domain, device_user, device_password) - assert result is True, "subsonic ping with syncloud credentials failed: {0}".format(result) +@pytest.mark.flaky(retries=10, delay=6) +def test_subsonic_login_via_authelia(app_domain, device_user, device_password): + assert provision_user(app_domain, device_user, device_password), "web provisioning failed" + r = subsonic_ping(app_domain, device_user, device_password) + assert r.status_code == 200, r.text + assert r.json().get('subsonic-response', {}).get('status') == 'ok', r.text def test_subsonic_rejects_wrong_password(app_domain, device_user): - session = requests.session() - url = "https://{0}/rest/ping".format(app_domain) - params = {'u': device_user, 'p': 'definitely-wrong', 'v': '1.16.1', 'c': 'syncloud-test', 'f': 'json'} - r = session.get(url, params=params, verify=False, timeout=10) - assert r.status_code == 200, r.text - assert r.json().get('subsonic-response', {}).get('status') == 'failed', r.text + r = subsonic_ping(app_domain, device_user, 'definitely-wrong') + assert r.status_code == 401, "expected 401 from authelia basic, got {0}: {1}".format(r.status_code, r.text[:200]) def test_remove(device, app): @@ -108,6 +102,9 @@ def test_reinstall(app_archive_path, device_host, device_password): local_install(device_host, device_password, app_archive_path) +@pytest.mark.flaky(retries=10, delay=6) def test_subsonic_after_reinstall(app_domain, device_user, device_password): - result = subsonic_ping_ok(app_domain, device_user, device_password) - assert result is True, "subsonic ping after reinstall failed: {0}".format(result) + assert provision_user(app_domain, device_user, device_password), "web provisioning failed after reinstall" + r = subsonic_ping(app_domain, device_user, device_password) + assert r.status_code == 200, r.text + assert r.json().get('subsonic-response', {}).get('status') == 'ok', r.text