From 95071da8883e341d86fd29ccebce9c9f63b184c9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 26 Sep 2026 01:26:33 +0000 Subject: [PATCH] feat(selfhosted): add a Firebase-free SMS gateway slice Serve local email/password auth, optional OIDC, and an httpsms-shaped send and webhook API that talks to SMS Gateway for Android on the LAN. Ship Postgres migrations, a Traefik compose file, and a small dashboard. Co-authored-by: Admin --- .env.example | 22 + .github/workflows/selfhosted.yml | 27 + .gitignore | 5 + README.md | 45 + docker-compose.selfhosted.yml | 74 ++ docs/godesk-sms-architecture.md | 66 ++ selfhosted/Dockerfile | 16 + selfhosted/cmd/godesk-sms/main.go | 123 +++ selfhosted/go.mod | 18 + selfhosted/go.sum | 32 + selfhosted/internal/auth/oidc.go | 140 +++ selfhosted/internal/auth/oidc_test.go | 52 + selfhosted/internal/auth/password.go | 17 + selfhosted/internal/auth/token.go | 138 +++ selfhosted/internal/gateway/client.go | 126 +++ selfhosted/internal/gateway/client_test.go | 69 ++ selfhosted/internal/httpapi/dashboard.html | 214 +++++ selfhosted/internal/httpapi/server.go | 890 ++++++++++++++++++ selfhosted/internal/httpapi/server_test.go | 288 ++++++ selfhosted/internal/model/model.go | 75 ++ selfhosted/internal/phone/phone.go | 20 + selfhosted/internal/secretbox/box.go | 70 ++ selfhosted/internal/secretbox/box_test.go | 24 + selfhosted/internal/store/memory/memory.go | 245 +++++ .../internal/store/pg/migrations/001_init.sql | 51 + selfhosted/internal/store/pg/pg.go | 320 +++++++ selfhosted/internal/store/pg/pg_test.go | 41 + selfhosted/internal/store/store.go | 36 + selfhosted/internal/urls/urls.go | 48 + selfhosted/internal/urls/urls_test.go | 23 + 30 files changed, 3315 insertions(+) create mode 100644 .env.example create mode 100644 .github/workflows/selfhosted.yml create mode 100644 docker-compose.selfhosted.yml create mode 100644 docs/godesk-sms-architecture.md create mode 100644 selfhosted/Dockerfile create mode 100644 selfhosted/cmd/godesk-sms/main.go create mode 100644 selfhosted/go.mod create mode 100644 selfhosted/go.sum create mode 100644 selfhosted/internal/auth/oidc.go create mode 100644 selfhosted/internal/auth/oidc_test.go create mode 100644 selfhosted/internal/auth/password.go create mode 100644 selfhosted/internal/auth/token.go create mode 100644 selfhosted/internal/gateway/client.go create mode 100644 selfhosted/internal/gateway/client_test.go create mode 100644 selfhosted/internal/httpapi/dashboard.html create mode 100644 selfhosted/internal/httpapi/server.go create mode 100644 selfhosted/internal/httpapi/server_test.go create mode 100644 selfhosted/internal/model/model.go create mode 100644 selfhosted/internal/phone/phone.go create mode 100644 selfhosted/internal/secretbox/box.go create mode 100644 selfhosted/internal/secretbox/box_test.go create mode 100644 selfhosted/internal/store/memory/memory.go create mode 100644 selfhosted/internal/store/pg/migrations/001_init.sql create mode 100644 selfhosted/internal/store/pg/pg.go create mode 100644 selfhosted/internal/store/pg/pg_test.go create mode 100644 selfhosted/internal/store/store.go create mode 100644 selfhosted/internal/urls/urls.go create mode 100644 selfhosted/internal/urls/urls_test.go diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..f58512b41 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# Copy to .env and replace every placeholder before starting the stack. +# Do not commit .env. None of these values are real credentials. + +POSTGRES_USER=godesk +POSTGRES_PASSWORD=change-me +POSTGRES_DB=godesk +DATABASE_URL=postgres://godesk:change-me@postgres:5432/godesk?sslmode=disable + +# At least 32 characters. Also used to seal device passwords unless CREDENTIALS_KEY is set. +JWT_SECRET=change-me-to-a-long-random-string +CREDENTIALS_KEY= + +# Public origin the phones can reach. HTTPS is required by SMS Gateway for Android +# except for http://127.0.0.1. Example: https://sms.example.com +PUBLIC_URL=http://godesk.localhost +GODESK_HOST=godesk.localhost + +# Optional Authentik (or any OIDC) login. Leave the issuer empty to use email and password only. +OIDC_ISSUER= +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +OIDC_REDIRECT_URL=http://godesk.localhost/v1/auth/oidc/callback diff --git a/.github/workflows/selfhosted.yml b/.github/workflows/selfhosted.yml new file mode 100644 index 000000000..907703a5a --- /dev/null +++ b/.github/workflows/selfhosted.yml @@ -0,0 +1,27 @@ +name: selfhosted + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Self-hosted unit tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: selfhosted/go.mod + + - name: Test + working-directory: selfhosted + run: go test ./... diff --git a/.gitignore b/.gitignore index cc68ad44b..03c77c3ce 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,11 @@ .env* !.env.docker !.env.production +!.env.example +certs/ +*.keystore +*.jks +*.p12 *serviceAccountKey.json android/app/debug/ *main.exe* diff --git a/README.md b/README.md index 42a6b0d36..f8e8d63cf 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,48 @@ +# GoDesk SMS + +GoDesk SMS is a self-hosted SMS gateway. The phone app for this self-hosted mode is [SMS Gateway for Android](https://github.com/capcom6/android-sms-gateway) (Apache-2.0). The original product in this repository is [httpSMS](https://github.com/NdoleStudio/httpsms) by Ndole Studio, licensed under the GNU Affero General Public License v3 (see [LICENSE](LICENSE)). The optional private server from [android-sms-gateway/server](https://github.com/android-sms-gateway/server) is Apache-2.0; this tree does not vendor that server. + +Self-hosted mode does not use Firebase. The design is in [docs/godesk-sms-architecture.md](docs/godesk-sms-architecture.md). The httpSMS README continues below for the existing Firebase deployment. + +## GoDesk SMS self-hosted quickstart + +1. Copy the example env and replace every `change-me` value. `JWT_SECRET` must be at least 32 characters. + +```bash +cp .env.example .env +``` + +2. Start the stack. Only Traefik publishes ports (80 and 443). Postgres and the API stay on the compose network. + +```bash +docker compose -f docker-compose.selfhosted.yml up --build +``` + +3. Open the dashboard at the host in `GODESK_HOST` (the example is `http://godesk.localhost`). Sign up, then register one SMS Gateway for Android phone in **local server** mode: + + - On the phone, turn on Local Server and start it. + - Copy the origin, username, and password into the device form. The origin is an `http` or `https` URL with no path, for example `https://phone.example:8080`. + - The API registers webhooks on the phone. `PUBLIC_URL` must be an address the phone can reach. The official app requires HTTPS except for `http://127.0.0.1`. + +4. Send a message with the dashboard or with the httpsms-shaped API: + +```bash +curl -X POST "$PUBLIC_URL/v1/messages/send" \ + -H "Content-Type: application/json" \ + -H "x-api-key: $API_KEY" \ + -d '{"from":"+18005550199","to":"+18005550100","content":"hello"}' +``` + +`from` selects the registered device. Inbound SMS from the phone is delivered to webhooks subscribed to `message.phone.received`. + +Optional OIDC (Authentik): set `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, and `OIDC_REDIRECT_URL`. The redirect URL is `{PUBLIC_URL}/v1/auth/oidc/callback`. Leave `OIDC_ISSUER` empty to use email and password only. + +Run the unit tests from `selfhosted/`: + +```bash +cd selfhosted && go test ./... +``` + # httpSMS [![Web](https://github.com/NdoleStudio/httpsms/actions/workflows/web.yml/badge.svg)](https://github.com/NdoleStudio/httpsms/actions/workflows/web.yml) diff --git a/docker-compose.selfhosted.yml b/docker-compose.selfhosted.yml new file mode 100644 index 000000000..0a4b1b04c --- /dev/null +++ b/docker-compose.selfhosted.yml @@ -0,0 +1,74 @@ +# GoDesk SMS self-hosted stack. +# Postgres and the API are reachable only on the compose network. +# Traefik publishes ports 80 and 443. + +name: godesk-sms + +services: + traefik: + image: traefik:v3.6 + command: + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + ports: + - "80:80" + - "443:443" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + restart: unless-stopped + + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER:?Set POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB:?Set POSTGRES_DB} + volumes: + - godesk_postgres:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 5s + timeout: 5s + retries: 20 + restart: unless-stopped + + api: + build: + context: ./selfhosted + environment: + HTTP_ADDR: ":8080" + DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL} + JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET} + CREDENTIALS_KEY: ${CREDENTIALS_KEY:-} + PUBLIC_URL: ${PUBLIC_URL:?Set PUBLIC_URL} + OIDC_ISSUER: ${OIDC_ISSUER:-} + OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} + OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-} + OIDC_REDIRECT_URL: ${OIDC_REDIRECT_URL:-} + depends_on: + postgres: + condition: service_healthy + labels: + - traefik.enable=true + - traefik.http.routers.godesk.rule=Host(`${GODESK_HOST:?Set GODESK_HOST}`) + - traefik.http.routers.godesk.entrypoints=web + - traefik.http.routers.godesk.service=godesk + - traefik.http.routers.godesk-secure.rule=Host(`${GODESK_HOST}`) + - traefik.http.routers.godesk-secure.entrypoints=websecure + - traefik.http.routers.godesk-secure.tls=true + - traefik.http.routers.godesk-secure.service=godesk + - traefik.http.services.godesk.loadbalancer.server.port=8080 + - traefik.http.services.godesk.loadbalancer.healthcheck.path=/health + - traefik.http.services.godesk.loadbalancer.healthcheck.interval=10s + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/health"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 10s + restart: unless-stopped + +volumes: + godesk_postgres: diff --git a/docs/godesk-sms-architecture.md b/docs/godesk-sms-architecture.md new file mode 100644 index 000000000..445501321 --- /dev/null +++ b/docs/godesk-sms-architecture.md @@ -0,0 +1,66 @@ +# GoDesk SMS architecture + +GoDesk SMS is a self-hosted SMS gateway. It keeps the httpSMS send, receive, and webhook ideas, and it uses [SMS Gateway for Android](https://github.com/capcom6/android-sms-gateway) as the phone app. The hard requirement is no Firebase and no other Google service. + +## Option A โ€” official private server + +Run [android-sms-gateway/server](https://github.com/android-sms-gateway/server) in private mode and point the Android app at it. + +What it already does: + +- Device registration protected by a private token +- The same 3rdparty REST API as the public SMSGate cloud +- Multi-device routing, message state, and webhooks +- A separate web dashboard image + +What it does not do: + +- The private server still wakes phones by relaying a push through `api.sms-gate.app` and Firebase Cloud Messaging. The project documents an SSE path, but the supported install still depends on that upstream for push. That fails the "no Firebase or Google services" rule. +- Storage is MariaDB or MySQL, not Postgres. +- The API is the SMSGate API (`/api/3rdparty/v1/...`), not the httpsms API (`/v1/messages/send`, `x-api-key`, `message.phone.received` webhooks). +- The httpSMS Nuxt dashboard signs in with Firebase. It cannot talk to this server without a new auth layer and a new client. + +## Option B โ€” GoDesk API talks to the phones + +Keep a Go API, Postgres, and an httpsms-compatible edge. Replace Firebase Auth with local accounts, and replace FCM by calling SMS Gateway for Android directly. + +Two phone modes exist: + +- **Local server mode** runs an HTTP server on the phone. The API sends SMS with HTTP basic auth and receives `sms:received` webhooks from the phone. No Firebase, no Google, no public upstream. +- **Private server mode** keeps message text on a server you run, but the official server still uses the public push relay. A later polling or SSE channel could remove that relay. It is not the path that is free of Google services today. + +## Decision + +Option B, with **local server mode** as the device integration. + +Local mode is the only supported SMS Gateway mode that is fully off Firebase. The GoDesk service then keeps the pieces this deployment needs: Postgres, Traefik on the LAN, more than one phone, the httpsms send/receive/webhook shape, and email/password auth with optional OIDC (Authentik). + +The tradeoff is reachability. The API must be able to open a connection to each phone, and each phone must be able to POST webhooks back. Phones that are not reachable inbound cannot send until a later polling design exists. That limitation is accepted for this slice because it is what makes the system independent of Google. + +The existing httpSMS API and Nuxt app stay in the tree for the current Firebase deployment. They are not started by `docker-compose.selfhosted.yml`. + +## What this slice runs + +`docker-compose.selfhosted.yml` starts: + +- Traefik, publishing only ports 80 and 443 +- Postgres 16, with no published port +- `selfhosted/`, a Go service + +On startup the service applies `selfhosted/internal/store/pg/migrations`. Accounts are email and password (bcrypt). Sessions are HS256 JWTs. Each user also has an `x-api-key` for httpsms-style clients. When `OIDC_ISSUER` is set, `/v1/auth/oidc/start` performs an authorization-code login with PKCE against that issuer. + +A device row stores the phone's E.164 number, the gateway origin, basic-auth username, and the password sealed with AES-GCM. The key comes from `CREDENTIALS_KEY`, or from `JWT_SECRET` when that is unset. Registering a device asks the phone to webhook `sms:received`, `sms:sent`, `sms:delivered`, and `sms:failed` to `PUBLIC_URL/v1/gateway/events/{token}`. + +`POST /v1/messages/send` with `from`, `to`, and `content` selects the device whose phone number matches `from` and POSTs to the phone's `/message` endpoint (local) or `/api/3rdparty/v1/messages` (private URL shape). `POST /v1/gateway/events/{token}` stores an inbound SMS and POSTs a CloudEvents JSON body of type `message.phone.received` to the user's webhooks, with `X-Event-Type` set. A signing key produces an `Authorization: Bearer` JWT whose audience is the webhook URL. + +The same process serves a small dashboard at `/` for sign-up, login, device registration, send, and webhooks. `http://godesk.localhost` is the generic host used in the example env file. Replace it before exposing the stack. + +## Remaining work + +- Port the Nuxt app in `web/` off Firebase and onto this login, and retire the small dashboard. +- Wake phones that cannot accept inbound connections, without FCM. That is device polling or SSE, not the official private-server push relay. +- Finish private-server mode as a first-class deploy (the client already knows the URL shape). +- httpsms features not in this slice: bulk send, MMS, end-to-end encryption, search, heartbeats, schedules, and the full webhook event set. +- Retry and signing parity for every outbound webhook. +- Store API keys as hashes and show them once. +- Bring your own TLS material for Traefik. Port 443 currently uses Traefik's default certificate. diff --git a/selfhosted/Dockerfile b/selfhosted/Dockerfile new file mode 100644 index 000000000..988d96d34 --- /dev/null +++ b/selfhosted/Dockerfile @@ -0,0 +1,16 @@ +FROM golang:1.23-alpine AS build + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /godesk-sms ./cmd/godesk-sms + +FROM alpine:3.22 + +RUN apk add --no-cache ca-certificates wget \ + && adduser -D -H -u 65532 godesk +COPY --from=build /godesk-sms /godesk-sms +USER godesk +EXPOSE 8080 +ENTRYPOINT ["/godesk-sms"] diff --git a/selfhosted/cmd/godesk-sms/main.go b/selfhosted/cmd/godesk-sms/main.go new file mode 100644 index 000000000..f2e5db618 --- /dev/null +++ b/selfhosted/cmd/godesk-sms/main.go @@ -0,0 +1,123 @@ +package main + +import ( + "context" + "errors" + "log" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/auth" + "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/httpapi" + "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/secretbox" + "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/store/pg" +) + +func main() { + cfg, err := loadConfig() + if err != nil { + log.Fatal(err) + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + database, err := pg.Open(ctx, cfg.databaseURL) + if err != nil { + log.Fatal(err) + } + defer database.Close() + + tokens, err := auth.NewTokens(cfg.jwtSecret) + if err != nil { + log.Fatal(err) + } + boxSecret := cfg.credentialsKey + if boxSecret == "" { + boxSecret = cfg.jwtSecret + } + box, err := secretbox.New(boxSecret) + if err != nil { + log.Fatal(err) + } + var oidc *auth.OIDC + if cfg.oidcIssuer != "" { + oidc = &auth.OIDC{ + Issuer: cfg.oidcIssuer, + ClientID: cfg.oidcClientID, + ClientSecret: cfg.oidcClientSecret, + RedirectURL: cfg.oidcRedirectURL, + } + } + api := httpapi.New(httpapi.Server{ + Store: database, + Tokens: tokens, + Box: box, + PublicURL: cfg.publicURL, + OIDC: oidc, + }) + server := &http.Server{ + Addr: cfg.httpAddr, + Handler: api.Handler(), + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 60 * time.Second, + } + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = server.Shutdown(shutdownCtx) + }() + log.Printf("godesk sms listening on %s", cfg.httpAddr) + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatal(err) + } +} + +type config struct { + httpAddr string + databaseURL string + jwtSecret string + credentialsKey string + publicURL string + oidcIssuer string + oidcClientID string + oidcClientSecret string + oidcRedirectURL string +} + +func loadConfig() (config, error) { + cfg := config{ + httpAddr: env("HTTP_ADDR", ":8080"), + databaseURL: os.Getenv("DATABASE_URL"), + jwtSecret: os.Getenv("JWT_SECRET"), + credentialsKey: os.Getenv("CREDENTIALS_KEY"), + publicURL: env("PUBLIC_URL", "http://godesk.localhost"), + oidcIssuer: os.Getenv("OIDC_ISSUER"), + oidcClientID: os.Getenv("OIDC_CLIENT_ID"), + oidcClientSecret: os.Getenv("OIDC_CLIENT_SECRET"), + oidcRedirectURL: os.Getenv("OIDC_REDIRECT_URL"), + } + if cfg.databaseURL == "" { + return config{}, errors.New("DATABASE_URL is required") + } + if len(cfg.jwtSecret) < 32 { + return config{}, errors.New("JWT_SECRET must be at least 32 characters") + } + if cfg.oidcIssuer != "" && (cfg.oidcClientID == "" || cfg.oidcClientSecret == "" || cfg.oidcRedirectURL == "") { + return config{}, errors.New("OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, and OIDC_REDIRECT_URL are required when OIDC_ISSUER is set") + } + return cfg, nil +} + +func env(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} diff --git a/selfhosted/go.mod b/selfhosted/go.mod new file mode 100644 index 000000000..35a83b5d2 --- /dev/null +++ b/selfhosted/go.mod @@ -0,0 +1,18 @@ +module github.com/GoDeskio/GoDesk-SMS/selfhosted + +go 1.23.0 + +require ( + github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.7.4 + golang.org/x/crypto v0.36.0 +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/text v0.23.0 // indirect +) diff --git a/selfhosted/go.sum b/selfhosted/go.sum new file mode 100644 index 000000000..3b757e1b4 --- /dev/null +++ b/selfhosted/go.sum @@ -0,0 +1,32 @@ +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/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= +github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +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/selfhosted/internal/auth/oidc.go b/selfhosted/internal/auth/oidc.go new file mode 100644 index 000000000..ad287972d --- /dev/null +++ b/selfhosted/internal/auth/oidc.go @@ -0,0 +1,140 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// OIDC is an authorization-code client for an issuer such as Authentik. +type OIDC struct { + Issuer string + ClientID string + ClientSecret string + RedirectURL string + HTTP *http.Client +} + +type provider struct { + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + UserinfoEndpoint string `json:"userinfo_endpoint"` +} + +func (o *OIDC) client() *http.Client { + if o.HTTP != nil { + return o.HTTP + } + return &http.Client{Timeout: 15 * time.Second} +} + +func (o *OIDC) discover(ctx context.Context) (provider, error) { + endpoint := strings.TrimRight(o.Issuer, "/") + "/.well-known/openid-configuration" + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return provider{}, err + } + response, err := o.client().Do(request) + if err != nil { + return provider{}, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return provider{}, fmt.Errorf("oidc discovery returned %d", response.StatusCode) + } + var found provider + if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&found); err != nil { + return provider{}, err + } + if found.AuthorizationEndpoint == "" || found.TokenEndpoint == "" || found.UserinfoEndpoint == "" { + return provider{}, fmt.Errorf("oidc discovery document is incomplete") + } + return found, nil +} + +// AuthCodeURL returns the browser redirect for a login. +func (o *OIDC) AuthCodeURL(ctx context.Context, state, challenge string) (string, error) { + found, err := o.discover(ctx) + if err != nil { + return "", err + } + values := url.Values{} + values.Set("response_type", "code") + values.Set("client_id", o.ClientID) + values.Set("redirect_uri", o.RedirectURL) + values.Set("scope", "openid email") + values.Set("state", state) + values.Set("code_challenge", challenge) + values.Set("code_challenge_method", "S256") + return found.AuthorizationEndpoint + "?" + values.Encode(), nil +} + +// Exchange trades an authorization code for the user's email. +func (o *OIDC) Exchange(ctx context.Context, code, verifier string) (string, error) { + found, err := o.discover(ctx) + if err != nil { + return "", err + } + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("code", code) + form.Set("redirect_uri", o.RedirectURL) + form.Set("client_id", o.ClientID) + form.Set("code_verifier", verifier) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, found.TokenEndpoint, strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.SetBasicAuth(o.ClientID, o.ClientSecret) + response, err := o.client().Do(request) + if err != nil { + return "", err + } + defer response.Body.Close() + body, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + if response.StatusCode != http.StatusOK { + return "", fmt.Errorf("oidc token returned %d", response.StatusCode) + } + var token struct { + AccessToken string `json:"access_token"` + } + if err := json.Unmarshal(body, &token); err != nil { + return "", err + } + if token.AccessToken == "" { + return "", fmt.Errorf("oidc token response has no access_token") + } + infoReq, err := http.NewRequestWithContext(ctx, http.MethodGet, found.UserinfoEndpoint, nil) + if err != nil { + return "", err + } + infoReq.Header.Set("Authorization", "Bearer "+token.AccessToken) + infoResp, err := o.client().Do(infoReq) + if err != nil { + return "", err + } + defer infoResp.Body.Close() + if infoResp.StatusCode != http.StatusOK { + return "", fmt.Errorf("oidc userinfo returned %d", infoResp.StatusCode) + } + var info struct { + Email string `json:"email"` + EmailVerified *bool `json:"email_verified"` + } + if err := json.NewDecoder(io.LimitReader(infoResp.Body, 1<<20)).Decode(&info); err != nil { + return "", err + } + if info.Email == "" { + return "", fmt.Errorf("oidc userinfo has no email") + } + if info.EmailVerified != nil && !*info.EmailVerified { + return "", fmt.Errorf("oidc email is not verified") + } + return strings.ToLower(strings.TrimSpace(info.Email)), nil +} diff --git a/selfhosted/internal/auth/oidc_test.go b/selfhosted/internal/auth/oidc_test.go new file mode 100644 index 000000000..bd36987b3 --- /dev/null +++ b/selfhosted/internal/auth/oidc_test.go @@ -0,0 +1,52 @@ +package auth + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestOIDCExchange(t *testing.T) { + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/openid-configuration": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"authorization_endpoint":"` + server.URL + `/auth","token_endpoint":"` + server.URL + `/token","userinfo_endpoint":"` + server.URL + `/userinfo"}`)) + case "/token": + if user, pass, ok := r.BasicAuth(); !ok || user != "client" || pass != "secret" { + t.Fatalf("client auth %q %q", user, pass) + } + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + if r.Form.Get("code") != "code-1" || r.Form.Get("code_verifier") == "" { + t.Fatalf("form %v", r.Form) + } + _, _ = w.Write([]byte(`{"access_token":"access-1"}`)) + case "/userinfo": + if r.Header.Get("Authorization") != "Bearer access-1" { + t.Fatalf("userinfo auth %s", r.Header.Get("Authorization")) + } + _, _ = w.Write([]byte(`{"email":"Owner@Example.com","email_verified":true}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + client := &OIDC{ + Issuer: server.URL, + ClientID: "client", + ClientSecret: "secret", + RedirectURL: "http://godesk.test/v1/auth/oidc/callback", + } + email, err := client.Exchange(context.Background(), "code-1", "verifier") + if err != nil { + t.Fatal(err) + } + if email != "owner@example.com" { + t.Fatalf("email %s", email) + } +} diff --git a/selfhosted/internal/auth/password.go b/selfhosted/internal/auth/password.go new file mode 100644 index 000000000..b36e76833 --- /dev/null +++ b/selfhosted/internal/auth/password.go @@ -0,0 +1,17 @@ +package auth + +import "golang.org/x/crypto/bcrypt" + +// HashPassword hashes a password with the given bcrypt cost. +func HashPassword(password string, cost int) (string, error) { + hashed, err := bcrypt.GenerateFromPassword([]byte(password), cost) + if err != nil { + return "", err + } + return string(hashed), nil +} + +// CheckPassword reports whether password matches the bcrypt hash. +func CheckPassword(hash, password string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} diff --git a/selfhosted/internal/auth/token.go b/selfhosted/internal/auth/token.go new file mode 100644 index 000000000..b2e894ee3 --- /dev/null +++ b/selfhosted/internal/auth/token.go @@ -0,0 +1,138 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// Tokens issues session JWTs and signs outbound webhooks. +type Tokens struct { + secret []byte +} + +// NewTokens rejects secrets shorter than 32 characters. +func NewTokens(secret string) (*Tokens, error) { + if len(secret) < 32 { + return nil, errors.New("JWT_SECRET must be at least 32 characters") + } + return &Tokens{secret: []byte(secret)}, nil +} + +type sessionClaims struct { + Email string `json:"email"` + jwt.RegisteredClaims +} + +type oidcStateClaims struct { + Verifier string `json:"verifier"` + jwt.RegisteredClaims +} + +// SessionToken is a bearer token for the dashboard and API. +func (t *Tokens) SessionToken(userID, email string) (string, error) { + now := time.Now().UTC() + claims := sessionClaims{ + Email: email, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: userID, + Issuer: "godesk-sms", + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(7 * 24 * time.Hour)), + }, + } + return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(t.secret) +} + +// ParseSession returns the user id and email from a session token. +func (t *Tokens) ParseSession(token string) (string, string, error) { + parsed, err := jwt.ParseWithClaims(token, &sessionClaims{}, func(token *jwt.Token) (any, error) { + if token.Method != jwt.SigningMethodHS256 { + return nil, fmt.Errorf("unexpected signing method %v", token.Header["alg"]) + } + return t.secret, nil + }) + if err != nil { + return "", "", err + } + claims, ok := parsed.Claims.(*sessionClaims) + if !ok || !parsed.Valid || claims.Subject == "" { + return "", "", errors.New("invalid session") + } + return claims.Subject, claims.Email, nil +} + +// WebhookToken matches the httpsms webhook Authorization bearer: HS256, audience +// is the webhook URL, subject is the user id. The issuer is godesk-sms. +func (t *Tokens) WebhookToken(userID, audience, signingKey string) (string, error) { + now := time.Now().UTC() + claims := jwt.RegisteredClaims{ + Audience: []string{audience}, + ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)), + IssuedAt: jwt.NewNumericDate(now), + Issuer: "godesk-sms", + NotBefore: jwt.NewNumericDate(now.Add(-10 * time.Minute)), + Subject: userID, + } + return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(signingKey)) +} + +// OIDCStateCookie seals the PKCE verifier for the callback. +func (t *Tokens) OIDCStateCookie(state, verifier string) (string, error) { + now := time.Now().UTC() + claims := oidcStateClaims{ + Verifier: verifier, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: state, + Issuer: "godesk-sms", + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)), + }, + } + return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(t.secret) +} + +// ParseOIDCState returns the state and PKCE verifier. +func (t *Tokens) ParseOIDCState(token string) (string, string, error) { + parsed, err := jwt.ParseWithClaims(token, &oidcStateClaims{}, func(token *jwt.Token) (any, error) { + if token.Method != jwt.SigningMethodHS256 { + return nil, fmt.Errorf("unexpected signing method %v", token.Header["alg"]) + } + return t.secret, nil + }) + if err != nil { + return "", "", err + } + claims, ok := parsed.Claims.(*oidcStateClaims) + if !ok || !parsed.Valid { + return "", "", errors.New("invalid oidc state") + } + return claims.Subject, claims.Verifier, nil +} + +// RandomToken returns a hex secret. +func RandomToken(bytes int) (string, error) { + buf := make([]byte, bytes) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + +// PKCE returns a verifier and S256 challenge. +func PKCE() (verifier, challenge string, err error) { + raw := make([]byte, 32) + if _, err = rand.Read(raw); err != nil { + return "", "", err + } + verifier = base64.RawURLEncoding.EncodeToString(raw) + sum := sha256.Sum256([]byte(verifier)) + challenge = base64.RawURLEncoding.EncodeToString(sum[:]) + return verifier, challenge, nil +} diff --git a/selfhosted/internal/gateway/client.go b/selfhosted/internal/gateway/client.go new file mode 100644 index 000000000..bda0461f7 --- /dev/null +++ b/selfhosted/internal/gateway/client.go @@ -0,0 +1,126 @@ +package gateway + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// Device is the subset of a handset the gateway client needs. +type Device struct { + Mode string + BaseURL string + Username string + Password string +} + +// Client calls SMS Gateway for Android. Local mode uses the on-phone server. +// Private mode uses the self-hosted server's 3rdparty API paths. +type Client struct { + HTTP *http.Client +} + +// New returns a client that does not follow redirects, so basic-auth credentials +// stay on the device URL the operator registered. +func New() *Client { + return &Client{HTTP: &http.Client{ + Timeout: 15 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + }} +} + +// SendSMS posts one text message. sim is 1 or 2. +func (c *Client) SendSMS(ctx context.Context, device Device, to, text string, sim int) (string, error) { + payload := map[string]any{ + "textMessage": map[string]string{"text": text}, + "phoneNumbers": []string{to}, + "simNumber": sim, + } + var decoded struct { + ID string `json:"id"` + } + if err := c.post(ctx, device, sendPath(device.Mode), payload, &decoded); err != nil { + return "", err + } + return decoded.ID, nil +} + +// RegisterWebhooks asks the gateway to POST sms events to callback. +func (c *Client) RegisterWebhooks(ctx context.Context, device Device, callback string) error { + for _, event := range []string{"sms:received", "sms:sent", "sms:delivered", "sms:failed"} { + body := map[string]string{"url": callback, "event": event} + if err := c.post(ctx, device, webhookPath(device.Mode), body, nil); err != nil { + return fmt.Errorf("register %s: %w", event, err) + } + } + return nil +} + +func (c *Client) post(ctx context.Context, device Device, path string, payload any, dest any) error { + raw, err := json.Marshal(payload) + if err != nil { + return err + } + endpoint, err := join(device.BaseURL, path) + if err != nil { + return err + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(raw)) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + request.SetBasicAuth(device.Username, device.Password) + response, err := c.HTTP.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + body, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + if response.StatusCode == http.StatusConflict { + return nil + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("gateway %s returned %d", path, response.StatusCode) + } + if dest == nil || len(bytes.TrimSpace(body)) == 0 { + return nil + } + if err := json.Unmarshal(body, dest); err != nil { + return fmt.Errorf("decode gateway response: %w", err) + } + return nil +} + +func sendPath(mode string) string { + if mode == "private" { + return "/api/3rdparty/v1/messages" + } + return "/message" +} + +func webhookPath(mode string) string { + if mode == "private" { + return "/api/3rdparty/v1/webhooks" + } + return "/webhooks" +} + +func join(base, path string) (string, error) { + parsed, err := url.Parse(strings.TrimRight(base, "/")) + if err != nil { + return "", err + } + parsed.Path = path + parsed.RawQuery = "" + parsed.Fragment = "" + return parsed.String(), nil +} diff --git a/selfhosted/internal/gateway/client_test.go b/selfhosted/internal/gateway/client_test.go new file mode 100644 index 000000000..07d979cc4 --- /dev/null +++ b/selfhosted/internal/gateway/client_test.go @@ -0,0 +1,69 @@ +package gateway + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestSendAndRegisterLocal(t *testing.T) { + var sawSend, sawHook bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + if !ok || user != "sms" || pass != "secret" { + t.Errorf("basic auth %q %q ok=%v", user, pass, ok) + } + body, _ := io.ReadAll(r.Body) + switch r.URL.Path { + case "/message": + sawSend = true + var payload struct { + PhoneNumbers []string `json:"phoneNumbers"` + TextMessage struct { + Text string `json:"text"` + } `json:"textMessage"` + SimNumber int `json:"simNumber"` + } + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatal(err) + } + if payload.TextMessage.Text != "hello" || payload.PhoneNumbers[0] != "+18005550100" || payload.SimNumber != 1 { + t.Fatalf("payload %+v", payload) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"gw-1","state":"Pending"}`)) + case "/webhooks": + sawHook = true + w.WriteHeader(http.StatusCreated) + default: + t.Errorf("unexpected path %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + client := New() + device := Device{Mode: "local", BaseURL: server.URL, Username: "sms", Password: "secret"} + if err := client.RegisterWebhooks(context.Background(), device, "https://godesk.example/v1/gateway/events/token"); err != nil { + t.Fatal(err) + } + id, err := client.SendSMS(context.Background(), device, "+18005550100", "hello", 1) + if err != nil { + t.Fatal(err) + } + if id != "gw-1" || !sawSend || !sawHook { + t.Fatalf("id %q send %v hook %v", id, sawSend, sawHook) + } +} + +func TestPrivatePaths(t *testing.T) { + if sendPath("private") != "/api/3rdparty/v1/messages" { + t.Fatal(sendPath("private")) + } + if webhookPath("local") != "/webhooks" { + t.Fatal(webhookPath("local")) + } +} diff --git a/selfhosted/internal/httpapi/dashboard.html b/selfhosted/internal/httpapi/dashboard.html new file mode 100644 index 000000000..e207b501a --- /dev/null +++ b/selfhosted/internal/httpapi/dashboard.html @@ -0,0 +1,214 @@ + + + + + + GoDesk SMS + + + +

GoDesk SMS

+
+
+

Account

+

Local email and password. No Firebase. The API key is the x-api-key used by httpsms-compatible clients.

+
+
+ + +
+ + +
+
+
+

+ +
+ +
+ + + diff --git a/selfhosted/internal/httpapi/server.go b/selfhosted/internal/httpapi/server.go new file mode 100644 index 000000000..4918283d7 --- /dev/null +++ b/selfhosted/internal/httpapi/server.go @@ -0,0 +1,890 @@ +package httpapi + +import ( + "bytes" + "context" + _ "embed" + "encoding/json" + "errors" + "io" + "log" + "net/http" + "strings" + "time" + + "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/auth" + "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/gateway" + "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/model" + "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/phone" + "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/secretbox" + "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/store" + "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/urls" + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +//go:embed dashboard.html +var dashboardHTML []byte + +// Server is the self-hosted GoDesk HTTP API and dashboard. +type Server struct { + Store store.Store + Tokens *auth.Tokens + Box *secretbox.Box + Gateway *gateway.Client + PublicURL string + OIDC *auth.OIDC + BcryptCost int + Now func() time.Time + HTTP *http.Client +} + +// New fills defaults and returns a server. +func New(s Server) *Server { + if s.BcryptCost == 0 { + s.BcryptCost = bcrypt.DefaultCost + } + if s.Now == nil { + s.Now = func() time.Time { return time.Now().UTC() } + } + if s.Gateway == nil { + s.Gateway = gateway.New() + } + if s.HTTP == nil { + s.HTTP = &http.Client{Timeout: 5 * time.Second} + } + s.PublicURL = strings.TrimRight(s.PublicURL, "/") + return &s +} + +// Handler is the public HTTP handler. +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /{$}", s.dashboard) + mux.HandleFunc("GET /health", s.health) + mux.HandleFunc("POST /v1/auth/signup", s.signup) + mux.HandleFunc("POST /v1/auth/login", s.login) + mux.HandleFunc("GET /v1/auth/oidc/start", s.oidcStart) + mux.HandleFunc("GET /v1/auth/oidc/callback", s.oidcCallback) + mux.HandleFunc("GET /v1/users/me", s.me) + mux.HandleFunc("POST /v1/devices", s.createDevice) + mux.HandleFunc("GET /v1/devices", s.listDevices) + mux.HandleFunc("DELETE /v1/devices/{id}", s.deleteDevice) + mux.HandleFunc("POST /v1/devices/{id}/webhooks", s.registerDeviceWebhooks) + mux.HandleFunc("POST /v1/messages/send", s.sendMessage) + mux.HandleFunc("GET /v1/messages", s.listMessages) + mux.HandleFunc("POST /v1/webhooks", s.createWebhook) + mux.HandleFunc("GET /v1/webhooks", s.listWebhooks) + mux.HandleFunc("DELETE /v1/webhooks/{id}", s.deleteWebhook) + mux.HandleFunc("POST /v1/gateway/events/{token}", s.gatewayEvent) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, x-api-key") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + mux.ServeHTTP(w, r) + }) +} + +func (s *Server) dashboard(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(dashboardHTML) +} + +func (s *Server) health(w http.ResponseWriter, r *http.Request) { + if err := s.Store.Ping(r.Context()); err != nil { + writeError(w, http.StatusServiceUnavailable, "database unavailable") + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) signup(w http.ResponseWriter, r *http.Request) { + var body struct { + Email string `json:"email"` + Password string `json:"password"` + } + if err := readJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "request body must be JSON") + return + } + s.writeSession(w, r, body.Email, body.Password, true) +} + +func (s *Server) login(w http.ResponseWriter, r *http.Request) { + var body struct { + Email string `json:"email"` + Password string `json:"password"` + } + if err := readJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "request body must be JSON") + return + } + s.writeSession(w, r, body.Email, body.Password, false) +} + +func (s *Server) writeSession(w http.ResponseWriter, r *http.Request, email, password string, create bool) { + normalized, err := normalizeEmail(email) + if err != nil { + writeError(w, http.StatusUnprocessableEntity, err.Error()) + return + } + if len(password) < 8 || len(password) > 200 { + writeError(w, http.StatusUnprocessableEntity, "password must be 8 to 200 characters") + return + } + var user model.User + if create { + hash, err := auth.HashPassword(password, s.BcryptCost) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not hash password") + return + } + apiKey, err := auth.RandomToken(32) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not create api key") + return + } + user = model.User{ + ID: uuid.NewString(), + Email: normalized, + PasswordHash: hash, + APIKey: apiKey, + CreatedAt: s.Now(), + } + if err := s.Store.CreateUser(r.Context(), user); err != nil { + if errors.Is(err, store.ErrConflict) { + writeError(w, http.StatusConflict, "an account with that email already exists") + return + } + writeError(w, http.StatusInternalServerError, "could not create account") + return + } + } else { + user, err = s.Store.UserByEmail(r.Context(), normalized) + if err != nil || !auth.CheckPassword(user.PasswordHash, password) { + writeError(w, http.StatusUnauthorized, "email or password is incorrect") + return + } + } + s.writeAuth(w, http.StatusOK, user) +} + +func (s *Server) writeAuth(w http.ResponseWriter, status int, user model.User) { + token, err := s.Tokens.SessionToken(user.ID, user.Email) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not create session") + return + } + writeJSON(w, status, map[string]any{ + "status": "success", + "message": "authenticated", + "data": map[string]any{ + "token": token, + "api_key": user.APIKey, + "user": map[string]string{ + "id": user.ID, + "email": user.Email, + }, + }, + }) +} + +func (s *Server) me(w http.ResponseWriter, r *http.Request) { + user, ok := s.currentUser(r) + if !ok { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "status": "success", + "message": "user", + "data": map[string]any{ + "api_key": user.APIKey, + "user": map[string]string{ + "id": user.ID, + "email": user.Email, + }, + }, + }) +} + +func (s *Server) createDevice(w http.ResponseWriter, r *http.Request) { + user, ok := s.currentUser(r) + if !ok { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + var body struct { + Name string `json:"name"` + PhoneNumber string `json:"phone_number"` + BaseURL string `json:"base_url"` + Username string `json:"username"` + Password string `json:"password"` + Mode string `json:"mode"` + } + if err := readJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "request body must be JSON") + return + } + device, note, err := s.buildDevice(r.Context(), user.ID, body.Name, body.PhoneNumber, body.BaseURL, body.Username, body.Password, body.Mode) + if err != nil { + writeError(w, http.StatusUnprocessableEntity, err.Error()) + return + } + if err := s.Store.CreateDevice(r.Context(), device); err != nil { + if errors.Is(err, store.ErrConflict) { + writeError(w, http.StatusConflict, "a device with that phone number is already registered") + return + } + writeError(w, http.StatusInternalServerError, "could not save device") + return + } + writeJSON(w, http.StatusCreated, map[string]any{ + "status": "success", + "message": "device registered", + "data": s.deviceJSON(device, note), + }) +} + +func (s *Server) buildDevice(ctx context.Context, userID, name, phoneNumber, baseURL, username, password, mode string) (model.Device, string, error) { + name = strings.TrimSpace(name) + if name == "" || len(name) > 80 { + return model.Device{}, "", errors.New("name is required") + } + number, err := phone.Normalize(phoneNumber) + if err != nil { + return model.Device{}, "", err + } + origin, err := urls.NormalizeBaseURL(baseURL, false) + if err != nil { + return model.Device{}, "", err + } + username = strings.TrimSpace(username) + if username == "" || password == "" { + return model.Device{}, "", errors.New("gateway username and password are required") + } + if mode == "" { + mode = model.ModeLocal + } + if mode != model.ModeLocal && mode != model.ModePrivate { + return model.Device{}, "", errors.New("mode must be local or private") + } + sealed, err := s.Box.Seal(password) + if err != nil { + return model.Device{}, "", errors.New("could not protect the gateway password") + } + token, err := auth.RandomToken(32) + if err != nil { + return model.Device{}, "", errors.New("could not create an ingest token") + } + device := model.Device{ + ID: uuid.NewString(), + UserID: userID, + Name: name, + PhoneNumber: number, + BaseURL: origin, + Username: username, + Password: sealed, + Mode: mode, + IngestToken: token, + CreatedAt: s.Now(), + } + note := "ok" + if err := s.Gateway.RegisterWebhooks(ctx, gateway.Device{ + Mode: device.Mode, BaseURL: device.BaseURL, Username: username, Password: password, + }, s.ingestURL(token)); err != nil { + note = err.Error() + log.Printf("device webhook registration failed for %s: %s", device.ID, note) + } + return device, note, nil +} + +func (s *Server) listDevices(w http.ResponseWriter, r *http.Request) { + user, ok := s.currentUser(r) + if !ok { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + devices, err := s.Store.ListDevices(r.Context(), user.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not list devices") + return + } + out := make([]map[string]any, 0, len(devices)) + for _, device := range devices { + out = append(out, s.deviceJSON(device, "")) + } + writeJSON(w, http.StatusOK, map[string]any{"status": "success", "message": "devices", "data": out}) +} + +func (s *Server) deleteDevice(w http.ResponseWriter, r *http.Request) { + user, ok := s.currentUser(r) + if !ok { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + if err := s.Store.DeleteDevice(r.Context(), user.ID, r.PathValue("id")); err != nil { + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "device not found") + return + } + writeError(w, http.StatusInternalServerError, "could not delete device") + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "success", "message": "device deleted"}) +} + +func (s *Server) registerDeviceWebhooks(w http.ResponseWriter, r *http.Request) { + user, ok := s.currentUser(r) + if !ok { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + device, err := s.Store.DeviceByID(r.Context(), user.ID, r.PathValue("id")) + if err != nil { + writeError(w, http.StatusNotFound, "device not found") + return + } + password, err := s.Box.Open(device.Password) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not read the gateway password") + return + } + note := "ok" + if err := s.Gateway.RegisterWebhooks(r.Context(), gateway.Device{ + Mode: device.Mode, BaseURL: device.BaseURL, Username: device.Username, Password: password, + }, s.ingestURL(device.IngestToken)); err != nil { + note = err.Error() + } + writeJSON(w, http.StatusOK, map[string]any{ + "status": "success", + "message": "webhook registration attempted", + "data": s.deviceJSON(device, note), + }) +} + +func (s *Server) deviceJSON(device model.Device, registration string) map[string]any { + return map[string]any{ + "id": device.ID, + "name": device.Name, + "phone_number": device.PhoneNumber, + "base_url": device.BaseURL, + "username": device.Username, + "mode": device.Mode, + "ingest_url": s.ingestURL(device.IngestToken), + "webhook_registration": registration, + "created_at": device.CreatedAt, + } +} + +func (s *Server) ingestURL(token string) string { + return s.PublicURL + "/v1/gateway/events/" + token +} + +func (s *Server) sendMessage(w http.ResponseWriter, r *http.Request) { + user, ok := s.currentUser(r) + if !ok { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + var body struct { + From string `json:"from"` + To string `json:"to"` + Content string `json:"content"` + RequestID string `json:"request_id"` + SIM string `json:"sim"` + } + if err := readJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "request body must be JSON") + return + } + from, err := phone.Normalize(body.From) + if err != nil { + writeError(w, http.StatusUnprocessableEntity, "from "+err.Error()) + return + } + to, err := phone.Normalize(body.To) + if err != nil { + writeError(w, http.StatusUnprocessableEntity, "to "+err.Error()) + return + } + content := strings.TrimSpace(body.Content) + if content == "" || len(content) > 1600 { + writeError(w, http.StatusUnprocessableEntity, "content is required and must be at most 1600 characters") + return + } + device, err := s.Store.DeviceByPhone(r.Context(), user.ID, from) + if err != nil { + writeError(w, http.StatusUnprocessableEntity, "no device is registered for "+from) + return + } + password, err := s.Box.Open(device.Password) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not read the gateway password") + return + } + simNumber := 1 + simLabel := model.SIM1 + if strings.EqualFold(body.SIM, model.SIM2) { + simNumber = 2 + simLabel = model.SIM2 + } + now := s.Now() + message := model.Message{ + ID: uuid.NewString(), + UserID: user.ID, + DeviceID: device.ID, + RequestID: strings.TrimSpace(body.RequestID), + Owner: from, + Contact: to, + Content: content, + Type: model.TypeMobileTerminated, + Status: model.StatusPending, + SIM: simLabel, + CreatedAt: now, + UpdatedAt: now, + } + gatewayID, err := s.Gateway.SendSMS(r.Context(), gateway.Device{ + Mode: device.Mode, BaseURL: device.BaseURL, Username: device.Username, Password: password, + }, to, content, simNumber) + if err != nil { + message.Status = model.StatusFailed + message.FailureReason = "gateway request failed" + _ = s.insertIgnoringDuplicate(r.Context(), message) + writeJSON(w, http.StatusBadGateway, map[string]any{ + "status": "error", + "message": "the SMS gateway did not accept the message", + "data": messageJSON(message), + }) + return + } + message.GatewayID = gatewayID + if _, err := s.Store.InsertMessage(r.Context(), message); err != nil { + writeError(w, http.StatusInternalServerError, "the gateway accepted the message but it could not be stored") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "status": "success", + "message": "message added to queue", + "data": messageJSON(message), + }) +} + +func (s *Server) listMessages(w http.ResponseWriter, r *http.Request) { + user, ok := s.currentUser(r) + if !ok { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + messages, err := s.Store.ListMessages(r.Context(), user.ID, 50) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not list messages") + return + } + out := make([]map[string]any, 0, len(messages)) + for _, message := range messages { + out = append(out, messageJSON(message)) + } + writeJSON(w, http.StatusOK, map[string]any{"status": "success", "message": "messages", "data": out}) +} + +func (s *Server) createWebhook(w http.ResponseWriter, r *http.Request) { + user, ok := s.currentUser(r) + if !ok { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + var body struct { + URL string `json:"url"` + SigningKey string `json:"signing_key"` + Events []string `json:"events"` + } + if err := readJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "request body must be JSON") + return + } + endpoint, err := urls.NormalizeBaseURL(body.URL, true) + if err != nil { + writeError(w, http.StatusUnprocessableEntity, err.Error()) + return + } + if len(body.Events) == 0 { + writeError(w, http.StatusUnprocessableEntity, "events must include message.phone.received") + return + } + hook := model.Webhook{ + ID: uuid.NewString(), + UserID: user.ID, + URL: endpoint, + SigningKey: body.SigningKey, + Events: body.Events, + CreatedAt: s.Now(), + } + if err := s.Store.CreateWebhook(r.Context(), hook); err != nil { + writeError(w, http.StatusInternalServerError, "could not save webhook") + return + } + writeJSON(w, http.StatusCreated, map[string]any{ + "status": "success", + "message": "webhook saved", + "data": webhookJSON(hook), + }) +} + +func (s *Server) listWebhooks(w http.ResponseWriter, r *http.Request) { + user, ok := s.currentUser(r) + if !ok { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + hooks, err := s.Store.ListWebhooks(r.Context(), user.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not list webhooks") + return + } + out := make([]map[string]any, 0, len(hooks)) + for _, hook := range hooks { + out = append(out, webhookJSON(hook)) + } + writeJSON(w, http.StatusOK, map[string]any{"status": "success", "message": "webhooks", "data": out}) +} + +func (s *Server) deleteWebhook(w http.ResponseWriter, r *http.Request) { + user, ok := s.currentUser(r) + if !ok { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + if err := s.Store.DeleteWebhook(r.Context(), user.ID, r.PathValue("id")); err != nil { + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "webhook not found") + return + } + writeError(w, http.StatusInternalServerError, "could not delete webhook") + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "success", "message": "webhook deleted"}) +} + +func (s *Server) gatewayEvent(w http.ResponseWriter, r *http.Request) { + device, err := s.Store.DeviceByIngestToken(r.Context(), r.PathValue("token")) + if err != nil { + writeError(w, http.StatusUnauthorized, "unknown device token") + return + } + var event struct { + Event string `json:"event"` + Payload json.RawMessage `json:"payload"` + } + if err := readJSON(r, &event); err != nil { + writeError(w, http.StatusBadRequest, "request body must be JSON") + return + } + var payload struct { + MessageID string `json:"messageId"` + Message string `json:"message"` + Sender string `json:"sender"` + Recipient string `json:"recipient"` + SimNumber *int `json:"simNumber"` + Reason string `json:"reason"` + } + if len(event.Payload) > 0 { + if err := json.Unmarshal(event.Payload, &payload); err != nil { + writeError(w, http.StatusBadRequest, "payload is invalid") + return + } + } + switch event.Event { + case "sms:received": + s.receiveSMS(w, r, device, payload.MessageID, payload.Message, payload.Sender, payload.Recipient, payload.SimNumber) + case "sms:sent", "sms:delivered", "sms:failed": + status := model.StatusSent + if event.Event == "sms:delivered" { + status = model.StatusDelivered + } + if event.Event == "sms:failed" { + status = model.StatusFailed + } + if payload.MessageID != "" { + _ = s.Store.UpdateMessageByGatewayID(r.Context(), device.UserID, payload.MessageID, status, payload.Reason) + } + writeJSON(w, http.StatusOK, map[string]string{"status": "success", "message": "event stored"}) + default: + writeJSON(w, http.StatusOK, map[string]string{"status": "success", "message": "event ignored"}) + } +} + +func (s *Server) receiveSMS(w http.ResponseWriter, r *http.Request, device model.Device, gatewayID, content, sender, recipient string, simNumber *int) { + owner := recipient + if owner == "" { + owner = device.PhoneNumber + } + owner, err := phone.Normalize(owner) + if err != nil { + owner = device.PhoneNumber + } + contact, err := phone.Normalize(sender) + if err != nil { + writeError(w, http.StatusUnprocessableEntity, "sender "+err.Error()) + return + } + simLabel := model.SIM1 + if simNumber != nil && *simNumber == 2 { + simLabel = model.SIM2 + } + now := s.Now() + message := model.Message{ + ID: uuid.NewString(), + UserID: device.UserID, + DeviceID: device.ID, + Owner: owner, + Contact: contact, + Content: content, + Type: model.TypeMobileOriginated, + Status: model.StatusReceived, + SIM: simLabel, + GatewayID: gatewayID, + CreatedAt: now, + UpdatedAt: now, + } + created, err := s.Store.InsertMessage(r.Context(), message) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not store message") + return + } + if created { + s.fanout(r.Context(), device.UserID, message) + } + writeJSON(w, http.StatusOK, map[string]any{"status": "success", "message": "message received", "data": messageJSON(message)}) +} + +func (s *Server) fanout(ctx context.Context, userID string, message model.Message) { + hooks, err := s.Store.ListWebhooksByEvent(ctx, userID, model.EventMessagePhoneReceived) + if err != nil { + log.Printf("list webhooks: %v", err) + return + } + eventID := uuid.NewString() + body, err := json.Marshal(map[string]any{ + "specversion": "1.0", + "id": eventID, + "source": s.PublicURL, + "type": model.EventMessagePhoneReceived, + "time": s.Now(), + "datacontenttype": "application/json", + "data": map[string]any{ + "message_id": message.ID, + "user_id": userID, + "owner": message.Owner, + "encrypted": false, + "contact": message.Contact, + "timestamp": message.CreatedAt, + "content": message.Content, + "sim": message.SIM, + "attachments": []string{}, + }, + }) + if err != nil { + return + } + for _, hook := range hooks { + request, err := http.NewRequestWithContext(ctx, http.MethodPost, hook.URL, bytes.NewReader(body)) + if err != nil { + continue + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("X-Event-Type", model.EventMessagePhoneReceived) + if strings.TrimSpace(hook.SigningKey) != "" { + token, err := s.Tokens.WebhookToken(userID, hook.URL, hook.SigningKey) + if err == nil { + request.Header.Set("Authorization", "Bearer "+token) + } + } + response, err := s.HTTP.Do(request) + if err != nil { + log.Printf("webhook delivery failed: %v", err) + continue + } + response.Body.Close() + } +} + +func (s *Server) insertIgnoringDuplicate(ctx context.Context, message model.Message) bool { + created, err := s.Store.InsertMessage(ctx, message) + if err != nil { + log.Printf("store message: %v", err) + return false + } + return created +} + +func (s *Server) oidcStart(w http.ResponseWriter, r *http.Request) { + if s.OIDC == nil { + writeError(w, http.StatusNotFound, "OIDC is not configured") + return + } + state, err := auth.RandomToken(16) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not start login") + return + } + verifier, challenge, err := auth.PKCE() + if err != nil { + writeError(w, http.StatusInternalServerError, "could not start login") + return + } + cookie, err := s.Tokens.OIDCStateCookie(state, verifier) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not start login") + return + } + http.SetCookie(w, &http.Cookie{ + Name: "godesk_oidc", + Value: cookie, + Path: "/v1/auth/oidc", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: strings.HasPrefix(s.PublicURL, "https://"), + MaxAge: 600, + }) + location, err := s.OIDC.AuthCodeURL(r.Context(), state, challenge) + if err != nil { + writeError(w, http.StatusBadGateway, "could not reach the OIDC issuer") + return + } + http.Redirect(w, r, location, http.StatusFound) +} + +func (s *Server) oidcCallback(w http.ResponseWriter, r *http.Request) { + if s.OIDC == nil { + writeError(w, http.StatusNotFound, "OIDC is not configured") + return + } + cookie, err := r.Cookie("godesk_oidc") + if err != nil { + writeError(w, http.StatusBadRequest, "OIDC state is missing") + return + } + state, verifier, err := s.Tokens.ParseOIDCState(cookie.Value) + if err != nil || state == "" || state != r.URL.Query().Get("state") { + writeError(w, http.StatusBadRequest, "OIDC state does not match") + return + } + email, err := s.OIDC.Exchange(r.Context(), r.URL.Query().Get("code"), verifier) + if err != nil { + writeError(w, http.StatusUnauthorized, "OIDC login failed") + return + } + user, err := s.Store.UserByEmail(r.Context(), email) + if errors.Is(err, store.ErrNotFound) { + random, randErr := auth.RandomToken(32) + if randErr != nil { + writeError(w, http.StatusInternalServerError, "could not create account") + return + } + hash, hashErr := auth.HashPassword(random, s.BcryptCost) + if hashErr != nil { + writeError(w, http.StatusInternalServerError, "could not create account") + return + } + apiKey, keyErr := auth.RandomToken(32) + if keyErr != nil { + writeError(w, http.StatusInternalServerError, "could not create account") + return + } + user = model.User{ID: uuid.NewString(), Email: email, PasswordHash: hash, APIKey: apiKey, CreatedAt: s.Now()} + if err := s.Store.CreateUser(r.Context(), user); err != nil { + writeError(w, http.StatusInternalServerError, "could not create account") + return + } + } else if err != nil { + writeError(w, http.StatusInternalServerError, "could not load account") + return + } + token, err := s.Tokens.SessionToken(user.ID, user.Email) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not create session") + return + } + http.Redirect(w, r, "/#token="+token, http.StatusFound) +} + +func (s *Server) currentUser(r *http.Request) (model.User, bool) { + if header := r.Header.Get("Authorization"); strings.HasPrefix(header, "Bearer ") { + userID, _, err := s.Tokens.ParseSession(strings.TrimPrefix(header, "Bearer ")) + if err == nil { + user, err := s.Store.UserByID(r.Context(), userID) + if err == nil { + return user, true + } + } + } + if apiKey := r.Header.Get("x-api-key"); apiKey != "" { + user, err := s.Store.UserByAPIKey(r.Context(), apiKey) + if err == nil { + return user, true + } + } + return model.User{}, false +} + +func messageJSON(message model.Message) map[string]any { + var requestID any + if message.RequestID != "" { + requestID = message.RequestID + } + var reason any + if message.FailureReason != "" { + reason = message.FailureReason + } + return map[string]any{ + "id": message.ID, + "request_id": requestID, + "owner": message.Owner, + "user_id": message.UserID, + "contact": message.Contact, + "content": message.Content, + "type": message.Type, + "status": message.Status, + "sim": message.SIM, + "failure_reason": reason, + "created_at": message.CreatedAt, + "updated_at": message.UpdatedAt, + } +} + +func webhookJSON(hook model.Webhook) map[string]any { + return map[string]any{ + "id": hook.ID, + "url": hook.URL, + "events": hook.Events, + "created_at": hook.CreatedAt, + } +} + +func readJSON(r *http.Request, dest any) error { + defer r.Body.Close() + return json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(dest) +} + +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"status": "error", "message": message}) +} + +func normalizeEmail(value string) (string, error) { + value = strings.ToLower(strings.TrimSpace(value)) + if len(value) < 3 || len(value) > 320 || !strings.Contains(value, "@") || strings.HasPrefix(value, "@") || strings.HasSuffix(value, "@") { + return "", errors.New("email is invalid") + } + return value, nil +} diff --git a/selfhosted/internal/httpapi/server_test.go b/selfhosted/internal/httpapi/server_test.go new file mode 100644 index 000000000..43a7cf8c9 --- /dev/null +++ b/selfhosted/internal/httpapi/server_test.go @@ -0,0 +1,288 @@ +package httpapi + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/auth" + "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/secretbox" + "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/store/memory" + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +func TestVerticalSlice(t *testing.T) { + var mu sync.Mutex + var sentUser string + var sentTo string + var hooks []string + gatewaySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, _, _ := r.BasicAuth() + body, _ := io.ReadAll(r.Body) + mu.Lock() + defer mu.Unlock() + switch r.URL.Path { + case "/message": + sentUser = user + var payload struct { + PhoneNumbers []string `json:"phoneNumbers"` + } + _ = json.Unmarshal(body, &payload) + if len(payload.PhoneNumbers) > 0 { + sentTo = payload.PhoneNumbers[0] + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"gw-send-1","state":"Pending"}`)) + case "/webhooks": + var payload struct { + URL string `json:"url"` + Event string `json:"event"` + } + _ = json.Unmarshal(body, &payload) + if payload.Event == "sms:received" { + hooks = append(hooks, payload.URL) + } + w.WriteHeader(http.StatusCreated) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer gatewaySrv.Close() + + var deliveries [][]byte + var authHeader string + webhookSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + mu.Lock() + deliveries = append(deliveries, body) + authHeader = r.Header.Get("Authorization") + mu.Unlock() + if r.Header.Get("X-Event-Type") != "message.phone.received" { + t.Errorf("event header %s", r.Header.Get("X-Event-Type")) + } + w.WriteHeader(http.StatusNoContent) + })) + defer webhookSrv.Close() + + tokens, err := auth.NewTokens("test-jwt-secret-must-be-32-characters") + if err != nil { + t.Fatal(err) + } + box, err := secretbox.New("test-jwt-secret-must-be-32-characters") + if err != nil { + t.Fatal(err) + } + api := New(Server{ + Store: memory.New(), + Tokens: tokens, + Box: box, + PublicURL: "http://godesk.test", + BcryptCost: bcrypt.MinCost, + }) + server := httptest.NewServer(api.Handler()) + defer server.Close() + + signup := postJSON(t, server.URL+"/v1/auth/signup", "", map[string]string{ + "email": "Ada@Example.com", "password": "correct horse", + }) + if signup.StatusCode != http.StatusOK { + t.Fatalf("signup %d %s", signup.StatusCode, signup.Body) + } + var session struct { + Data struct { + Token string `json:"token"` + APIKey string `json:"api_key"` + } `json:"data"` + } + decode(t, signup.Body, &session) + if session.Data.Token == "" || session.Data.APIKey == "" { + t.Fatalf("session %+v", session.Data) + } + + conflict := postJSON(t, server.URL+"/v1/auth/signup", "", map[string]string{ + "email": "ada@example.com", "password": "correct horse", + }) + if conflict.StatusCode != http.StatusConflict { + t.Fatalf("duplicate signup %d", conflict.StatusCode) + } + badLogin := postJSON(t, server.URL+"/v1/auth/login", "", map[string]string{ + "email": "ada@example.com", "password": "wrong-password", + }) + if badLogin.StatusCode != http.StatusUnauthorized { + t.Fatalf("bad login %d", badLogin.StatusCode) + } + + var phoneBIngest string + for _, device := range []map[string]string{ + {"name": "SIM A", "phone_number": "+18005550199", "username": "phone-a"}, + {"name": "SIM B", "phone_number": "+18005550198", "username": "phone-b"}, + } { + created := postJSON(t, server.URL+"/v1/devices", session.Data.Token, map[string]string{ + "name": device["name"], + "phone_number": device["phone_number"], + "base_url": gatewaySrv.URL, + "username": device["username"], + "password": "gateway-secret", + "mode": "local", + }) + if created.StatusCode != http.StatusCreated { + t.Fatalf("device %s: %d %s", device["name"], created.StatusCode, created.Body) + } + var payload struct { + Data struct { + Registration string `json:"webhook_registration"` + IngestURL string `json:"ingest_url"` + } `json:"data"` + } + decode(t, created.Body, &payload) + if payload.Data.Registration != "ok" { + t.Fatalf("registration %q", payload.Data.Registration) + } + phoneBIngest = payload.Data.IngestURL + } + + sent := postJSON(t, server.URL+"/v1/messages/send", "", map[string]string{ + "from": "+18005550198", "to": "+18005550100", "content": "hello from b", + }, session.Data.APIKey) + if sent.StatusCode != http.StatusOK { + t.Fatalf("send %d %s", sent.StatusCode, sent.Body) + } + var sentBody struct { + Message string `json:"message"` + Data struct { + Status string `json:"status"` + Owner string `json:"owner"` + } `json:"data"` + } + decode(t, sent.Body, &sentBody) + if sentBody.Message != "message added to queue" || sentBody.Data.Status != "pending" || sentBody.Data.Owner != "+18005550198" { + t.Fatalf("send body %+v", sentBody) + } + mu.Lock() + if sentUser != "phone-b" || sentTo != "+18005550100" { + t.Fatalf("gateway saw user %q to %q", sentUser, sentTo) + } + if len(hooks) < 2 { + t.Fatalf("expected a received webhook per device, got %d", len(hooks)) + } + mu.Unlock() + ingestToken := phoneBIngest[strings.LastIndex(phoneBIngest, "/")+1:] + ingest := server.URL + "/v1/gateway/events/" + ingestToken + + hook := postJSON(t, server.URL+"/v1/webhooks", session.Data.Token, map[string]any{ + "url": webhookSrv.URL + "/sms", "signing_key": "hook-secret", "events": []string{"message.phone.received"}, + }) + if hook.StatusCode != http.StatusCreated { + t.Fatalf("webhook %d %s", hook.StatusCode, hook.Body) + } + + event := map[string]any{ + "event": "sms:received", + "payload": map[string]any{ + "messageId": "inbound-1", + "message": "ping", + "sender": "+18005550100", + "recipient": "+18005550198", + "simNumber": 1, + }, + } + first := postJSON(t, ingest, "", event) + if first.StatusCode != http.StatusOK { + t.Fatalf("ingest %d %s", first.StatusCode, first.Body) + } + second := postJSON(t, ingest, "", event) + if second.StatusCode != http.StatusOK { + t.Fatalf("duplicate ingest %d", second.StatusCode) + } + mu.Lock() + if len(deliveries) != 1 { + t.Fatalf("webhook deliveries %d", len(deliveries)) + } + delivery := deliveries[0] + header := authHeader + mu.Unlock() + + var cloud struct { + Type string `json:"type"` + Data struct { + Content string `json:"content"` + Owner string `json:"owner"` + Contact string `json:"contact"` + } `json:"data"` + } + decode(t, string(delivery), &cloud) + if cloud.Type != "message.phone.received" || cloud.Data.Content != "ping" || cloud.Data.Owner != "+18005550198" || cloud.Data.Contact != "+18005550100" { + t.Fatalf("cloud event %+v", cloud) + } + if !strings.HasPrefix(header, "Bearer ") { + t.Fatalf("signing header %q", header) + } + parsed, err := jwt.Parse(strings.TrimPrefix(header, "Bearer "), func(token *jwt.Token) (any, error) { + return []byte("hook-secret"), nil + }) + if err != nil || !parsed.Valid { + t.Fatalf("webhook jwt: %v", err) + } + + health, err := http.Get(server.URL + "/health") + if err != nil { + t.Fatal(err) + } + defer health.Body.Close() + if health.StatusCode != http.StatusOK { + t.Fatalf("health %d", health.StatusCode) + } + dashboard, err := http.Get(server.URL + "/") + if err != nil { + t.Fatal(err) + } + defer dashboard.Body.Close() + page, _ := io.ReadAll(dashboard.Body) + if !bytes.Contains(page, []byte("GoDesk SMS")) { + t.Fatal("dashboard missing") + } +} + +type recorded struct { + StatusCode int + Body string +} + +func postJSON(t *testing.T, endpoint, bearer string, payload any, apiKey ...string) recorded { + t.Helper() + raw, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + request, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Content-Type", "application/json") + if bearer != "" { + request.Header.Set("Authorization", "Bearer "+bearer) + } + if len(apiKey) > 0 && apiKey[0] != "" { + request.Header.Set("x-api-key", apiKey[0]) + } + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + body, _ := io.ReadAll(response.Body) + return recorded{StatusCode: response.StatusCode, Body: string(body)} +} + +func decode(t *testing.T, raw string, dest any) { + t.Helper() + if err := json.Unmarshal([]byte(raw), dest); err != nil { + t.Fatalf("decode %s: %v", raw, err) + } +} diff --git a/selfhosted/internal/model/model.go b/selfhosted/internal/model/model.go new file mode 100644 index 000000000..820d5a30b --- /dev/null +++ b/selfhosted/internal/model/model.go @@ -0,0 +1,75 @@ +package model + +import "time" + +// User is a GoDesk account. PasswordHash is empty for display and is a bcrypt +// hash in storage. APIKey is the httpsms-compatible x-api-key credential. +type User struct { + ID string + Email string + PasswordHash string + APIKey string + CreatedAt time.Time +} + +// Device is one SMS Gateway for Android handset. Password is sealed before it +// is stored and opened only when calling the gateway. +type Device struct { + ID string + UserID string + Name string + PhoneNumber string + BaseURL string + Username string + Password string + Mode string + IngestToken string + CreatedAt time.Time +} + +// Message is an outbound or inbound SMS in the httpsms shape. +type Message struct { + ID string + UserID string + DeviceID string + RequestID string + Owner string + Contact string + Content string + Type string + Status string + SIM string + GatewayID string + FailureReason string + CreatedAt time.Time + UpdatedAt time.Time +} + +// Webhook is an httpsms-compatible subscription. +type Webhook struct { + ID string + UserID string + URL string + SigningKey string + Events []string + CreatedAt time.Time +} + +const ( + ModeLocal = "local" + ModePrivate = "private" + + TypeMobileTerminated = "mobile-terminated" + TypeMobileOriginated = "mobile-originated" + + StatusPending = "pending" + StatusSent = "sent" + StatusDelivered = "delivered" + StatusFailed = "failed" + StatusReceived = "received" + + SIM1 = "SIM1" + SIM2 = "SIM2" + + EventMessagePhoneReceived = "message.phone.received" +) diff --git a/selfhosted/internal/phone/phone.go b/selfhosted/internal/phone/phone.go new file mode 100644 index 000000000..d71860255 --- /dev/null +++ b/selfhosted/internal/phone/phone.go @@ -0,0 +1,20 @@ +package phone + +import ( + "errors" + "strings" +) + +// Normalize checks an E.164 number and returns it unchanged aside from trimming. +func Normalize(value string) (string, error) { + value = strings.TrimSpace(value) + if len(value) < 8 || len(value) > 16 || !strings.HasPrefix(value, "+") { + return "", errors.New("phone number must be E.164, for example +18005550100") + } + for _, r := range value[1:] { + if r < '0' || r > '9' { + return "", errors.New("phone number must be E.164, for example +18005550100") + } + } + return value, nil +} diff --git a/selfhosted/internal/secretbox/box.go b/selfhosted/internal/secretbox/box.go new file mode 100644 index 000000000..759a0d622 --- /dev/null +++ b/selfhosted/internal/secretbox/box.go @@ -0,0 +1,70 @@ +package secretbox + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "io" +) + +// Box seals short secrets with AES-GCM. The key is derived from a server secret +// so device passwords are not stored in plaintext. +type Box struct { + key []byte +} + +// New derives a 32-byte key from secret. secret must be non-empty. +func New(secret string) (*Box, error) { + if secret == "" { + return nil, errors.New("encryption secret is empty") + } + sum := sha256.Sum256([]byte(secret)) + return &Box{key: sum[:]}, nil +} + +// Seal encrypts plaintext and returns a base64 token. +func (b *Box) Seal(plaintext string) (string, error) { + block, err := aes.NewCipher(b.key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.RawStdEncoding.EncodeToString(sealed), nil +} + +// Open reverses Seal. +func (b *Box) Open(token string) (string, error) { + raw, err := base64.RawStdEncoding.DecodeString(token) + if err != nil { + return "", fmt.Errorf("decode secret: %w", err) + } + block, err := aes.NewCipher(b.key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + if len(raw) < gcm.NonceSize() { + return "", errors.New("secret token is too short") + } + nonce, ciphertext := raw[:gcm.NonceSize()], raw[gcm.NonceSize():] + plain, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", fmt.Errorf("open secret: %w", err) + } + return string(plain), nil +} diff --git a/selfhosted/internal/secretbox/box_test.go b/selfhosted/internal/secretbox/box_test.go new file mode 100644 index 000000000..5e9f79da6 --- /dev/null +++ b/selfhosted/internal/secretbox/box_test.go @@ -0,0 +1,24 @@ +package secretbox + +import "testing" + +func TestRoundTrip(t *testing.T) { + box, err := New("test-secret-value") + if err != nil { + t.Fatal(err) + } + sealed, err := box.Seal("gateway-password") + if err != nil { + t.Fatal(err) + } + if sealed == "gateway-password" { + t.Fatal("password was stored in plaintext") + } + opened, err := box.Open(sealed) + if err != nil { + t.Fatal(err) + } + if opened != "gateway-password" { + t.Fatalf("opened %q", opened) + } +} diff --git a/selfhosted/internal/store/memory/memory.go b/selfhosted/internal/store/memory/memory.go new file mode 100644 index 000000000..e86545da9 --- /dev/null +++ b/selfhosted/internal/store/memory/memory.go @@ -0,0 +1,245 @@ +package memory + +import ( + "context" + "sort" + "sync" + "time" + + "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/model" + "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/store" +) + +// Store is an in-memory Store used by tests and local experiments. +type Store struct { + mu sync.Mutex + users map[string]model.User + byEmail map[string]string + byAPIKey map[string]string + devices map[string]model.Device + messages []model.Message + webhooks map[string]model.Webhook +} + +// New returns an empty store. +func New() *Store { + return &Store{ + users: map[string]model.User{}, + byEmail: map[string]string{}, + byAPIKey: map[string]string{}, + devices: map[string]model.Device{}, + webhooks: map[string]model.Webhook{}, + } +} + +func (s *Store) Ping(context.Context) error { return nil } + +func (s *Store) CreateUser(_ context.Context, user model.User) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.byEmail[user.Email]; ok { + return store.ErrConflict + } + if _, ok := s.byAPIKey[user.APIKey]; ok { + return store.ErrConflict + } + s.users[user.ID] = user + s.byEmail[user.Email] = user.ID + s.byAPIKey[user.APIKey] = user.ID + return nil +} + +func (s *Store) UserByEmail(_ context.Context, email string) (model.User, error) { + s.mu.Lock() + defer s.mu.Unlock() + id, ok := s.byEmail[email] + if !ok { + return model.User{}, store.ErrNotFound + } + return s.users[id], nil +} + +func (s *Store) UserByID(_ context.Context, id string) (model.User, error) { + s.mu.Lock() + defer s.mu.Unlock() + user, ok := s.users[id] + if !ok { + return model.User{}, store.ErrNotFound + } + return user, nil +} + +func (s *Store) UserByAPIKey(_ context.Context, apiKey string) (model.User, error) { + s.mu.Lock() + defer s.mu.Unlock() + id, ok := s.byAPIKey[apiKey] + if !ok { + return model.User{}, store.ErrNotFound + } + return s.users[id], nil +} + +func (s *Store) CreateDevice(_ context.Context, device model.Device) error { + s.mu.Lock() + defer s.mu.Unlock() + for _, existing := range s.devices { + if existing.UserID == device.UserID && existing.PhoneNumber == device.PhoneNumber { + return store.ErrConflict + } + } + s.devices[device.ID] = device + return nil +} + +func (s *Store) ListDevices(_ context.Context, userID string) ([]model.Device, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []model.Device + for _, device := range s.devices { + if device.UserID == userID { + out = append(out, device) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.Before(out[j].CreatedAt) }) + return out, nil +} + +func (s *Store) DeviceByID(_ context.Context, userID, id string) (model.Device, error) { + s.mu.Lock() + defer s.mu.Unlock() + device, ok := s.devices[id] + if !ok || device.UserID != userID { + return model.Device{}, store.ErrNotFound + } + return device, nil +} + +func (s *Store) DeviceByPhone(_ context.Context, userID, phone string) (model.Device, error) { + s.mu.Lock() + defer s.mu.Unlock() + for _, device := range s.devices { + if device.UserID == userID && device.PhoneNumber == phone { + return device, nil + } + } + return model.Device{}, store.ErrNotFound +} + +func (s *Store) DeviceByIngestToken(_ context.Context, token string) (model.Device, error) { + s.mu.Lock() + defer s.mu.Unlock() + for _, device := range s.devices { + if device.IngestToken == token { + return device, nil + } + } + return model.Device{}, store.ErrNotFound +} + +func (s *Store) DeleteDevice(_ context.Context, userID, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + device, ok := s.devices[id] + if !ok || device.UserID != userID { + return store.ErrNotFound + } + delete(s.devices, id) + return nil +} + +func (s *Store) InsertMessage(_ context.Context, message model.Message) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + if message.GatewayID != "" { + for _, existing := range s.messages { + if existing.UserID == message.UserID && existing.GatewayID == message.GatewayID { + return false, nil + } + } + } + s.messages = append(s.messages, message) + return true, nil +} + +func (s *Store) ListMessages(_ context.Context, userID string, limit int) ([]model.Message, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []model.Message + for i := len(s.messages) - 1; i >= 0; i-- { + if s.messages[i].UserID == userID { + out = append(out, s.messages[i]) + } + if limit > 0 && len(out) >= limit { + break + } + } + return out, nil +} + +func (s *Store) UpdateMessageByGatewayID(_ context.Context, userID, gatewayID, status, reason string) error { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.messages { + if s.messages[i].UserID == userID && s.messages[i].GatewayID == gatewayID { + s.messages[i].Status = status + s.messages[i].FailureReason = reason + s.messages[i].UpdatedAt = time.Now().UTC() + return nil + } + } + return store.ErrNotFound +} + +func (s *Store) CreateWebhook(_ context.Context, hook model.Webhook) error { + s.mu.Lock() + defer s.mu.Unlock() + s.webhooks[hook.ID] = hook + return nil +} + +func (s *Store) ListWebhooks(_ context.Context, userID string) ([]model.Webhook, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.webhooksFor(userID, ""), nil +} + +func (s *Store) ListWebhooksByEvent(_ context.Context, userID, event string) ([]model.Webhook, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.webhooksFor(userID, event), nil +} + +func (s *Store) webhooksFor(userID, event string) []model.Webhook { + var out []model.Webhook + for _, hook := range s.webhooks { + if hook.UserID != userID { + continue + } + if event != "" && !contains(hook.Events, event) { + continue + } + out = append(out, hook) + } + sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.Before(out[j].CreatedAt) }) + return out +} + +func (s *Store) DeleteWebhook(_ context.Context, userID, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + hook, ok := s.webhooks[id] + if !ok || hook.UserID != userID { + return store.ErrNotFound + } + delete(s.webhooks, id) + return nil +} + +func contains(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} diff --git a/selfhosted/internal/store/pg/migrations/001_init.sql b/selfhosted/internal/store/pg/migrations/001_init.sql new file mode 100644 index 000000000..463a600b3 --- /dev/null +++ b/selfhosted/internal/store/pg/migrations/001_init.sql @@ -0,0 +1,51 @@ +CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + api_key TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE devices ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + name TEXT NOT NULL, + phone_number TEXT NOT NULL, + base_url TEXT NOT NULL, + username TEXT NOT NULL, + password TEXT NOT NULL, + mode TEXT NOT NULL, + ingest_token TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL, + UNIQUE (user_id, phone_number) +); + +CREATE TABLE messages ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + device_id TEXT REFERENCES devices (id) ON DELETE SET NULL, + request_id TEXT NOT NULL DEFAULT '', + owner TEXT NOT NULL, + contact TEXT NOT NULL, + content TEXT NOT NULL, + type TEXT NOT NULL, + status TEXT NOT NULL, + sim TEXT NOT NULL, + gateway_id TEXT NOT NULL DEFAULT '', + failure_reason TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE UNIQUE INDEX messages_user_gateway_uidx + ON messages (user_id, gateway_id) + WHERE gateway_id <> ''; + +CREATE TABLE webhooks ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + url TEXT NOT NULL, + signing_key TEXT NOT NULL DEFAULT '', + events TEXT[] NOT NULL, + created_at TIMESTAMPTZ NOT NULL +); diff --git a/selfhosted/internal/store/pg/pg.go b/selfhosted/internal/store/pg/pg.go new file mode 100644 index 000000000..f64c8c9f7 --- /dev/null +++ b/selfhosted/internal/store/pg/pg.go @@ -0,0 +1,320 @@ +package pg + +import ( + "context" + "embed" + "errors" + "fmt" + "sort" + "time" + + "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/model" + "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/store" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" +) + +//go:embed migrations/*.sql +var migrations embed.FS + +// Store is the Postgres implementation used by docker compose. +type Store struct { + pool *pgxpool.Pool +} + +// Open connects, applies migrations, and returns a store. +func Open(ctx context.Context, databaseURL string) (*Store, error) { + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + return nil, fmt.Errorf("connect postgres: %w", err) + } + store := &Store{pool: pool} + if err := store.migrate(ctx); err != nil { + pool.Close() + return nil, err + } + return store, nil +} + +// Close releases the pool. +func (s *Store) Close() { + s.pool.Close() +} + +func (s *Store) migrate(ctx context.Context) error { + if _, err := s.pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT PRIMARY KEY)`); err != nil { + return fmt.Errorf("create schema_migrations: %w", err) + } + entries, err := migrations.ReadDir("migrations") + if err != nil { + return err + } + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name()) + } + sort.Strings(names) + for _, name := range names { + var applied bool + err := s.pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE version = $1)`, name).Scan(&applied) + if err != nil { + return err + } + if applied { + continue + } + body, err := migrations.ReadFile("migrations/" + name) + if err != nil { + return err + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + if _, err := tx.Exec(ctx, string(body)); err != nil { + _ = tx.Rollback(ctx) + return fmt.Errorf("apply %s: %w", name, err) + } + if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, name); err != nil { + _ = tx.Rollback(ctx) + return err + } + if err := tx.Commit(ctx); err != nil { + return err + } + } + return nil +} + +func (s *Store) Ping(ctx context.Context) error { + return s.pool.Ping(ctx) +} + +func (s *Store) CreateUser(ctx context.Context, user model.User) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO users (id, email, password_hash, api_key, created_at) + VALUES ($1, $2, $3, $4, $5)`, + user.ID, user.Email, user.PasswordHash, user.APIKey, user.CreatedAt) + return mapConflict(err) +} + +func (s *Store) UserByEmail(ctx context.Context, email string) (model.User, error) { + return s.oneUser(ctx, `SELECT id, email, password_hash, api_key, created_at FROM users WHERE email = $1`, email) +} + +func (s *Store) UserByID(ctx context.Context, id string) (model.User, error) { + return s.oneUser(ctx, `SELECT id, email, password_hash, api_key, created_at FROM users WHERE id = $1`, id) +} + +func (s *Store) UserByAPIKey(ctx context.Context, apiKey string) (model.User, error) { + return s.oneUser(ctx, `SELECT id, email, password_hash, api_key, created_at FROM users WHERE api_key = $1`, apiKey) +} + +func (s *Store) oneUser(ctx context.Context, query, arg string) (model.User, error) { + var user model.User + err := s.pool.QueryRow(ctx, query, arg).Scan(&user.ID, &user.Email, &user.PasswordHash, &user.APIKey, &user.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return model.User{}, store.ErrNotFound + } + return user, err +} + +func (s *Store) CreateDevice(ctx context.Context, device model.Device) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO devices (id, user_id, name, phone_number, base_url, username, password, mode, ingest_token, created_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, + device.ID, device.UserID, device.Name, device.PhoneNumber, device.BaseURL, device.Username, device.Password, device.Mode, device.IngestToken, device.CreatedAt) + return mapConflict(err) +} + +func (s *Store) ListDevices(ctx context.Context, userID string) ([]model.Device, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, user_id, name, phone_number, base_url, username, password, mode, ingest_token, created_at + FROM devices WHERE user_id = $1 ORDER BY created_at`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []model.Device + for rows.Next() { + device, err := scanDevice(rows) + if err != nil { + return nil, err + } + out = append(out, device) + } + return out, rows.Err() +} + +func (s *Store) DeviceByID(ctx context.Context, userID, id string) (model.Device, error) { + return s.oneDevice(ctx, ` + SELECT id, user_id, name, phone_number, base_url, username, password, mode, ingest_token, created_at + FROM devices WHERE user_id = $1 AND id = $2`, userID, id) +} + +func (s *Store) DeviceByPhone(ctx context.Context, userID, phone string) (model.Device, error) { + return s.oneDevice(ctx, ` + SELECT id, user_id, name, phone_number, base_url, username, password, mode, ingest_token, created_at + FROM devices WHERE user_id = $1 AND phone_number = $2`, userID, phone) +} + +func (s *Store) DeviceByIngestToken(ctx context.Context, token string) (model.Device, error) { + return s.oneDevice(ctx, ` + SELECT id, user_id, name, phone_number, base_url, username, password, mode, ingest_token, created_at + FROM devices WHERE ingest_token = $1`, token) +} + +func (s *Store) oneDevice(ctx context.Context, query string, args ...any) (model.Device, error) { + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return model.Device{}, err + } + defer rows.Close() + if !rows.Next() { + if err := rows.Err(); err != nil { + return model.Device{}, err + } + return model.Device{}, store.ErrNotFound + } + return scanDevice(rows) +} + +type scanner interface { + Scan(dest ...any) error +} + +func scanDevice(row scanner) (model.Device, error) { + var device model.Device + err := row.Scan(&device.ID, &device.UserID, &device.Name, &device.PhoneNumber, &device.BaseURL, &device.Username, &device.Password, &device.Mode, &device.IngestToken, &device.CreatedAt) + return device, err +} + +func (s *Store) DeleteDevice(ctx context.Context, userID, id string) error { + tag, err := s.pool.Exec(ctx, `DELETE FROM devices WHERE user_id = $1 AND id = $2`, userID, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return store.ErrNotFound + } + return nil +} + +func (s *Store) InsertMessage(ctx context.Context, message model.Message) (bool, error) { + if message.GatewayID != "" { + var existing string + err := s.pool.QueryRow(ctx, `SELECT id FROM messages WHERE user_id = $1 AND gateway_id = $2`, message.UserID, message.GatewayID).Scan(&existing) + if err == nil { + return false, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return false, err + } + } + _, err := s.pool.Exec(ctx, ` + INSERT INTO messages (id, user_id, device_id, request_id, owner, contact, content, type, status, sim, gateway_id, failure_reason, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, + message.ID, message.UserID, nullIfEmpty(message.DeviceID), message.RequestID, message.Owner, message.Contact, message.Content, + message.Type, message.Status, message.SIM, message.GatewayID, message.FailureReason, message.CreatedAt, message.UpdatedAt) + if err != nil { + return false, err + } + return true, nil +} + +func (s *Store) ListMessages(ctx context.Context, userID string, limit int) ([]model.Message, error) { + if limit <= 0 { + limit = 50 + } + rows, err := s.pool.Query(ctx, ` + SELECT id, user_id, COALESCE(device_id, ''), request_id, owner, contact, content, type, status, sim, gateway_id, failure_reason, created_at, updated_at + FROM messages WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2`, userID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []model.Message + for rows.Next() { + var message model.Message + if err := rows.Scan(&message.ID, &message.UserID, &message.DeviceID, &message.RequestID, &message.Owner, &message.Contact, &message.Content, &message.Type, &message.Status, &message.SIM, &message.GatewayID, &message.FailureReason, &message.CreatedAt, &message.UpdatedAt); err != nil { + return nil, err + } + out = append(out, message) + } + return out, rows.Err() +} + +func (s *Store) UpdateMessageByGatewayID(ctx context.Context, userID, gatewayID, status, reason string) error { + tag, err := s.pool.Exec(ctx, ` + UPDATE messages SET status = $3, failure_reason = $4, updated_at = $5 + WHERE user_id = $1 AND gateway_id = $2`, userID, gatewayID, status, reason, time.Now().UTC()) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return store.ErrNotFound + } + return nil +} + +func (s *Store) CreateWebhook(ctx context.Context, hook model.Webhook) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO webhooks (id, user_id, url, signing_key, events, created_at) + VALUES ($1,$2,$3,$4,$5,$6)`, + hook.ID, hook.UserID, hook.URL, hook.SigningKey, hook.Events, hook.CreatedAt) + return err +} + +func (s *Store) ListWebhooks(ctx context.Context, userID string) ([]model.Webhook, error) { + return s.queryWebhooks(ctx, `SELECT id, user_id, url, signing_key, events, created_at FROM webhooks WHERE user_id = $1 ORDER BY created_at`, userID) +} + +func (s *Store) ListWebhooksByEvent(ctx context.Context, userID, event string) ([]model.Webhook, error) { + return s.queryWebhooks(ctx, ` + SELECT id, user_id, url, signing_key, events, created_at + FROM webhooks WHERE user_id = $1 AND $2 = ANY (events) ORDER BY created_at`, userID, event) +} + +func (s *Store) queryWebhooks(ctx context.Context, query string, args ...any) ([]model.Webhook, error) { + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []model.Webhook + for rows.Next() { + var hook model.Webhook + if err := rows.Scan(&hook.ID, &hook.UserID, &hook.URL, &hook.SigningKey, &hook.Events, &hook.CreatedAt); err != nil { + return nil, err + } + out = append(out, hook) + } + return out, rows.Err() +} + +func (s *Store) DeleteWebhook(ctx context.Context, userID, id string) error { + tag, err := s.pool.Exec(ctx, `DELETE FROM webhooks WHERE user_id = $1 AND id = $2`, userID, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return store.ErrNotFound + } + return nil +} + +func nullIfEmpty(value string) any { + if value == "" { + return nil + } + return value +} + +func mapConflict(err error) error { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return store.ErrConflict + } + return err +} diff --git a/selfhosted/internal/store/pg/pg_test.go b/selfhosted/internal/store/pg/pg_test.go new file mode 100644 index 000000000..1ae3098f8 --- /dev/null +++ b/selfhosted/internal/store/pg/pg_test.go @@ -0,0 +1,41 @@ +package pg + +import ( + "context" + "os" + "testing" + "time" + + "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/model" +) + +func TestMigrationsAndUsers(t *testing.T) { + databaseURL := os.Getenv("DATABASE_URL") + if databaseURL == "" { + t.Skip("DATABASE_URL is not set") + } + ctx := context.Background() + store, err := Open(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(store.Close) + + user := model.User{ + ID: "user-1", + Email: "pg@example.com", + PasswordHash: "hash", + APIKey: "api-key-1", + CreatedAt: time.Now().UTC(), + } + if err := store.CreateUser(ctx, user); err != nil { + t.Fatal(err) + } + if err := store.CreateUser(ctx, user); err == nil { + t.Fatal("expected a conflict on the second insert") + } + loaded, err := store.UserByEmail(ctx, user.Email) + if err != nil || loaded.APIKey != user.APIKey { + t.Fatalf("loaded %+v err %v", loaded, err) + } +} diff --git a/selfhosted/internal/store/store.go b/selfhosted/internal/store/store.go new file mode 100644 index 000000000..edf4fa79b --- /dev/null +++ b/selfhosted/internal/store/store.go @@ -0,0 +1,36 @@ +package store + +import ( + "context" + "errors" + + "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/model" +) + +// ErrNotFound is returned when a row does not exist for this caller. +var ErrNotFound = errors.New("not found") + +// ErrConflict is returned when a unique field is already taken. +var ErrConflict = errors.New("conflict") + +// Store is the persistence used by the self-hosted API. +type Store interface { + Ping(ctx context.Context) error + CreateUser(ctx context.Context, user model.User) error + UserByEmail(ctx context.Context, email string) (model.User, error) + UserByID(ctx context.Context, id string) (model.User, error) + UserByAPIKey(ctx context.Context, apiKey string) (model.User, error) + CreateDevice(ctx context.Context, device model.Device) error + ListDevices(ctx context.Context, userID string) ([]model.Device, error) + DeviceByID(ctx context.Context, userID, id string) (model.Device, error) + DeviceByPhone(ctx context.Context, userID, phone string) (model.Device, error) + DeviceByIngestToken(ctx context.Context, token string) (model.Device, error) + DeleteDevice(ctx context.Context, userID, id string) error + InsertMessage(ctx context.Context, message model.Message) (created bool, err error) + ListMessages(ctx context.Context, userID string, limit int) ([]model.Message, error) + UpdateMessageByGatewayID(ctx context.Context, userID, gatewayID, status, reason string) error + CreateWebhook(ctx context.Context, hook model.Webhook) error + ListWebhooks(ctx context.Context, userID string) ([]model.Webhook, error) + ListWebhooksByEvent(ctx context.Context, userID, event string) ([]model.Webhook, error) + DeleteWebhook(ctx context.Context, userID, id string) error +} diff --git a/selfhosted/internal/urls/urls.go b/selfhosted/internal/urls/urls.go new file mode 100644 index 000000000..fb78511ef --- /dev/null +++ b/selfhosted/internal/urls/urls.go @@ -0,0 +1,48 @@ +package urls + +import ( + "errors" + "net" + "net/url" + "strings" +) + +// NormalizeBaseURL checks a device or webhook URL. +// Paths are allowed only when allowPath is true. +// Link-local and unspecified addresses are rejected. LAN addresses are allowed +// because SMS Gateway local mode is reached on the operator's network. +func NormalizeBaseURL(raw string, allowPath bool) (string, error) { + raw = strings.TrimSpace(raw) + parsed, err := url.Parse(raw) + if err != nil || parsed.Host == "" { + return "", errors.New("URL is invalid") + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", errors.New("URL must be http or https") + } + if parsed.User != nil { + return "", errors.New("URL must not include user info") + } + host := parsed.Hostname() + if host == "" { + return "", errors.New("URL is missing a host") + } + if strings.EqualFold(host, "metadata.google.internal") { + return "", errors.New("URL host is not allowed") + } + if ip := net.ParseIP(host); ip != nil { + if ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() { + return "", errors.New("URL address is not allowed") + } + } + if !allowPath { + if parsed.Path != "" && parsed.Path != "/" { + return "", errors.New("device URL must be an origin without a path") + } + parsed.Path = "" + } + if parsed.RawQuery != "" || parsed.Fragment != "" { + return "", errors.New("URL must not include a query or fragment") + } + return strings.TrimRight(parsed.String(), "/"), nil +} diff --git a/selfhosted/internal/urls/urls_test.go b/selfhosted/internal/urls/urls_test.go new file mode 100644 index 000000000..169fb7708 --- /dev/null +++ b/selfhosted/internal/urls/urls_test.go @@ -0,0 +1,23 @@ +package urls + +import "testing" + +func TestNormalizeBaseURL(t *testing.T) { + got, err := NormalizeBaseURL("https://phone.example:8080/", false) + if err != nil || got != "https://phone.example:8080" { + t.Fatalf("got %q err %v", got, err) + } + if _, err := NormalizeBaseURL("http://10.1.2.3:8080", false); err != nil { + t.Fatal(err) + } + if _, err := NormalizeBaseURL("http://169.254.169.254/", false); err == nil { + t.Fatal("expected link-local metadata address to be rejected") + } + if _, err := NormalizeBaseURL("https://phone.example/api", false); err == nil { + t.Fatal("expected a path to be rejected for device origins") + } + got, err = NormalizeBaseURL("https://hooks.example/sms", true) + if err != nil || got != "https://hooks.example/sms" { + t.Fatalf("webhook url %q err %v", got, err) + } +}