mirror of
https://github.com/therootcompany/golib.git
synced 2026-03-13 12:27:59 +00:00
feat+ref(cmd/smsapid): add sms:* endpoints, csvauth, cli flags, etc
This commit is contained in:
parent
4bda5b4580
commit
516b23eac3
21
cmd/smsapid/cli.go
Normal file
21
cmd/smsapid/cli.go
Normal file
@ -0,0 +1,21 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ArgFields(list string, optionalDelim string, nothings []string) (args []string) {
|
||||
list = strings.ReplaceAll(list, optionalDelim, " ")
|
||||
list = strings.TrimSpace(list)
|
||||
if list == "" || slices.Contains(nothings, list) {
|
||||
return nil
|
||||
}
|
||||
|
||||
args = strings.Fields(list)
|
||||
if len(args) == 1 && args[0] == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
33
cmd/smsapid/delimiter.go
Normal file
33
cmd/smsapid/delimiter.go
Normal file
@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import "unicode/utf8"
|
||||
|
||||
const (
|
||||
fileSeparator = '\x1c'
|
||||
groupSeparator = '\x1d'
|
||||
recordSeparator = '\x1e'
|
||||
unitSeparator = '\x1f'
|
||||
)
|
||||
|
||||
func DecodeDelimiter(delimString string) (rune, error) {
|
||||
switch delimString {
|
||||
case "^_", "\\x1f":
|
||||
delimString = string(unitSeparator)
|
||||
case "^^", "\\x1e":
|
||||
delimString = string(recordSeparator)
|
||||
case "^]", "\\x1d":
|
||||
delimString = string(groupSeparator)
|
||||
case "^\\", "\\x1c":
|
||||
delimString = string(fileSeparator)
|
||||
case "^L", "\\f":
|
||||
delimString = "\f"
|
||||
case "^K", "\\v":
|
||||
delimString = "\v"
|
||||
case "^I", "\\t":
|
||||
delimString = "\t"
|
||||
default:
|
||||
// it is what it is
|
||||
}
|
||||
delim, _ := utf8.DecodeRuneInString(delimString)
|
||||
return delim, nil
|
||||
}
|
||||
@ -3,14 +3,28 @@ module github.com/therootcompany/golib/cmd/smsapid
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/jszwec/csvutil v1.10.0
|
||||
github.com/simonfrey/jsonl v0.0.0-20240904112901-935399b9a740
|
||||
github.com/therootcompany/golib/auth v1.1.1
|
||||
github.com/therootcompany/golib/auth/csvauth v1.2.3
|
||||
github.com/therootcompany/golib/colorjson v1.0.1
|
||||
github.com/therootcompany/golib/http/androidsmsgateway v0.0.0-20260223054429-c8f26aca7c6d
|
||||
github.com/therootcompany/golib/http/middleware/v2 v2.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
golang.org/x/sys v0.25.0 // indirect
|
||||
golang.org/x/crypto v0.42.0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
)
|
||||
|
||||
replace (
|
||||
github.com/therootcompany/golib/auth => ../../auth
|
||||
github.com/therootcompany/golib/auth/csvauth => ../../auth/csvauth
|
||||
github.com/therootcompany/golib/http/androidsmsgateway => ../../http/androidsmsgateway
|
||||
github.com/therootcompany/golib/http/middleware/v2 => ../../http/middleware
|
||||
)
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/jszwec/csvutil v1.10.0 h1:upMDUxhQKqZ5ZDCs/wy+8Kib8rZR8I8lOR34yJkdqhI=
|
||||
github.com/jszwec/csvutil v1.10.0/go.mod h1:/E4ONrmGkwmWsk9ae9jpXnv9QT8pLHEPcCirMFhxG9I=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
@ -9,9 +15,9 @@ github.com/simonfrey/jsonl v0.0.0-20240904112901-935399b9a740 h1:CXJI+lliMiiEwzf
|
||||
github.com/simonfrey/jsonl v0.0.0-20240904112901-935399b9a740/go.mod h1:G4w16caPmc6at7u4fmkj/8OAoOnM9mkmJr2fvL0vhaw=
|
||||
github.com/therootcompany/golib/colorjson v1.0.1 h1:AfBeVr9GX9xMvlJNFmFYzkWFy62yWwwXjX2LLA/Afto=
|
||||
github.com/therootcompany/golib/colorjson v1.0.1/go.mod h1:bE0wCyOsRFQnz22+TnQu4D0+FPl+ZugaaE79bjgDqRw=
|
||||
github.com/therootcompany/golib/http/androidsmsgateway v0.0.0-20260223054429-c8f26aca7c6d h1:jKf9QQUiGAHsrjkfpoo4FTQnFJu4UkDkPreZLll7tdE=
|
||||
github.com/therootcompany/golib/http/androidsmsgateway v0.0.0-20260223054429-c8f26aca7c6d/go.mod h1:2O9+uXPc1VAJvveK9eqm9X4e4pTJmFWV6vtJa3sI/CA=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
||||
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
|
||||
@ -2,50 +2,252 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/simonfrey/jsonl"
|
||||
"github.com/therootcompany/golib/auth"
|
||||
"github.com/therootcompany/golib/auth/csvauth"
|
||||
"github.com/therootcompany/golib/colorjson"
|
||||
"github.com/therootcompany/golib/http/androidsmsgateway"
|
||||
"github.com/therootcompany/golib/http/middleware/v2"
|
||||
|
||||
chiware "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/jszwec/csvutil"
|
||||
"github.com/simonfrey/jsonl"
|
||||
)
|
||||
|
||||
var jsonf = colorjson.NewFormatter()
|
||||
const (
|
||||
name = "smsapid"
|
||||
desc = "for self-hosting android-sms-gateway"
|
||||
licenseYear = "2026"
|
||||
licenseOwner = "AJ ONeal <aj@therootcompany.com> (https://therootcompany.com)"
|
||||
licenseType = "MPL-2.0"
|
||||
)
|
||||
|
||||
var webhookEvents []androidsmsgateway.WebhookEvent
|
||||
var webhookWriter jsonl.Writer
|
||||
var webhookMux = sync.Mutex{}
|
||||
// replaced by goreleaser / ldflags
|
||||
var (
|
||||
version = "0.0.0-dev"
|
||||
commit = "0000000"
|
||||
date = "0001-01-01"
|
||||
)
|
||||
|
||||
// printVersion displays the version, commit, and build date.
|
||||
func printVersion(w io.Writer) {
|
||||
_, _ = fmt.Fprintf(w, "%s v%s %s (%s)\n", name, version, commit[:7], date)
|
||||
_, _ = fmt.Fprintf(w, "%s\n", desc)
|
||||
_, _ = fmt.Fprintf(w, "Copyright (C) %s %s\n", licenseYear, licenseOwner)
|
||||
_, _ = fmt.Fprintf(w, "Licensed under %s\n", licenseType)
|
||||
}
|
||||
|
||||
var (
|
||||
ErrNoAuth = errors.New("request missing the required form of authorization")
|
||||
)
|
||||
|
||||
var (
|
||||
jsonf = colorjson.NewFormatter()
|
||||
|
||||
// webhookMux protects webhookWriter, pingWriter, webhookEvents, and pingEvents.
|
||||
webhookMux = sync.Mutex{}
|
||||
webhookEvents []androidsmsgateway.WebhookEvent
|
||||
webhookWriter jsonl.Writer
|
||||
pingEvents []*androidsmsgateway.WebhookPing
|
||||
pingWriter jsonl.Writer
|
||||
|
||||
smsgwSigningKey string
|
||||
smsRequestAuth *auth.BasicRequestAuthenticator
|
||||
// TODO
|
||||
// smsgwUsername string
|
||||
// smsgwPassword string
|
||||
)
|
||||
|
||||
type MainConfig struct {
|
||||
Bind string
|
||||
Port int
|
||||
credsPath string
|
||||
credsComma rune
|
||||
credsCommaString string
|
||||
AES128KeyPath string
|
||||
ShowVersion bool
|
||||
BasicRealm string
|
||||
AuthorizationHeaderSchemes []string
|
||||
TokenHeaderNames []string
|
||||
QueryParamNames []string
|
||||
tokenSchemeList string
|
||||
tokenHeaderList string
|
||||
tokenParamList string
|
||||
// TODO
|
||||
// SMSGatewayURL string
|
||||
}
|
||||
|
||||
func (c *MainConfig) Addr() string {
|
||||
return fmt.Sprintf("%s:%d", c.Bind, c.Port)
|
||||
}
|
||||
|
||||
func main() {
|
||||
cli := MainConfig{
|
||||
Bind: "0.0.0.0",
|
||||
Port: 8080,
|
||||
credsPath: "./credentials.tsv",
|
||||
AES128KeyPath: filepath.Join("~", ".config", "csvauth", "aes-128.key"),
|
||||
credsComma: '\t',
|
||||
tokenSchemeList: "",
|
||||
tokenHeaderList: "",
|
||||
tokenParamList: "",
|
||||
BasicRealm: "Basic",
|
||||
AuthorizationHeaderSchemes: nil, // []string{"Bearer", "Token"}
|
||||
TokenHeaderNames: nil, // []string{"X-API-Key", "X-Auth-Token", "X-Access-Token"},
|
||||
QueryParamNames: nil, // []string{"access_token", "token"},
|
||||
}
|
||||
|
||||
// Override defaults from env
|
||||
if v := os.Getenv("SMSAPID_PORT"); v != "" {
|
||||
if _, err := fmt.Sscanf(v, "%d", &cli.Port); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "invalid SMSAPID_PORT value: %s\n", v)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
if v := os.Getenv("SMSAPID_ADDRESS"); v != "" {
|
||||
cli.Bind = v
|
||||
}
|
||||
if v := os.Getenv("SMSAPID_CREDENTIALS_FILE"); v != "" {
|
||||
cli.credsPath = v
|
||||
}
|
||||
|
||||
// Flags
|
||||
fs := flag.NewFlagSet(name, flag.ContinueOnError)
|
||||
fs.BoolVar(&cli.ShowVersion, "version", false, "show version and exit")
|
||||
fs.IntVar(&cli.Port, "port", cli.Port, "port to listen on")
|
||||
fs.StringVar(&cli.Bind, "address", cli.Bind, "address to bind to (e.g. 127.0.0.1)")
|
||||
fs.StringVar(&cli.AES128KeyPath, "creds-key-file", cli.AES128KeyPath, "path to credentials TSV/CSV file")
|
||||
fs.StringVar(&cli.credsPath, "creds-file", cli.credsPath, "path to credentials TSV/CSV file")
|
||||
fs.StringVar(&cli.credsCommaString, "creds-comma", "\\t", "single-character CSV separator for credentials file (literal characters and escapes accepted)")
|
||||
fs.StringVar(&cli.tokenSchemeList, "token-schemes", "Bearer,Token", "checks for header 'Authorization: <Scheme> <token>'")
|
||||
fs.StringVar(&cli.tokenHeaderList, "token-headers", "X-API-Key,X-Auth-Token,X-Access-Token", "checks for header '<API-Key-Header>: <token>'")
|
||||
fs.StringVar(&cli.tokenParamList, "token-params", "access_token,token", "checks for query param '?<param>=<token>'")
|
||||
// TODO
|
||||
// fs.StringVar(&cli.SMSGatewayURL, "sms-gateway-url", "", "URL of the phone running android-sms-gateway")
|
||||
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, "USAGE\n %s [flags]\n\n", name)
|
||||
fmt.Fprintf(os.Stderr, "FLAGS\n")
|
||||
fs.PrintDefaults()
|
||||
fmt.Fprintf(os.Stderr, "\nENVIRONMENT\n")
|
||||
fmt.Fprintf(os.Stderr, " SMSAPID_PORT port to listen on\n")
|
||||
fmt.Fprintf(os.Stderr, " SMSAPID_ADDRESS bind address\n")
|
||||
fmt.Fprintf(os.Stderr, " SMSAPID_CREDENTIALS_FILE path to tokens file\n")
|
||||
fmt.Fprintf(os.Stderr, " SMS_GATEWAY_USERNAME android-sms-gateway basic auth username\n")
|
||||
fmt.Fprintf(os.Stderr, " SMS_GATEWAY_PASSWORD android-sms-gateway basic auth password\n")
|
||||
fmt.Fprintf(os.Stderr, " SMS_GATEWAY_SIGNING_KEY android-sms-gateway signing key for webhooks\n")
|
||||
}
|
||||
|
||||
// Special handling for version/help
|
||||
if len(os.Args) > 1 {
|
||||
arg := os.Args[1]
|
||||
switch arg {
|
||||
case "-V", "version", "-version", "--version":
|
||||
printVersion(os.Stdout)
|
||||
os.Exit(0)
|
||||
case "help", "-help", "--help":
|
||||
printVersion(os.Stdout)
|
||||
_, _ = fmt.Fprintln(os.Stdout, "")
|
||||
fs.SetOutput(os.Stdout)
|
||||
fs.Usage()
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
printVersion(os.Stderr)
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
|
||||
if err := fs.Parse(os.Args[1:]); err != nil {
|
||||
if err == flag.ErrHelp {
|
||||
fs.Usage()
|
||||
os.Exit(0)
|
||||
}
|
||||
log.Fatalf("flag parse error: %v", err)
|
||||
}
|
||||
|
||||
{
|
||||
homedir, err := os.UserHomeDir()
|
||||
if err == nil {
|
||||
var found bool
|
||||
if cli.AES128KeyPath, found = strings.CutPrefix(cli.AES128KeyPath, "~"); found {
|
||||
cli.AES128KeyPath = homedir + cli.AES128KeyPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cli.AuthorizationHeaderSchemes = ArgFields(cli.tokenSchemeList, ",", []string{"none"})
|
||||
cli.TokenHeaderNames = ArgFields(cli.tokenHeaderList, ",", []string{"none"})
|
||||
cli.QueryParamNames = ArgFields(cli.tokenParamList, ",", []string{"none"})
|
||||
|
||||
// Load credentials for /api/smsgw routes.
|
||||
var smsAuth *csvauth.Auth
|
||||
credPath := "./credentials.tsv"
|
||||
if v := os.Getenv("SMSAPID_CREDENTIALS_FILE"); v != "" {
|
||||
credPath = v
|
||||
}
|
||||
f, err := os.Open(credPath)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to load credentials from %q: %v", credPath, err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
aesKey, err := getAESKey("CSVAUTH_AES_128_KEY", cli.AES128KeyPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
smsAuth = csvauth.New(aesKey)
|
||||
if err := smsAuth.LoadCSV(f, cli.credsComma); err != nil {
|
||||
log.Fatalf("failed to load credentials from %q: %v\n", credPath, err)
|
||||
}
|
||||
smsRequestAuth = auth.NewBasicRequestAuthenticator(smsAuth)
|
||||
|
||||
// Load optional webhook signing key.
|
||||
smsgwSigningKey = os.Getenv("SMS_GATEWAY_SIGNING_KEY")
|
||||
// TODO
|
||||
// smsgwUsername = os.Getenv("SMS_GATEWAY_USERNAME")
|
||||
// smsgwPassword = os.Getenv("SMS_GATEWAY_PASSWORD")
|
||||
|
||||
// credentials file delimiter
|
||||
cli.credsComma, err = DecodeDelimiter(cli.credsCommaString)
|
||||
if err != nil {
|
||||
log.Fatalf("comma parse error: %v", err)
|
||||
}
|
||||
|
||||
cli.run()
|
||||
}
|
||||
|
||||
func (cli *MainConfig) run() {
|
||||
jsonf.Indent = 3
|
||||
|
||||
// TODO manual override via flags
|
||||
// color.NoColor = false
|
||||
|
||||
filePath := "./messages.jsonl"
|
||||
messagesPath := "./messages.jsonl"
|
||||
{
|
||||
file, err := os.OpenFile(filePath, os.O_RDONLY|os.O_CREATE, 0644)
|
||||
file, err := os.OpenFile(messagesPath, os.O_RDONLY|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to open file '%s': %v", filePath, err)
|
||||
log.Fatalf("failed to open file '%s': %v", messagesPath, err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
// buf := bufio.NewReader(file)
|
||||
buf := file
|
||||
webhookEvents, err = readWebhooks(buf)
|
||||
webhookEvents, err = readWebhooks(file)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to read jsonl file '%s': %v", filePath, err)
|
||||
log.Fatalf("failed to read jsonl file '%s': %v", messagesPath, err)
|
||||
}
|
||||
}
|
||||
{
|
||||
file, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
file, err := os.OpenFile(messagesPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to open file: %v", err))
|
||||
}
|
||||
@ -54,21 +256,211 @@ func main() {
|
||||
webhookWriter = jsonl.NewWriter(file)
|
||||
}
|
||||
|
||||
pingsPath := "./pings.jsonl"
|
||||
{
|
||||
file, err := os.OpenFile(pingsPath, os.O_RDONLY|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to open file '%s': %v", pingsPath, err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
pingEvents, err = readPings(file)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to read jsonl file '%s': %v", pingsPath, err)
|
||||
}
|
||||
}
|
||||
{
|
||||
file, err := os.OpenFile(pingsPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to open file: %v", err))
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
pingWriter = jsonl.NewWriter(file)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /api/webhooks", handlerWebhooks)
|
||||
mux.Handle("GET /", LogRequest(http.HandlerFunc(HandleOK)))
|
||||
mux.Handle("POST /", LogRequest(http.HandlerFunc(handler)))
|
||||
mux.Handle("PATCH /", LogRequest(http.HandlerFunc(HandleOK)))
|
||||
mux.Handle("PUT /", LogRequest(http.HandlerFunc(HandleOK)))
|
||||
mux.Handle("DELETE /", LogRequest(http.HandlerFunc(HandleOK)))
|
||||
|
||||
addr := "localhost:8088"
|
||||
// Protected routes under /api/smsgw, each guarded by its specific sms:* permission.
|
||||
smsgw := middleware.WithMux(mux, LogRequest)
|
||||
smsgw.With(requireSMSPermission("sms:received")).HandleFunc("GET /api/smsgw/received.csv", handlerReceived)
|
||||
smsgw.With(requireSMSPermission("sms:received")).HandleFunc("GET /api/smsgw/received.json", handlerReceived)
|
||||
smsgw.With(requireSMSPermission("sms:sent")).HandleFunc("GET /api/smsgw/sent.csv", handlerSent)
|
||||
smsgw.With(requireSMSPermission("sms:sent")).HandleFunc("GET /api/smsgw/sent.json", handlerSent)
|
||||
smsgw.With(requireSMSPermission("sms:ping")).HandleFunc("GET /api/smsgw/ping.csv", handlerPing)
|
||||
smsgw.With(requireSMSPermission("sms:ping")).HandleFunc("GET /api/smsgw/ping.json", handlerPing)
|
||||
|
||||
addr := cli.Addr()
|
||||
fmt.Printf("Listening on %s...\n\n", addr)
|
||||
log.Fatal(http.ListenAndServe(addr, mux))
|
||||
log.Fatal(http.ListenAndServe(addr, chiware.Logger(chiware.Compress(5)(mux))))
|
||||
}
|
||||
|
||||
func HandleOK(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
// hasSMSPermission reports whether perms includes the wildcard "sms:*" or the specific permission.
|
||||
func hasSMSPermission(perms []string, permission string) bool {
|
||||
for _, p := range perms {
|
||||
if p == "sms:*" || p == permission {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// requireSMSPermission returns a middleware that authenticates the request and enforces
|
||||
// that the credential holds "sms:*" or the given specific permission.
|
||||
func requireSMSPermission(permission string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cred, err := smsRequestAuth.Authenticate(r)
|
||||
if err != nil || !hasSMSPermission(cred.Permissions(), permission) {
|
||||
w.Header().Set("WWW-Authenticate", smsRequestAuth.BasicRealm)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// getAESKey reads an AES-128 key (32 hex chars) from an environment variable.
|
||||
func getAESKey(envname, filename string) ([]byte, error) {
|
||||
envKey := os.Getenv(envname)
|
||||
if envKey != "" {
|
||||
key, err := hex.DecodeString(strings.TrimSpace(envKey))
|
||||
if err != nil || len(key) != 16 {
|
||||
return nil, fmt.Errorf("invalid %s: must be 32-char hex string", envname)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Found AES Key in %s\n", envname)
|
||||
return key, nil
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filename); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read %s: %v", filename, err)
|
||||
}
|
||||
key, err := hex.DecodeString(strings.TrimSpace(string(data)))
|
||||
if err != nil || len(key) != 16 {
|
||||
return nil, fmt.Errorf("invalid key in %s: must be 32-char hex string", filename)
|
||||
}
|
||||
// relpath := strings.Replace(filename, homedir, "~", 1)
|
||||
fmt.Fprintf(os.Stderr, "Found AES Key at %s\n", filename)
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// parseSinceLimit extracts the "since" (ISO datetime) and "limit" query parameters.
|
||||
func parseSinceLimit(r *http.Request) (time.Time, int) {
|
||||
var since time.Time
|
||||
if s := r.URL.Query().Get("since"); s != "" {
|
||||
for _, format := range []string{time.RFC3339, "2006-01-02T15:04:05-0700", "2006-01-02"} {
|
||||
if t, err := time.Parse(format, s); err == nil {
|
||||
since = t
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
limit := 10_000
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if n, err := strconv.Atoi(strings.ReplaceAll(l, "_", "")); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
|
||||
return since, limit
|
||||
}
|
||||
|
||||
func handlerReceived(w http.ResponseWriter, r *http.Request) {
|
||||
since, limit := parseSinceLimit(r)
|
||||
|
||||
webhookMux.Lock()
|
||||
rows := make([]*androidsmsgateway.WebhookReceived, 0, min(len(webhookEvents), limit))
|
||||
for _, event := range webhookEvents {
|
||||
recv, ok := event.(*androidsmsgateway.WebhookReceived)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !since.IsZero() && !recv.Payload.ReceivedAt.After(since) {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, recv)
|
||||
if len(rows) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
webhookMux.Unlock()
|
||||
|
||||
serveCSVOrJSON(w, r, rows)
|
||||
}
|
||||
|
||||
func handlerSent(w http.ResponseWriter, r *http.Request) {
|
||||
since, limit := parseSinceLimit(r)
|
||||
|
||||
webhookMux.Lock()
|
||||
rows := make([]*androidsmsgateway.WebhookSent, 0, min(len(webhookEvents), limit))
|
||||
for _, event := range webhookEvents {
|
||||
sent, ok := event.(*androidsmsgateway.WebhookSent)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !since.IsZero() && !sent.Payload.SentAt.After(since) {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, sent)
|
||||
if len(rows) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
webhookMux.Unlock()
|
||||
|
||||
serveCSVOrJSON(w, r, rows)
|
||||
}
|
||||
|
||||
func handlerPing(w http.ResponseWriter, r *http.Request) {
|
||||
since, limit := parseSinceLimit(r)
|
||||
|
||||
webhookMux.Lock()
|
||||
rows := make([]*androidsmsgateway.WebhookPing, 0, min(len(pingEvents), limit))
|
||||
for _, ping := range pingEvents {
|
||||
pingedAt := ping.PingedAt
|
||||
if pingedAt.IsZero() {
|
||||
pingedAt = time.UnixMilli(ping.XTimestamp).UTC()
|
||||
}
|
||||
if !since.IsZero() && !pingedAt.After(since) {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, ping)
|
||||
if len(rows) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
webhookMux.Unlock()
|
||||
|
||||
serveCSVOrJSON(w, r, rows)
|
||||
}
|
||||
|
||||
// serveCSVOrJSON writes v as CSV when the request path ends with ".csv", otherwise as JSON.
|
||||
func serveCSVOrJSON[T any](w http.ResponseWriter, r *http.Request, v []T) {
|
||||
if strings.HasSuffix(r.URL.Path, ".csv") {
|
||||
b, err := csvutil.Marshal(v)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to encode CSV"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
_, _ = w.Write(b)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetEscapeHTML(false)
|
||||
_ = enc.Encode(v)
|
||||
}
|
||||
|
||||
type ctxKey struct{}
|
||||
@ -118,7 +510,7 @@ func LogBody(next http.Handler) http.Handler {
|
||||
fmt.Fprintf(os.Stderr, "Unexpected body:\n%q\n", string(body))
|
||||
}
|
||||
case "POST", "PATCH", "PUT":
|
||||
// known
|
||||
// known
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Unexpected method %s\n", r.Method)
|
||||
}
|
||||
@ -249,6 +641,13 @@ func handler(w http.ResponseWriter, r *http.Request) {
|
||||
webhook.XTimestamp = int64(ts)
|
||||
webhook.XSignature = r.Header.Get("X-Signature")
|
||||
|
||||
if smsgwSigningKey != "" {
|
||||
if !androidsmsgateway.VerifySignature(smsgwSigningKey, string(body), r.Header.Get("X-Timestamp"), webhook.XSignature) {
|
||||
http.Error(w, `{"error":"invalid signature"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
h, err := androidsmsgateway.Decode(&webhook)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to parse webhook as a specific event"}`, http.StatusOK)
|
||||
@ -256,6 +655,16 @@ func handler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
switch h.GetEvent() {
|
||||
case "system:ping":
|
||||
ping := h.(*androidsmsgateway.WebhookPing)
|
||||
ping.PingedAt = time.UnixMilli(webhook.XTimestamp).UTC()
|
||||
webhookMux.Lock()
|
||||
defer webhookMux.Unlock()
|
||||
if err := pingWriter.Write(ping); err != nil {
|
||||
http.Error(w, `{"error":"failed to save ping"}`, http.StatusOK)
|
||||
return
|
||||
}
|
||||
pingEvents = append(pingEvents, ping)
|
||||
case "mms:received", "sms:received", "sms:data-received", "sms:sent", "sms:delivered", "sms:failed":
|
||||
webhookMux.Lock()
|
||||
defer webhookMux.Unlock()
|
||||
@ -264,8 +673,6 @@ func handler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
webhookEvents = append(webhookEvents, h)
|
||||
case "system:ping":
|
||||
// nothing to do yet
|
||||
default:
|
||||
http.Error(w, `{"error":"unknown webhook event"}`, http.StatusOK)
|
||||
return
|
||||
@ -325,3 +732,24 @@ func readWebhooks(f io.Reader) ([]androidsmsgateway.WebhookEvent, error) {
|
||||
}
|
||||
return webhooks, nil
|
||||
}
|
||||
|
||||
func readPings(f io.Reader) ([]*androidsmsgateway.WebhookPing, error) {
|
||||
var pings []*androidsmsgateway.WebhookPing
|
||||
r := jsonl.NewReader(f)
|
||||
err := r.ReadLines(func(line []byte) error {
|
||||
if len(line) == 0 {
|
||||
return nil
|
||||
}
|
||||
var ping androidsmsgateway.WebhookPing
|
||||
if err := json.Unmarshal(line, &ping); err != nil {
|
||||
return fmt.Errorf("could not unmarshal into WebhookPing: %w", err)
|
||||
}
|
||||
pings = append(pings, &ping)
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return pings, fmt.Errorf("failed to read JSONL lines: %w", err)
|
||||
}
|
||||
return pings, nil
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user