diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1ad7c38 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# Server +LISTEN_ADDR=:8080 +SESSION_SECRET=change-me-to-a-long-random-secret-at-least-32-chars +DATABASE_PATH=data/migaduadmin.db +COOKIE_SECURE=false + +# Migadu API (https://www.migadu.com/api/) +# Username is your Migadu account email; password is the API key ("app-key"). +# `make dev` creates .env automatically; replace these before loading domains. +MIGADU_USER=you@example.com +MIGADU_API_KEY=your-migadu-api-key +MIGADU_BASE_URL=https://api.migadu.com/v1 + +# Auth: local | pangolin | both +AUTH_MODE=both + +# Comma-separated CIDRs allowed to supply Pangolin SSO headers +# (Remote-Email, Remote-User, Remote-Name, Remote-Role). +TRUSTED_PROXIES=127.0.0.1/32,::1/128 + +# After the first admin exists, registration stays closed unless true. +REGISTRATION_OPEN=false + +# When true, unknown Pangolin SSO emails are auto-created as role=user. +# First-ever user is always admin regardless of this setting. +SSO_AUTO_PROVISION=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6d9dd28 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Binaries / local data +/bin/ +/data/ +*.db +*.exe +/migaduadmin +/tmp/ + +# Env +.env +.env.local + +# Frontend +web/node_modules/ +web/dist/ + +# Editor / OS +.DS_Store +.idea/ +.vscode/ +*.swp + +# Go +vendor/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..79fb7ff --- /dev/null +++ b/Makefile @@ -0,0 +1,41 @@ +.PHONY: setup-dev dev build frontend backend run tidy clean + +export PATH := $(HOME)/.local/go/bin:$(HOME)/.local/bin:$(PATH) + +setup-dev: + @bash scripts/setup-dev.sh + +dev: setup-dev + @echo "API → http://127.0.0.1:8080" + @echo "UI → http://127.0.0.1:5173 (live reload; proxies /api → Go)" + @echo "Open the UI URL in your browser. Ctrl+C stops both." + @bash -c 'set -euo pipefail; \ + go run ./cmd/server & \ + api_pid=$$!; \ + (cd web && npm run dev -- --host 127.0.0.1 --port 5173) & \ + ui_pid=$$!; \ + trap "kill $$api_pid $$ui_pid 2>/dev/null; wait $$api_pid $$ui_pid 2>/dev/null" EXIT INT TERM; \ + wait' + +frontend: + cd web && npm install && npm run build + rm -rf cmd/server/ui + mkdir -p cmd/server/ui + cp -R web/dist/. cmd/server/ui/ + +backend: + go build -o bin/migaduadmin ./cmd/server + +build: frontend backend + +run: build + ./bin/migaduadmin + +tidy: + go mod tidy + cd web && npm install + +clean: + rm -rf bin web/dist cmd/server/ui + mkdir -p cmd/server/ui + printf '%s\n' '
UI not built. Run make frontend.
' > cmd/server/ui/index.html diff --git a/README.md b/README.md index 3da05cb..fb4f778 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,95 @@ # MigaduAdmin -Administrate settings for several domains in one place. \ No newline at end of file +Administrate domain-level Migadu settings for multiple domains in one place: +sender denylist (blacklist), sender allowlist (whitelist), recipient denylist, +and spam controls. + +The Go backend holds your Migadu credentials and talks to the +[Migadu JSON API](https://www.migadu.com/api/). The React UI only talks to this +application. + +## Features + +- Multi-domain list and detail views +- Shared denylist / allowlist / recipient deny lists with manual apply to all or selected domains +- Edit per-domain denylist / allowlist / recipient deny lists +- Edit domain spam aggressiveness and related toggles +- First registered local account becomes admin +- Optional [Pangolin](https://docs.pangolin.net/manage/access-control/forwarded-headers) + SSO via `Remote-Email` (and related) headers from trusted proxies + +## Requirements + +- Go 1.25+ (or Go toolchain that can download it) +- Node.js 20+ (for building the UI) + +## Quick start + +```bash +make dev +``` + +On first run this will: +1. Create `.env` from `.env.example` with a generated `SESSION_SECRET` +2. Install frontend dependencies +3. Start the Go API and Vite UI together + +Then open **http://127.0.0.1:5173**, create the first admin account, and edit +`.env` with your real `MIGADU_USER` / `MIGADU_API_KEY` before managing domains. + +Production-style single binary: + +```bash +# edit .env first +make build +make run +``` + +Open http://127.0.0.1:8080. + +## Configuration + +| Variable | Description | +| --- | --- | +| `MIGADU_USER` | Migadu account email (Basic Auth username) | +| `MIGADU_API_KEY` | Migadu API key | +| `MIGADU_BASE_URL` | Default `https://api.migadu.com/v1` | +| `LISTEN_ADDR` | Default `:8080` | +| `SESSION_SECRET` | Cookie signing secret (min 32 characters) | +| `DATABASE_PATH` | SQLite path for users/sessions | +| `AUTH_MODE` | `local`, `pangolin`, or `both` | +| `TRUSTED_PROXIES` | CIDRs allowed to set SSO headers | +| `REGISTRATION_OPEN` | Allow new local signups after bootstrap | +| `SSO_AUTO_PROVISION` | Auto-create users from trusted Pangolin emails | +| `COOKIE_SECURE` | Set `true` behind HTTPS | + +Create a Migadu API key under **My Account → API Keys** in the Migadu admin UI. + +## Pangolin SSO + +When `AUTH_MODE` is `pangolin` or `both`, and the request comes from an IP in +`TRUSTED_PROXIES`, the backend trusts: + +- `Remote-Email` (required) +- `Remote-User`, `Remote-Name`, `Remote-Role` (optional) + +Identity headers from untrusted peers are stripped. Put Pangolin / Badger in +front of this app and add the proxy address to `TRUSTED_PROXIES`. + +## API overview + +| Method | Path | Notes | +| --- | --- | --- | +| GET | `/api/auth/status` | Registration open + auth mode | +| POST | `/api/auth/register` | Bootstrap / open registration | +| POST | `/api/auth/login` | Local login | +| POST | `/api/auth/logout` | Clear session | +| GET | `/api/auth/me` | Current user | +| GET | `/api/domains` | List domains (admin) | +| GET | `/api/domains/{domain}` | Domain settings (admin) | +| PATCH | `/api/domains/{domain}` | Update lists/spam fields (admin) | +| GET/POST | `/api/users` | List / create users (admin) | + +## License + +See [LICENSE](LICENSE). diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..ea71d8d --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,79 @@ +package main + +import ( + "embed" + "io/fs" + "log" + "net/http" + "strings" + + "github.com/squid/MigaduAdmin/internal/api" + "github.com/squid/MigaduAdmin/internal/auth" + "github.com/squid/MigaduAdmin/internal/config" + "github.com/squid/MigaduAdmin/internal/migadu" + "github.com/squid/MigaduAdmin/internal/store" +) + +//go:embed all:ui +var uiEmbed embed.FS + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("config: %v", err) + } + + st, err := store.Open(cfg.DatabasePath) + if err != nil { + log.Fatalf("database: %v", err) + } + defer st.Close() + + authService := auth.NewService(st, cfg) + migaduClient := migadu.NewClient(cfg.MigaduBaseURL, cfg.MigaduUser, cfg.MigaduAPIKey) + server := api.New(cfg, authService, st, migaduClient) + + staticHandler, err := spaHandler() + if err != nil { + log.Fatalf("ui assets: %v", err) + } + + handler := server.Router(staticHandler) + log.Printf("MigaduAdmin listening on %s", cfg.ListenAddr) + log.Printf( + "Migadu credentials: user=%q api_key=%s base=%s", + cfg.MigaduUser, + cfg.MaskedMigaduKey(), + cfg.MigaduBaseURL, + ) + if err := http.ListenAndServe(cfg.ListenAddr, handler); err != nil { + log.Fatalf("server: %v", err) + } +} + +func spaHandler() (http.Handler, error) { + sub, err := fs.Sub(uiEmbed, "ui") + if err != nil { + return nil, err + } + + fileServer := http.FileServer(http.FS(sub)) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/") + if path == "" { + path = "index.html" + } + if _, err := fs.Stat(sub, path); err == nil && !strings.HasSuffix(path, "/") { + fileServer.ServeHTTP(w, r) + return + } + // SPA fallback for client-side routes + index, err := fs.ReadFile(sub, "index.html") + if err != nil { + http.Error(w, "ui not available", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(index) + }), nil +} diff --git a/cmd/server/ui/assets/index-BWmBGRQb.js b/cmd/server/ui/assets/index-BWmBGRQb.js new file mode 100644 index 0000000..634d0d1 --- /dev/null +++ b/cmd/server/ui/assets/index-BWmBGRQb.js @@ -0,0 +1,11 @@ +var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;li[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},c=(n,r,a)=>(a=n==null?{}:e(i(n)),s(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var l=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.consumer`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.for(`react.activity`),p=Symbol.iterator;function m(e){return typeof e!=`object`||!e?null:(e=p&&e[p]||e[`@@iterator`],typeof e==`function`?e:null)}var h={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},g=Object.assign,_={};function v(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}v.prototype.isReactComponent={},v.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},v.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function y(){}y.prototype=v.prototype;function b(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}var x=b.prototype=new y;x.constructor=b,g(x,v.prototype),x.isPureReactComponent=!0;var ee=Array.isArray;function S(){}var C={H:null,A:null,T:null,S:null},te=Object.prototype.hasOwnProperty;function ne(e,n,r){var i=r.ref;return{$$typeof:t,type:e,key:n,ref:i===void 0?null:i,props:r}}function re(e,t){return ne(e.type,t,e.props)}function w(e){return typeof e==`object`&&!!e&&e.$$typeof===t}function T(e){var t={"=":`=0`,":":`=2`};return`$`+e.replace(/[=:]/g,function(e){return t[e]})}var ie=/\/+/g;function ae(e,t){return typeof e==`object`&&e&&e.key!=null?T(``+e.key):t.toString(36)}function oe(e){switch(e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason;default:switch(typeof e.status==`string`?e.then(S,S):(e.status=`pending`,e.then(function(t){e.status===`pending`&&(e.status=`fulfilled`,e.value=t)},function(t){e.status===`pending`&&(e.status=`rejected`,e.reason=t)})),e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason}}throw e}function se(e,r,i,a,o){var s=typeof e;(s===`undefined`||s===`boolean`)&&(e=null);var c=!1;if(e===null)c=!0;else switch(s){case`bigint`:case`string`:case`number`:c=!0;break;case`object`:switch(e.$$typeof){case t:case n:c=!0;break;case d:return c=e._init,se(c(e._payload),r,i,a,o)}}if(c)return o=o(e),c=a===``?`.`+ae(e,0):a,ee(o)?(i=``,c!=null&&(i=c.replace(ie,`$&/`)+`/`),se(o,r,i,``,function(e){return e})):o!=null&&(w(o)&&(o=re(o,i+(o.key==null||e&&e.key===o.key?``:(``+o.key).replace(ie,`$&/`)+`/`)+c)),r.push(o)),1;c=0;var l=a===``?`.`:a+`:`;if(ee(e))for(var u=0;u