this repo has no description
13
fork

Configure Feed

Select the types of activity you want to include in your feed.

add handlr from un-merged indigo branch

+364
+1
cmd/handlr/.gitignore
··· 1 + handlr
+54
cmd/handlr/README.md
··· 1 + 2 + handlr: DNS TXT handle resolution wrapper 3 + ========================================= 4 + 5 + This is a simple daemon which helps expose atproto handles to the public internet using the DNS TXT resolution mechanism, for services which don't want to individually configure DNS records. 6 + 7 + The expected use-case is a network service which implements the `resolveHandle` atproto HTTP API method (aka, can resolve handles to DID against an internal datastore) for a large number of handles which are on nested sub-domains of a common suffix domain. In this case, it is difficult to use the HTTPS `/.well-known/` handle resolution mechanism, because SSL wildcard certs don't work with arbitrary nested domains. And DNS TXT resolution is difficult because it requires either running a DNS server, or integrating with an arbitrary provider API which may have restrictive limits. 8 + 9 + This is a small DNS server which responds to TXT requests, and queries a backend service (over HTTP API) for actual resolution. Specifically, it calls the `/xrpc/com.atproto.identity.resolveHandle` GET endpoint with the query parameter `handle`, and expects a simple JSON object returned with a `did` field. An HTTP 400 or 404 error are both interpreted as an NXDOMAIN. 10 + 11 + 12 + ## Development Examples 13 + 14 + Build the daemon from the top level of the indigo repo: 15 + 16 + go build ./cmd/handlr 17 + 18 + Then run it configured to connect to the Bluesky appview: 19 + 20 + ./handlr --backend-host https://public.api.bsky.app serve 21 + 22 + You can test real handles on the live network: 23 + 24 + dig @localhost -p 5333 TXT _atproto.atproto.com 25 + 26 + A demo backend service is provided to experiment with local request. You need python3 and flask installed (`sudo apt install python3-flask`): 27 + 28 + # run the backend service in one terminal 29 + FLASK_APP=demo-backend python3 -m flask run --host 127.0.0.1 --port 5000 30 + 31 + # the daemon in another 32 + ./handlr serve 33 + 34 + # example resolution 35 + dig @localhost -p 5333 TXT _atproto.demo.handlr.example.com 36 + 37 + 38 + ## Deployment 39 + 40 + To run this service for real, you'll need to create an `NS` DNS entry which covers the set of possible subdomains, such as `*.handlr.example.com`. You point this to `NS` record to the server running handlr, such as `srv.example.com`. It is assumed that the backend service is also running on this host. 41 + 42 + NOTE: it might be possible to set up handlr to work with an entire base domain, like `*.example.com`, using glue records, but this hasn't been tried yet, and could break DNS for all other sub-domains (including A/AAAA and CNAME records). 43 + 44 + NOTE: if you are on an Ubuntu server which is using `systemd-resolved` (bound to port 53), you need to disable it, for example [following these directions](https://www.turek.dev/posts/disable-systemd-resolved-cleanly/). 45 + 46 + You can build the handlr binary (`go build ./cmd/handlr`) and then `scp` it to the server (if you are using the same operating system and architecture locally), and run it like: 47 + 48 + sudo LOG_LEVEL=warn ./handlr serve --bind :53 --backend-host http://localhost:5000 --domain-suffix handlr.example.com --ttl 300 49 + 50 + Note that running with superuser privileges is required to bind to port 53 (DNS default). You'll need to ensure that inbound UDP is allowed for that port (might need to configure any firewall or iptables). Using a service manager like `systemd` is probably a good idea. Configuration can also be supplied via environment variables. You can adjust the log level, but probably want to keep it to `warn` for privacy and log volume reasons. 51 + 52 + If all is working correctly, you should be able to resolve handles from any machine using regular DNS like: 53 + 54 + dig TXT _atproto.demo.handlr.example.com
+27
cmd/handlr/demo-backend.py
··· 1 + """ 2 + Run this script with: 3 + 4 + FLASK_APP=demo-backend python3 -m flask run --host 127.0.0.1 --port 5000 5 + """ 6 + 7 + from flask import Flask, abort, request, jsonify 8 + 9 + app = Flask(__name__) 10 + 11 + known_handles = { 12 + "demo.handlr.example.com": "did:web:dummy.example.com", 13 + } 14 + 15 + @app.route("/", methods=['GET']) 16 + def homepage(): 17 + return "This is a demo HTTP server which resolves handles to DIDs" 18 + 19 + @app.route("/xrpc/com.atproto.identity.resolveHandle", methods=['GET']) 20 + def resolve_handle(): 21 + handle = request.args.get('handle', '') 22 + if not handle: 23 + abort(400, "handle param not passed") 24 + did = known_handles.get(handle, '') 25 + if not did: 26 + abort(404, "handle not found") 27 + return jsonify({"did": did})
+34
cmd/handlr/handlr_test.go
··· 1 + package main 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + ) 8 + 9 + func TestParseHandle(t *testing.T) { 10 + assert := assert.New(t) 11 + hr := HTTPResolver{} 12 + 13 + fixtures := []struct { 14 + domain string 15 + valid bool 16 + hdl string 17 + }{ 18 + {domain: "asdf", valid: false, hdl: ""}, 19 + {domain: "", valid: false, hdl: ""}, 20 + {domain: "_atproto.blah", valid: false, hdl: ""}, 21 + {domain: "_atproto.handle.example.com.", valid: true, hdl: "handle.example.com"}, 22 + {domain: "_atproto.handle.example.com", valid: true, hdl: "handle.example.com"}, 23 + } 24 + 25 + for _, fix := range fixtures { 26 + hdl, err := hr.parseDomain(fix.domain) 27 + if fix.valid { 28 + assert.NoError(err) 29 + assert.Equal(fix.hdl, hdl.String()) 30 + } else { 31 + assert.Error(err) 32 + } 33 + } 34 + }
+106
cmd/handlr/main.go
··· 1 + package main 2 + 3 + import ( 4 + "log/slog" 5 + "net/http" 6 + "os" 7 + "strings" 8 + "time" 9 + 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/hashicorp/golang-lru/v2/expirable" 12 + _ "github.com/joho/godotenv/autoload" 13 + cli "github.com/urfave/cli/v2" 14 + ) 15 + 16 + func main() { 17 + if err := run(os.Args); err != nil { 18 + slog.Error("exiting", "err", err) 19 + os.Exit(-1) 20 + } 21 + } 22 + 23 + func run(args []string) error { 24 + 25 + app := cli.App{ 26 + Name: "handlr", 27 + Usage: "atproto handle DNS TXT proxy demon", 28 + } 29 + 30 + flags := []cli.Flag{ 31 + &cli.StringFlag{ 32 + Name: "bind", 33 + Usage: "local UDP IP and port to listen on. note that DNS port 53 requires superuser on most systems", 34 + Value: ":5333", 35 + EnvVars: []string{"HANDLR_BIND"}, 36 + }, 37 + &cli.StringFlag{ 38 + Name: "backend-host", 39 + Usage: "HTTP method, hostname, and port of backend resolution service", 40 + Value: "http://localhost:5000", 41 + EnvVars: []string{"HANDLR_BACKEND_HOST"}, 42 + }, 43 + &cli.StringFlag{ 44 + Name: "domain-suffix", 45 + Usage: "domain suffix to filter handles (don't include trailing period)", 46 + EnvVars: []string{"HANDLR_DOMAIN_SUFFIX"}, 47 + }, 48 + &cli.IntFlag{ 49 + Name: "ttl", 50 + Usage: "TTL for both DNS TXT responses, and internal caching", 51 + Value: 5 * 60, 52 + EnvVars: []string{"HANDLR_TTL"}, 53 + }, 54 + &cli.StringFlag{ 55 + Name: "log-level", 56 + Usage: "log level (debug, info, warn, error)", 57 + Value: "info", 58 + EnvVars: []string{"HANDLR_LOG_LEVEL", "LOG_LEVEL"}, 59 + }, 60 + } 61 + app.Commands = []*cli.Command{ 62 + &cli.Command{ 63 + Name: "serve", 64 + Usage: "run the handlr daemon", 65 + Action: runServe, 66 + Flags: flags, 67 + }, 68 + } 69 + return app.Run(args) 70 + } 71 + 72 + func runServe(cctx *cli.Context) error { 73 + 74 + logLevel := slog.LevelInfo 75 + switch strings.ToLower(cctx.String("log-level")) { 76 + case "debug": 77 + logLevel = slog.LevelDebug 78 + case "info": 79 + logLevel = slog.LevelInfo 80 + case "warn": 81 + logLevel = slog.LevelWarn 82 + case "error": 83 + logLevel = slog.LevelError 84 + } 85 + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ 86 + Level: logLevel, 87 + AddSource: true, 88 + })) 89 + slog.SetDefault(logger) 90 + 91 + // set a minimum for the internal cache 92 + var cache *expirable.LRU[syntax.Handle, syntax.DID] 93 + ttl := cctx.Int("ttl") 94 + if ttl != 0 { 95 + cache = expirable.NewLRU[syntax.Handle, syntax.DID](10_000, nil, time.Second*time.Duration(ttl)) 96 + } 97 + client := http.Client{Timeout: time.Second * 5} 98 + hr := HTTPResolver{ 99 + client: &client, 100 + backendHost: cctx.String("backend-host"), 101 + suffix: cctx.String("domain-suffix"), 102 + ttl: cctx.Int("ttl"), 103 + cache: cache, 104 + } 105 + return hr.Run(cctx.String("bind")) 106 + }
+142
cmd/handlr/server.go
··· 1 + package main 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "io" 7 + "log/slog" 8 + "net/http" 9 + "strings" 10 + 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + "github.com/hashicorp/golang-lru/v2/expirable" 13 + "github.com/miekg/dns" 14 + ) 15 + 16 + type HTTPResolver struct { 17 + client *http.Client 18 + backendHost string 19 + suffix string 20 + ttl int 21 + cache *expirable.LRU[syntax.Handle, syntax.DID] 22 + } 23 + 24 + // represents JSON response from backend com.atproto.identity.resolveHandle request 25 + type ResolveResp struct { 26 + DID string `json:"did"` 27 + } 28 + 29 + func (hr *HTTPResolver) Run(bind string) error { 30 + srv := &dns.Server{Addr: bind, Net: "udp"} 31 + dns.HandleFunc(".", hr.handleTXT) 32 + slog.Info("listening on UDP", "bind", bind, "backendHost", hr.backendHost, "ttl", hr.ttl, "suffix", hr.suffix) 33 + return srv.ListenAndServe() 34 + } 35 + 36 + func (hr *HTTPResolver) parseDomain(domain string) (syntax.Handle, error) { 37 + domain = strings.ToLower(domain) 38 + if !strings.HasPrefix(domain, "_atproto.") { 39 + return "", fmt.Errorf("missing _atproto prefix") 40 + } 41 + domain = strings.TrimPrefix(domain, "_atproto.") 42 + domain = strings.TrimSuffix(domain, ".") 43 + return syntax.ParseHandle(domain) 44 + } 45 + 46 + func (hr *HTTPResolver) resolveHandle(hdl syntax.Handle) (syntax.DID, error) { 47 + 48 + // first try cache 49 + if hr.cache != nil { 50 + val, ok := hr.cache.Get(hdl) 51 + if ok { 52 + return val, nil 53 + } 54 + } 55 + 56 + req, err := http.NewRequest("GET", fmt.Sprintf("%s/xrpc/com.atproto.identity.resolveHandle?handle=%s", hr.backendHost, hdl), nil) 57 + if err != nil { 58 + return "", err 59 + } 60 + req.Header.Set("User-Agent", "indigo-handlr") 61 + resp, err := hr.client.Do(req) 62 + if err != nil { 63 + return "", err 64 + } 65 + defer resp.Body.Close() 66 + if resp.StatusCode == 404 || resp.StatusCode == 400 { 67 + return "", nil 68 + } 69 + if resp.StatusCode != 200 { 70 + return "", fmt.Errorf("failed to resolve handle status=%d", resp.StatusCode) 71 + } 72 + bodyBytes, err := io.ReadAll(resp.Body) 73 + if err != nil { 74 + return "", err 75 + } 76 + var resolveResp ResolveResp 77 + if err = json.Unmarshal(bodyBytes, &resolveResp); err != nil { 78 + return "", err 79 + } 80 + did, err := syntax.ParseDID(resolveResp.DID) 81 + if err != nil { 82 + return "", err 83 + } 84 + if hr.cache != nil { 85 + hr.cache.Add(hdl, did) 86 + } 87 + return did, nil 88 + } 89 + 90 + func (hr *HTTPResolver) handleTXT(w dns.ResponseWriter, r *dns.Msg) { 91 + msg := dns.Msg{} 92 + msg.SetReply(r) 93 + if len(r.Question) == 0 { 94 + w.WriteMsg(&msg) 95 + return 96 + } 97 + // there is at most one question: tps://datatracker.ietf.org/doc/draft-ietf-dnsop-qdcount-is-one/ 98 + switch r.Question[0].Qtype { 99 + case dns.TypeTXT: 100 + msg.Authoritative = true 101 + domain := msg.Question[0].Name 102 + hdl, err := hr.parseDomain(domain) 103 + slog.Debug("DNS TXT request", "domain", domain, "handle", hdl, "parseErr", err) 104 + if err != nil { 105 + w.WriteMsg(&msg) 106 + return 107 + } 108 + if hr.suffix != "" && !strings.HasSuffix(hdl.String(), hr.suffix) { 109 + // handle falls outside the configured domain-suffix 110 + slog.Info("refusing to resolve handle outside suffix", "handle", hdl) 111 + msg.SetRcode(r, dns.RcodeRefused) 112 + w.WriteMsg(&msg) 113 + return 114 + } 115 + did, err := hr.resolveHandle(hdl) 116 + if err != nil { 117 + slog.Error("error resolving handle", "handle", hdl, "err", err) 118 + msg.SetRcode(r, dns.RcodeServerFailure) 119 + w.WriteMsg(&msg) 120 + return 121 + } 122 + if did == "" { 123 + // handle not found. 124 + // not an NXDOMAIN, because there might be a valid handle which is a sub-domain 125 + // return "NODATA", which NOERROR and no answer section 126 + // TODO: should return an Authority section here 127 + msg.SetRcode(r, dns.RcodeSuccess) 128 + w.WriteMsg(&msg) 129 + return 130 + } 131 + msg.Answer = append(msg.Answer, &dns.TXT{ 132 + Hdr: dns.RR_Header{ 133 + Name: domain, 134 + Rrtype: dns.TypeTXT, 135 + Class: dns.ClassINET, 136 + Ttl: uint32(hr.ttl), 137 + }, 138 + Txt: []string{"did=" + did.String()}, 139 + }) 140 + } 141 + w.WriteMsg(&msg) 142 + }