Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions .github/workflows/selfhosted.yml
Original file line number Diff line number Diff line change
@@ -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 ./...
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
.env*
!.env.docker
!.env.production
!.env.example
certs/
*.keystore
*.jks
*.p12
*serviceAccountKey.json
android/app/debug/
*main.exe*
Expand Down
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
74 changes: 74 additions & 0 deletions docker-compose.selfhosted.yml
Original file line number Diff line number Diff line change
@@ -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:
66 changes: 66 additions & 0 deletions docs/godesk-sms-architecture.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions selfhosted/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
123 changes: 123 additions & 0 deletions selfhosted/cmd/godesk-sms/main.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading