diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..78845f2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.github +.vscode +.idea +.claude +*.md +LICENSE +*.db +*.db-journal +*.db-wal +*.db-shm +backend +hosted-backend +.env +.env.local diff --git a/.env.example b/.env.example index f4a28fc..386555f 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,47 @@ +# Copy to .env for local dev. obby-api is built locally; obbyircd +# and the obby web client are pulled from Dockerhub. In Coolify, +# set these as application environment variables instead. + +# --- DNS / Traefik routing (Coolify auto-issues LE) --- +WEB_FQDN=chat.example.com +API_FQDN=api.example.com +IRC_FQDN=irc.example.com + +# --- backend tuning --- PORT=8080 DELETE_TIMEOUT_MINUTES=2 MAX_UPLOAD_SIZE_MB=32 -JWT_SECRET=testsecret -IRC_SERVER_KEY=testkey PASSWORD_SALT_SIZE=32 -UNREALIRCD_WS_URL=wss://127.0.0.1:8600/ -UNREALIRCD_API_USERNAME=adminpanel -UNREALIRCD_API_PASSWORD=password \ No newline at end of file + +# --- secrets (set per environment) --- +JWT_SECRET=testsecret # (secret in prod) openssl rand -hex 32 +IRC_SERVER_KEY=testkey # (secret in prod) shared with obbyircd +VOICE_TURN_SECRET= # (secret) openssl rand -hex 32 +RPC_PASSWORD=password # (secret in prod) obbyircd JSON-RPC password + +# --- IRCd reachability --- +UNREALIRCD_WS_URL=ws://obbyircd:8080 +UNREALIRCD_API_USERNAME=admin +UNREALIRCD_API_PASSWORD=password # mirror of RPC_PASSWORD; unify in prod + +# --- voice / TURN --- +VOICE_PUBLIC_IP= # public IP advertised in ICE candidates +TURN_PORT=3478 # UDP, open in host firewall +VOICE_TURN_REALM=obby +VOICE_MAX_ROOM=25 + +# --- filehost --- +FILEHOST_PUBLIC_URL= # https://files.example.com + +# --- image overrides (optional) --- +# OBBY_API_IMAGE=obby-api:local +# OBBYIRCD_IMAGE=obbyworld/obbyircd:latest +# OBBY_WEB_IMAGE=obbyworld/obby:latest + +# --- frontend profile (opt-in) --- +# COMPOSE_PROFILES=frontend + +# --- bind-mount overrides (optional) --- +# OBBY_API_DATA_BIND=/srv/obby/obby-api-data +# OBBY_API_IMAGES_BIND=/srv/obby/obby-api-images +# VOICE_BRIDGE_BIND=/srv/obby/voice-bridge diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml new file mode 100644 index 0000000..7106a1d --- /dev/null +++ b/.github/workflows/docker-build-push.yml @@ -0,0 +1,105 @@ +name: Reusable Docker Build and Push + +on: + workflow_call: + inputs: + image_name: + required: true + type: string + context: + required: true + type: string + dockerfile: + required: true + type: string + secrets: + DOCKERHUB_USERNAME: + required: true + DOCKERHUB_TOKEN: + required: true + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-24.04 + - arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + if: ${{ github.event_name != 'pull_request' }} + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - id: meta + run: echo "short_sha=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT" + + - uses: docker/build-push-action@v6 + with: + context: ${{ inputs.context }} + file: ${{ inputs.dockerfile }} + platforms: linux/${{ matrix.arch }} + push: ${{ github.event_name != 'pull_request' }} + provenance: false + tags: ${{ secrets.DOCKERHUB_USERNAME }}/${{ inputs.image_name }}:sha-${{ steps.meta.outputs.short_sha }}-${{ matrix.arch }} + cache-from: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/${{ inputs.image_name }}:cache-${{ matrix.arch }} + cache-to: ${{ github.event_name != 'pull_request' && format('type=registry,ref={0}/{1}:cache-{2},mode=max,image-manifest=true,oci-mediatypes=true', secrets.DOCKERHUB_USERNAME, inputs.image_name, matrix.arch) || '' }} + + manifest: + needs: build + if: ${{ github.event_name != 'pull_request' }} + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKERHUB_USERNAME }}/${{ inputs.image_name }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha,prefix=sha-,format=short + type=raw,value=latest,enable={{is_default_branch}} + + - id: short + run: echo "sha=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT" + + - name: Assemble multi-arch manifest + shell: bash + env: + IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/${{ inputs.image_name }} + SHORT_SHA: ${{ steps.short.outputs.sha }} + TAGS: ${{ steps.meta.outputs.tags }} + run: | + src_amd64="${IMAGE}:sha-${SHORT_SHA}-amd64" + src_arm64="${IMAGE}:sha-${SHORT_SHA}-arm64" + tag_args=() + while IFS= read -r t; do + [ -n "$t" ] && tag_args+=( --tag "$t" ) + done <<< "$TAGS" + docker buildx imagetools create "${tag_args[@]}" "$src_amd64" "$src_arm64" + docker buildx imagetools inspect "${IMAGE}:sha-${SHORT_SHA}" diff --git a/.github/workflows/docker-obby-api.yml b/.github/workflows/docker-obby-api.yml new file mode 100644 index 0000000..e8e5db8 --- /dev/null +++ b/.github/workflows/docker-obby-api.yml @@ -0,0 +1,30 @@ +name: Build and Push obby-api Docker Image + +on: + push: + branches: + - main + tags: + - 'v*' + paths-ignore: + - '**.md' + - 'LICENSE' + pull_request: + branches: + - main + paths-ignore: + - '**.md' + - 'LICENSE' + workflow_dispatch: + +jobs: + build: + name: Build obby-api Image + uses: ./.github/workflows/docker-build-push.yml + with: + image_name: obby-api + context: . + dockerfile: ./Dockerfile + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a141259 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# syntax=docker/dockerfile:1.6 +# +# obby-api / hosted-backend image -- REST + JWT + WebRTC SFU + TURN. +# CGO_ENABLED=1 is required because mattn/go-sqlite3 ships as cgo. +# +# Build: docker build -t obbyworld/obby-api . +# Compose: docker compose up -d + +FROM golang:1.25-alpine AS builder +RUN apk add --no-cache build-base sqlite-dev opus-dev opusfile-dev pkgconfig +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /out/backend . + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates sqlite-libs tzdata netcat-openbsd opus opusfile \ + && adduser -D -u 1000 backend \ + && mkdir -p /app/data /app/images /run/obbyirc \ + && chown -R backend:backend /app /run/obbyirc +WORKDIR /app +COPY --from=builder /out/backend /usr/local/bin/backend +USER backend +EXPOSE 8080 3478/udp +HEALTHCHECK --interval=10s --timeout=3s --start-period=15s --retries=3 \ + CMD nc -z 127.0.0.1 8080 || exit 1 +CMD ["backend"] diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..fa2628f --- /dev/null +++ b/compose.yaml @@ -0,0 +1,111 @@ +# Local dev stack for obby-api. +# +# This service is built from the local checkout; obbyircd and the +# web client are pulled from Dockerhub. For full-stack development +# in one of the other repos, swap which service has `build:` and +# which has only `image:`. +# +# docker compose up -d # api + obbyircd +# docker compose --profile frontend up -d # + obby (web) + +services: + obby-api: + build: + context: . + dockerfile: ./Dockerfile + image: ${OBBY_API_IMAGE:-obby-api:local} + container_name: obby-api + restart: unless-stopped + depends_on: + obbyircd: + condition: service_healthy + expose: + - "8080" + ports: + - "${TURN_PORT:-3478}:3478/udp" + volumes: + - ${OBBY_API_DATA_BIND:-obby_api_data}:/app/data + - ${OBBY_API_IMAGES_BIND:-obby_api_images}:/app/images + - ${VOICE_BRIDGE_BIND:-obbyircd_voice_bridge}:/run/obbyirc + environment: + PORT: "8080" + VOICE_BRIDGE_SOCKET: /run/obbyirc/voice.sock + VOICE_TURN_SECRET: ${VOICE_TURN_SECRET} + VOICE_PUBLIC_IP: ${VOICE_PUBLIC_IP} + VOICE_TURN_PORT: "${TURN_PORT:-3478}" + VOICE_TURN_REALM: ${VOICE_TURN_REALM:-obby} + VOICE_MAX_ROOM: "${VOICE_MAX_ROOM:-25}" + JWT_SECRET: ${JWT_SECRET} + IRC_SERVER_KEY: ${IRC_SERVER_KEY} + UNREALIRCD_WS_URL: ${UNREALIRCD_WS_URL:-ws://obbyircd:8080} + UNREALIRCD_API_USERNAME: ${UNREALIRCD_API_USERNAME:-admin} + UNREALIRCD_API_PASSWORD: ${RPC_PASSWORD} + FILEHOST_PUBLIC_URL: ${FILEHOST_PUBLIC_URL} + env_file: + - path: .env + required: false + labels: + - traefik.enable=true + - traefik.http.routers.api.rule=Host(`${API_FQDN}`) + - traefik.http.routers.api.tls=true + - traefik.http.services.api.loadbalancer.server.port=8080 + + obbyircd: + image: ${OBBYIRCD_IMAGE:-obbyworld/obbyircd:latest} + container_name: obbyircd + restart: unless-stopped + expose: + - "8080" + ports: + - "${SSL_HOST_PORT:-6697}:${SSL_PORT:-6697}" + volumes: + - obbyircd_conf:/home/obbyircd/obby/conf + - obbyircd_data:/home/obbyircd/obby/data + - obbyircd_logs:/home/obbyircd/obby/logs + - obbyircd_tls:/home/obbyircd/obby/tls + - obbyircd_custom_modules:/home/obbyircd/obby/custom-modules + - ${VOICE_BRIDGE_BIND:-obbyircd_voice_bridge}:/run/obbyirc + environment: + WS_PORT: "${WS_PORT:-8080}" + SSL_PORT: "${SSL_PORT:-6697}" + VOICE_BRIDGE_SOCKET: /run/obbyirc/voice.sock + env_file: + - path: .env + required: false + healthcheck: + test: ["CMD-SHELL", "nc -z localhost ${WS_PORT:-8080}"] + interval: 5s + timeout: 5s + retries: 6 + start_period: 30s + labels: + - traefik.enable=true + - traefik.http.routers.obbyircd.rule=Host(`${IRC_FQDN}`) + - traefik.http.routers.obbyircd.tls=true + - traefik.http.services.obbyircd.loadbalancer.server.port=8080 + + obby: + image: ${OBBY_WEB_IMAGE:-obbyworld/obby:latest} + container_name: obby-web + restart: unless-stopped + profiles: ["frontend"] + expose: + - "80" + env_file: + - path: .env + required: false + labels: + - traefik.enable=true + - traefik.http.routers.web.rule=Host(`${WEB_FQDN}`) + - traefik.http.routers.web.tls=true + - traefik.http.services.web.loadbalancer.server.port=80 + +volumes: + obby_api_data: + obby_api_images: + obbyircd_conf: + obbyircd_data: + obbyircd_logs: + obbyircd_tls: + obbyircd_custom_modules: + obbyircd_voice_bridge: diff --git a/voice.go b/voice.go index 014de67..1ce92f4 100644 --- a/voice.go +++ b/voice.go @@ -3,7 +3,7 @@ // // Architecture: // -// ObsidianIRC client obbyircd hosted-backend (this file) +// obbyworld client obbyircd hosted-backend (this file) // ───────────────── ──────── ────────────── // TAGMSG ^vc @+obsidianirc/rtc=… → (received via bridge) // ↓ voice.c module @@ -35,8 +35,8 @@ import ( "sync" "time" - "github.com/pion/turn/v3" "github.com/pion/rtcp" + "github.com/pion/turn/v3" "github.com/pion/webrtc/v4" ) @@ -63,6 +63,15 @@ type VoiceConfig struct { MaxRoomSize int // ICE/TURN realm for the embedded TURN server. Realm string + // When true, skip starting the embedded TURN listener. The SFU + // still runs; clients are expected to receive TURN credentials + // from an external source (e.g. the IRCd's voice::turn rewrite). + DisableEmbeddedTurn bool + // Ephemeral UDP port range pion gathers ICE candidates on. Must + // be published in the container's port spec for clients to reach + // the SFU directly. + WebRTCPortMin uint16 + WebRTCPortMax uint16 } func loadVoiceConfig() VoiceConfig { @@ -90,6 +99,19 @@ func loadVoiceConfig() VoiceConfig { cfg.MaxRoomSize = p } } + if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("VOICE_TURN_DISABLE_EMBEDDED"))); err == nil { + cfg.DisableEmbeddedTurn = b + } + if v := os.Getenv("VOICE_WEBRTC_PORT_MIN"); v != "" { + if p, err := strconv.Atoi(v); err == nil && p > 0 && p < 65536 { + cfg.WebRTCPortMin = uint16(p) + } + } + if v := os.Getenv("VOICE_WEBRTC_PORT_MAX"); v != "" { + if p, err := strconv.Atoi(v); err == nil && p > 0 && p < 65536 { + cfg.WebRTCPortMax = uint16(p) + } + } return cfg } @@ -106,10 +128,10 @@ func loadVoiceConfig() VoiceConfig { // The TURN server uses the same secret to validate the password, // rejecting anything past the expiry. type TurnCreds struct { - Username string `json:"username"` - Password string `json:"password"` + Username string `json:"username"` + Password string `json:"password"` URLs []string `json:"urls"` - TTL int64 `json:"ttl"` + TTL int64 `json:"ttl"` } func mintTurnCreds(cfg VoiceConfig, account string, ttl time.Duration) TurnCreds { @@ -246,8 +268,8 @@ type signalEnvelope struct { SDP string `json:"sdp,omitempty"` // "ice" - Candidate string `json:"cand,omitempty"` - SDPMid string `json:"mid,omitempty"` + Candidate string `json:"cand,omitempty"` + SDPMid string `json:"mid,omitempty"` SDPMLineIndex *uint16 `json:"mlineidx,omitempty"` // state broadcast: "mic" / "video" / "speaking" / "silent" / @@ -378,6 +400,7 @@ type voicePeer struct { type voiceManager struct { cfg VoiceConfig + api *webrtc.API mu sync.RWMutex rooms map[string]*voiceRoom // Outgoing message channel back to the IRCd bridge. All @@ -394,22 +417,16 @@ type voiceManager struct { func newVoiceManager(cfg VoiceConfig) *voiceManager { return &voiceManager{ cfg: cfg, + api: newWebrtcAPI(cfg), rooms: map[string]*voiceRoom{}, } } -// peerConnectionConfig builds the ICE config that gets sent to clients -// (they run their own PCs and need our TURN URL/creds). +// Server-side PeerConnection only needs STUN; TURN relay is for the +// *client* side and is delivered via the "joined" envelope's `turn` +// field (mintTurnCreds). Including TURN here without credentials +// causes pion to reject NewPeerConnection. func (m *voiceManager) peerConnectionConfig() webrtc.Configuration { - host := m.cfg.PublicIP - if host == "" { - host = "127.0.0.1" - } - // Server-side PeerConnection only needs STUN; TURN relay is for - // the *client* side and is delivered via the "joined" envelope's - // `turn` field (mintTurnCreds). Including TURN here without - // credentials causes pion to reject NewPeerConnection. - _ = host return webrtc.Configuration{ ICEServers: []webrtc.ICEServer{ {URLs: []string{"stun:stun.l.google.com:19302"}}, @@ -417,6 +434,30 @@ func (m *voiceManager) peerConnectionConfig() webrtc.Configuration { } } +// newWebrtcAPI constructs a *webrtc.API with NAT1To1 + ephemeral-port +// settings so server-side PeerConnections advertise reachable host +// candidates when running behind container NAT. Without these, the +// SFU only emits container-internal IPs and Google-STUN-derived srflx +// candidates pointing at unmapped ephemeral ports, and ICE never +// converges from outside the host. +func newWebrtcAPI(cfg VoiceConfig) *webrtc.API { + se := webrtc.SettingEngine{} + if cfg.PublicIP != "" { + se.SetNAT1To1IPs([]string{cfg.PublicIP}, webrtc.ICECandidateTypeHost) + } + if cfg.WebRTCPortMin > 0 && cfg.WebRTCPortMax >= cfg.WebRTCPortMin { + if err := se.SetEphemeralUDPPortRange(cfg.WebRTCPortMin, cfg.WebRTCPortMax); err != nil { + log.Printf("voice: SetEphemeralUDPPortRange(%d,%d): %v", + cfg.WebRTCPortMin, cfg.WebRTCPortMax, err) + } + } + m := &webrtc.MediaEngine{} + if err := m.RegisterDefaultCodecs(); err != nil { + log.Printf("voice: RegisterDefaultCodecs: %v", err) + } + return webrtc.NewAPI(webrtc.WithSettingEngine(se), webrtc.WithMediaEngine(m)) +} + func (m *voiceManager) getOrCreateRoom(name string) *voiceRoom { m.mu.Lock() defer m.mu.Unlock() @@ -488,7 +529,7 @@ func (m *voiceManager) handleJoin(nick, channel, account string) { // Idempotent: re-issue a fresh PC if a stale one still exists. room.peers[nick].close() } - pc, err := webrtc.NewPeerConnection(m.peerConnectionConfig()) + pc, err := m.api.NewPeerConnection(m.peerConnectionConfig()) if err != nil { room.mu.Unlock() m.send(nick, signalEnvelope{ diff --git a/voice_bridge.go b/voice_bridge.go index 2e42cc6..048e603 100644 --- a/voice_bridge.go +++ b/voice_bridge.go @@ -39,6 +39,8 @@ import ( "sync" "sync/atomic" "time" + + "github.com/pion/turn/v3" ) type bridgeFrame struct { @@ -302,10 +304,18 @@ func startVoiceSubsystem(ctx context.Context) (*voiceManager, func()) { return nil, func() {} } - turnSrv, err := startTurnServer(cfg) - if err != nil { - log.Printf("voice: TURN startup: %v", err) - return nil, func() {} + var turnSrv *turn.Server + if cfg.DisableEmbeddedTurn { + log.Printf("voice: VOICE_TURN_DISABLE_EMBEDDED set; not starting embedded TURN") + } else { + + + s, err := startTurnServer(cfg) + if err != nil { + log.Printf("voice: TURN startup: %v", err) + return nil, func() {} + } + turnSrv = s } mgr := newVoiceManager(cfg) @@ -317,6 +327,9 @@ func startVoiceSubsystem(ctx context.Context) (*voiceManager, func()) { }() return mgr, func() { + if turnSrv == nil { + return + } shutdownCtx, cancel := context.WithTimeout( context.Background(), 5*time.Second) defer cancel()