Skip to content

Commit 7a2ed17

Browse files
authored
cmd/bsky-webhook: add optional support for a secrets service (#3)
- When --secrets-url is set, the program will attempt to fetch its webhook URL and app key from the specified setec server, overriding the values set on the command line. Include environment hooks. - Plumb a context to handle signals. - Plumb an HTTP client (currently only the default) so we can stub in a proxy. - Update configuration docs in README.
1 parent 2d72fd0 commit 7a2ed17

File tree

4 files changed

+141
-37
lines changed

4 files changed

+141
-37
lines changed

README.md

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,21 @@ go run ./cmd/bsky-webhook/ -bskyHandle me.example.com -watchWord "pangolin"
1515
## Configuration
1616

1717
These configuration options are available as command-line flags and
18-
environment variables. Those without defaults are required.
18+
environment variables. Those without defaults are required, unless
19+
explicitly marked as optional.
1920

2021
Here's the complete table based on the provided Go code:
2122

22-
| Command-line flag | Environment variable | Default value | Description |
23-
| ------------------ | -------------------- | --------------------------------------- | ------------------------------------------------------------------------------------ |
24-
| `-addr` | `JETSTREAM_ADDRESS` | Rotation of all public jetsream servers | The [jetstream](https://github.com/bluesky-social/jetstream) hostname to connect to. |
25-
| `-bsky-handle` | `BSKY_HANDLE` | none | The Bluesky handle of the account that will make API requests. |
26-
| `-bsky-app-password` | `BSKY_APP_PASSWORD` | none | The Bluesky app password for authentication. |
27-
| `-slack-webhook-url` | `SLACK_WEBHOOK_URL` | none | The Slack webhook URL for sending notifications. |
28-
| `-bsky-server-url` | `BSKY_SERVER_URL` | "https://bsky.network" | The Bluesky PDS server to send API requests to URL. |
29-
| `-watch-word` | `WATCH_WORD` | "tailscale" | The word to watch out for; may support multiple words in the future. |
30-
23+
| Command-line flag | Environment variable | Default value | Description |
24+
|----------------------|----------------------|-----------------------------------------|-------------------------------------------------------------------------|
25+
| `-addr` | `JETSTREAM_ADDRESS` | Rotation of all public jetsream servers | The [jetstream][jetstream] hostname to connect to. |
26+
| `-bsky-handle` | `BSKY_HANDLE` | none | The Bluesky handle of the account that will make API requests. |
27+
| `-bsky-app-password` | `BSKY_APP_PASSWORD` | none | The Bluesky app password for authentication. |
28+
| `-slack-webhook-url` | `SLACK_WEBHOOK_URL` | none | The Slack webhook URL for sending notifications. |
29+
| `-bsky-server-url` | `BSKY_SERVER_URL` | "https://bsky.social" | The Bluesky PDS server to send API requests to URL. |
30+
| `-watch-word` | `WATCH_WORD` | "tailscale" | The word to watch out for; may support multiple words in the future. |
31+
| `-secrets-url` | `SECRETS_URL` | none | The address of a [setec][setec] server to fetch secrets from (optional) |
32+
| `-secrets-prefix` | `SECRETS_PREFIX` | "" | A prefix to prepend to secret names fetched from setec (optional) |
33+
34+
[jetstream]: https://github.com/bluesky-social/jetstream
35+
[setec]: https://github.com/tailscale/setec

cmd/bsky-webhook/main.go

Lines changed: 55 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,17 @@ import (
1414
"net/http"
1515
"net/url"
1616
"os"
17+
"os/signal"
18+
"path"
1719
"strings"
20+
"syscall"
1821
"time"
1922

2023
"github.com/bluesky-social/jetstream/pkg/models"
2124
"github.com/gorilla/websocket"
22-
"github.com/karalabe/go-bluesky"
25+
bluesky "github.com/karalabe/go-bluesky"
2326
"github.com/klauspost/compress/zstd"
27+
"github.com/tailscale/setec/client/setec"
2428
)
2529

2630
var (
@@ -33,9 +37,14 @@ var (
3337
webhookURL = flag.String("slack-webhook-url", envOr("SLACK_WEBHOOK_URL", ""),
3438
"slack webhook URL (required)")
3539
bskyServerURL = flag.String("bsky-server-url", envOr("BSKY_SERVER_URL",
36-
"https://bsky.network"), "bluesky PDS server URL")
40+
"https://bsky.social"), "bluesky PDS server URL")
3741
watchWord = flag.String("watch-word", envOr("WATCH_WORD", "tailscale"),
3842
"the word to watch out for. may be multiple words in future (required)")
43+
44+
secretsURL = flag.String("secrets-url", envOr("SECRETS_URL", ""),
45+
"the URL of a secrets server (if empty, no server is used)")
46+
secretsPrefix = flag.String("secrets-prefix", envOr("SECRETS_PREFIX", ""),
47+
"the prefix to prepend to secret names fetched from --secrets-url")
3948
)
4049

4150
// Public addresses of jetstream websocket services.
@@ -51,6 +60,10 @@ var jetstreams = []string{
5160
// Only the DecodeAll method may be used.
5261
var zstdDecoder *zstd.Decoder
5362

63+
// httpClient must be used for all HTTP requests. It is a variable so that it
64+
// can be replaced with a proxy.
65+
var httpClient = http.DefaultClient
66+
5467
func init() {
5568
// Jetstream uses a custom zstd dictionary, so make sure we do the same.
5669
var err error
@@ -65,20 +78,38 @@ func main() {
6578
// TODO(creachadair): Usage text.
6679

6780
switch {
68-
case *webhookURL == "":
81+
case *webhookURL == "" && *secretsURL == "":
6982
log.Fatal("missing slack webhook URL (SLACK_WEBHOOK_URL)")
7083
case *bskyServerURL == "":
7184
log.Fatal("missing Bluesky server URL (BSKY_SERVER_URL)")
7285
case *bskyHandle == "":
7386
log.Fatal("Missing Bluesky account handle (BSKY_HANDLE)")
74-
case *bskyAppKey == "":
87+
case *bskyAppKey == "" && *secretsURL == "":
7588
log.Fatal("missing Bluesky app secret (BSKY_APP_PASSWORD)")
7689
case *watchWord == "":
7790
log.Fatal("missing watchword")
7891
}
7992

93+
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
94+
defer cancel()
95+
96+
if *secretsURL != "" {
97+
webhookSecret := path.Join(*secretsPrefix, "slack-webhook-url")
98+
appKeySecret := path.Join(*secretsPrefix, "bluesky-app-key")
99+
st, err := setec.NewStore(ctx, setec.StoreConfig{
100+
Client: setec.Client{Server: *secretsURL, DoHTTP: httpClient.Do},
101+
Secrets: []string{webhookSecret, appKeySecret},
102+
})
103+
if err != nil {
104+
log.Fatalf("initialize secrets store: %v", err)
105+
}
106+
*webhookURL = st.Secret(webhookSecret).GetString()
107+
*bskyAppKey = st.Secret(appKeySecret).GetString()
108+
log.Printf("Fetched client secrets from %q", *secretsURL)
109+
}
110+
80111
nextAddr := nextWSAddress()
81-
for {
112+
for ctx.Err() == nil {
82113
wsURL := url.URL{
83114
Scheme: "wss",
84115
Host: nextAddr(),
@@ -87,7 +118,7 @@ func main() {
87118
}
88119
slog.Info("ws connecting", "url", wsURL.String())
89120

90-
err := websocketConnection(wsURL)
121+
err := websocketConnection(ctx, wsURL)
91122
slog.Error("ws connection", "url", wsURL, "err", err)
92123

93124
// TODO(erisa): exponential backoff
@@ -119,13 +150,12 @@ func nextWSAddress() func() string {
119150
}
120151
}
121152

122-
func websocketConnection(wsUrl url.URL) error {
153+
func websocketConnection(ctx context.Context, wsUrl url.URL) error {
123154
// add compression headers
124155
headers := http.Header{}
125156
headers.Add("Socket-Encoding", "zstd")
126157

127158
c, _, err := websocket.DefaultDialer.Dial(wsUrl.String(), headers)
128-
129159
if err != nil {
130160
return fmt.Errorf("dial jetstream: %v", err)
131161
}
@@ -135,9 +165,7 @@ func websocketConnection(wsUrl url.URL) error {
135165
return nil
136166
})
137167

138-
ctx := context.Background()
139-
140-
bsky, err := bluesky.Dial(ctx, *bskyServerURL)
168+
bsky, err := bluesky.DialWithClient(ctx, *bskyServerURL, httpClient)
141169
if err != nil {
142170
log.Fatal("dial bsky: ", err)
143171
}
@@ -148,7 +176,7 @@ func websocketConnection(wsUrl url.URL) error {
148176
log.Fatal("login bsky: ", err)
149177
}
150178

151-
for {
179+
for ctx.Err() == nil {
152180
// bail if we take too long for a read
153181
c.SetReadDeadline(time.Now().Add(time.Second * 5))
154182

@@ -157,15 +185,16 @@ func websocketConnection(wsUrl url.URL) error {
157185
return err
158186
}
159187

160-
err = readJetstreamMessage(jetstreamMessage, bsky)
188+
err = readJetstreamMessage(ctx, jetstreamMessage, bsky)
161189
if err != nil {
162190
log.Println("error reading jetstream message: ", jetstreamMessage, err)
163191
continue
164192
}
165193
}
194+
return ctx.Err()
166195
}
167196

168-
func readJetstreamMessage(jetstreamMessageEncoded []byte, bsky *bluesky.Client) error {
197+
func readJetstreamMessage(ctx context.Context, jetstreamMessageEncoded []byte, bsky *bluesky.Client) error {
169198
// Decompress the message
170199
m, err := zstdDecoder.DecodeAll(jetstreamMessageEncoded, nil)
171200
if err != nil {
@@ -189,7 +218,7 @@ func readJetstreamMessage(jetstreamMessageEncoded []byte, bsky *bluesky.Client)
189218
jetstreamMessageStr := string(jetstreamMessage)
190219

191220
go func() {
192-
profile, err := getBskyProfile(bskyMessage, bsky)
221+
profile, err := getBskyProfile(ctx, bskyMessage, bsky)
193222
if err != nil {
194223
slog.Error("fetch profile", "err", err, "msg", jetstreamMessageStr)
195224
return
@@ -201,7 +230,7 @@ func readJetstreamMessage(jetstreamMessageEncoded []byte, bsky *bluesky.Client)
201230
imageURL = fmt.Sprintf("https://cdn.bsky.app/img/feed_fullsize/plain/%s/%s", bskyMessage.Did, bskyMessage.Commit.Record.Embed.Images[0].Image.Ref.Link)
202231
}
203232

204-
err = sendToSlack(jetstreamMessageStr, bskyMessage, imageURL, *profile)
233+
err = sendToSlack(ctx, jetstreamMessageStr, bskyMessage, imageURL, *profile)
205234
if err != nil {
206235
slog.Error("slack error", "err", err)
207236
}
@@ -211,8 +240,8 @@ func readJetstreamMessage(jetstreamMessageEncoded []byte, bsky *bluesky.Client)
211240
return nil
212241
}
213242

214-
func getBskyProfile(bskyMessage BskyMessage, bsky *bluesky.Client) (*bluesky.Profile, error) {
215-
profile, err := bsky.FetchProfile(context.Background(), bskyMessage.Did)
243+
func getBskyProfile(ctx context.Context, bskyMessage BskyMessage, bsky *bluesky.Client) (*bluesky.Profile, error) {
244+
profile, err := bsky.FetchProfile(ctx, bskyMessage.Did)
216245
if err != nil {
217246
return nil, err
218247
}
@@ -225,7 +254,7 @@ func getBskyProfile(bskyMessage BskyMessage, bsky *bluesky.Client) (*bluesky.Pro
225254
return profile, nil
226255
}
227256

228-
func sendToSlack(jetstreamMessageStr string, bskyMessage BskyMessage, imageURL string, profile bluesky.Profile) error {
257+
func sendToSlack(ctx context.Context, jetstreamMessageStr string, bskyMessage BskyMessage, imageURL string, profile bluesky.Profile) error {
229258
attachments := []SlackAttachment{
230259
{
231260
AuthorName: fmt.Sprintf("%s (@%s)", profile.Name, profile.Handle),
@@ -246,20 +275,24 @@ func sendToSlack(jetstreamMessageStr string, bskyMessage BskyMessage, imageURL s
246275
log.Printf("failed to marshal text: %v", err)
247276

248277
}
249-
res, err := http.Post(*webhookURL, "application/json", bytes.NewBuffer(body))
278+
req, err := http.NewRequestWithContext(ctx, "POST", *webhookURL, bytes.NewReader(body))
279+
if err != nil {
280+
return err
281+
}
282+
req.Header.Set("Content-Type", "application/json")
283+
res, err := httpClient.Do(req)
250284
if err != nil {
251285
slog.Error("failed to post to slack", "msg", jetstreamMessageStr)
252286
return err
253287
}
288+
defer res.Body.Close()
254289

255290
if res.StatusCode != http.StatusOK {
256291
body, err := io.ReadAll(res.Body)
257292
if err != nil {
258293
slog.Error("bad error code from slack and fail to read body", "statusCode", res.StatusCode, "msg", jetstreamMessageStr)
259294
return err
260295
}
261-
defer res.Body.Close()
262-
263296
slog.Error("error code response from slack", "statusCode", res.StatusCode, "responseBody", string(body), "msg", jetstreamMessageStr)
264297
return fmt.Errorf("slack: %s %s", res.Status, string(body))
265298
}

go.mod

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
module github.com/tailscale/bsky-webhook
22

3-
go 1.22.6
3+
go 1.23
44

55
require (
66
github.com/bluesky-social/jetstream v0.0.0-20241031234625-0ab10bd041fe
77
github.com/gorilla/websocket v1.5.3
88
github.com/karalabe/go-bluesky v0.0.0-20230506152134-dd72fcf127a8
99
github.com/klauspost/compress v1.17.9
10+
github.com/tailscale/setec v0.0.0-20241107175935-3954dc4aade5
1011
)
1112

1213
require (
1314
github.com/bluesky-social/indigo v0.0.0-20241008040750-06bacb465af7 // indirect
1415
github.com/carlmjohnson/versioninfo v0.22.5 // indirect
1516
github.com/felixge/httpsnoop v1.0.4 // indirect
17+
github.com/go-json-experiment/json v0.0.0-20231102232822-2e55bd4e08b0 // indirect
1618
github.com/go-logr/logr v1.4.2 // indirect
1719
github.com/go-logr/stdr v1.2.2 // indirect
1820
github.com/goccy/go-json v0.10.2 // indirect
@@ -55,9 +57,13 @@ require (
5557
go.uber.org/atomic v1.11.0 // indirect
5658
go.uber.org/multierr v1.11.0 // indirect
5759
go.uber.org/zap v1.27.0 // indirect
60+
go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect
5861
golang.org/x/crypto v0.28.0 // indirect
62+
golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 // indirect
63+
golang.org/x/sync v0.7.0 // indirect
5964
golang.org/x/sys v0.26.0 // indirect
6065
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
6166
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
6267
lukechampine.com/blake3 v1.3.0 // indirect
68+
tailscale.com v1.73.0-pre.0.20240822193108-696711cc17c4 // indirect
6369
)

0 commit comments

Comments
 (0)