Commit d42eec7
Antonio Mika
·
2026-10-11 16:45:03 -0400 EDT
parent 874bfe4
feat(pico): check where a domain points in dns-check and pin pico-ssh dns
6 files changed,
+147,
-8
+1,
-0
| ... | ... | @@ -352,6 +352,7 @@ services: | |
| 352 | 352 | - all | |
| 353 | 353 | logging: *default-logging | |
| 354 | 354 | pico-ssh: | |
| 355 | + | dns: 1.1.1.1 | |
| 355 | 356 | networks: | |
| 356 | 357 | pico: | |
| 357 | 358 | aliases: |
+11,
-4
| ... | ... | @@ -43,7 +43,11 @@ type Cmd struct { | |
| 43 | 43 | Dbpool db.DB | |
| 44 | 44 | Write bool | |
| 45 | 45 | ||
| 46 | - | lookupTXT func(host, space string) ([]string, error) | |
| 46 | + | // DomainTargets are the hosts dns-check expects a custom domain to point at. | |
| 47 | + | DomainTargets []string | |
| 48 | + | ||
| 49 | + | lookupTXT func(host, space string) ([]string, error) | |
| 50 | + | lookupHost func(host string) (cname string, addrs []string, err error) | |
| 47 | 51 | } | |
| 48 | 52 | ||
| 49 | 53 | func (c *Cmd) output(out string) { |
| ... | ... | @@ -58,7 +62,7 @@ func (c *Cmd) help() { | |
| 58 | 62 | helpStr += "access-logs - fetch access logs from the last 30 days\n" | |
| 59 | 63 | helpStr += "chat - IRC chat (must enable pty with `-t` to the SSH command)\n" | |
| 60 | 64 | helpStr += "not-found - return all status 404 requests for a host (hostname.com [year|month])\n" | |
| 61 | - | helpStr += "dns-check - check a domain's TXT records for pgs, prose and tuns custom domains (hostname.com or *.hostname.com)\n" | |
| 65 | + | helpStr += "dns-check - check a domain's CNAME/A and TXT records for pgs, prose and tuns custom domains (hostname.com or *.hostname.com)\n" | |
| 62 | 66 | c.output(helpStr) | |
| 63 | 67 | } | |
| 64 | 68 |
| ... | ... | @@ -177,8 +181,9 @@ func (c *Cmd) logs(ctx context.Context) error { | |
| 177 | 181 | } | |
| 178 | 182 | ||
| 179 | 183 | type CliHandler struct { | |
| 180 | - | DBPool db.DB | |
| 181 | - | Logger *slog.Logger | |
| 184 | + | DBPool db.DB | |
| 185 | + | Logger *slog.Logger | |
| 186 | + | DomainTargets []string | |
| 182 | 187 | } | |
| 183 | 188 | ||
| 184 | 189 | func Middleware(handler *CliHandler) pssh.SSHServerMiddleware { |
| ... | ... | @@ -250,6 +255,8 @@ func Middleware(handler *CliHandler) pssh.SSHServerMiddleware { | |
| 250 | 255 | Log: log, | |
| 251 | 256 | Dbpool: dbpool, | |
| 252 | 257 | Write: false, | |
| 258 | + | ||
| 259 | + | DomainTargets: handler.DomainTargets, | |
| 253 | 260 | } | |
| 254 | 261 | ||
| 255 | 262 | cmd := strings.TrimSpace(args[0]) |
+13,
-0
| ... | ... | @@ -12,6 +12,8 @@ type Config struct { | |
| 12 | 12 | Space string | |
| 13 | 13 | TunsSecret string | |
| 14 | 14 | Logger *slog.Logger | |
| 15 | + | // CustomDomainTargets are the hosts a custom domain can point at. | |
| 16 | + | CustomDomainTargets []string | |
| 15 | 17 | } | |
| 16 | 18 | ||
| 17 | 19 | func NewConfig(service string) *Config { |
| ... | ... | @@ -24,5 +26,16 @@ func NewConfig(service string) *Config { | |
| 24 | 26 | Space: "pico", | |
| 25 | 27 | Logger: shared.CreateLogger(service, withPipe), | |
| 26 | 28 | TunsSecret: tuns, | |
| 29 | + | CustomDomainTargets: []string{ | |
| 30 | + | hostOnly(shared.GetEnv("PGS_DOMAIN", "pgs.sh")), | |
| 31 | + | hostOnly(shared.GetEnv("PROSE_DOMAIN", "prose.sh")), | |
| 32 | + | "tuns.sh", | |
| 33 | + | "nue.tuns.sh", | |
| 34 | + | }, | |
| 27 | 35 | } | |
| 28 | 36 | } | |
| 37 | + | ||
| 38 | + | func hostOnly(domain string) string { | |
| 39 | + | host, _, _ := strings.Cut(domain, ":") | |
| 40 | + | return host | |
| 41 | + | } |
+61,
-2
| ... | ... | @@ -5,6 +5,8 @@ import ( | |
| 5 | 5 | "encoding/hex" | |
| 6 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | + | "net" | |
| 9 | + | "slices" | |
| 8 | 10 | "strings" | |
| 9 | 11 | ||
| 10 | 12 | "github.com/picosh/pico/pkg/db" |
| ... | ... | @@ -107,8 +109,59 @@ func checkTunsRecords(c *Cmd, records []string) string { | |
| 107 | 109 | return fmt.Sprintf("%s is invalid: no record is your username or the SHA256 fingerprint of one of your keys", strings.Join(quoted, ", ")) | |
| 108 | 110 | } | |
| 109 | 111 | ||
| 110 | - | // dnsCheck reports, for each service, whether domain has a TXT record and | |
| 111 | - | // whether the service would accept it. | |
| 112 | + | // lookupHost resolves host and returns its canonical name, or "" when it has | |
| 113 | + | // no CNAME. | |
| 114 | + | func lookupHost(host string) (string, []string, error) { | |
| 115 | + | addrs, err := net.LookupHost(host) | |
| 116 | + | if err != nil { | |
| 117 | + | return "", nil, err | |
| 118 | + | } | |
| 119 | + | cname, err := net.LookupCNAME(host) | |
| 120 | + | if err != nil { | |
| 121 | + | return "", addrs, nil | |
| 122 | + | } | |
| 123 | + | cname = strings.TrimSuffix(cname, ".") | |
| 124 | + | if strings.EqualFold(cname, host) { | |
| 125 | + | cname = "" | |
| 126 | + | } | |
| 127 | + | return cname, addrs, nil | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // checkPointer reports what host resolves to and which of the service | |
| 131 | + | // domains it points at. Addresses are compared so a CNAME to a regional | |
| 132 | + | // name or an apex A record both count. | |
| 133 | + | func (c *Cmd) checkPointer(host string, lookup func(string) (string, []string, error)) string { | |
| 134 | + | cname, addrs, err := lookup(host) | |
| 135 | + | var dnsErr *net.DNSError | |
| 136 | + | if errors.As(err, &dnsErr) && dnsErr.IsNotFound { | |
| 137 | + | return fmt.Sprintf("%s: no CNAME or A record", host) | |
| 138 | + | } | |
| 139 | + | if err != nil { | |
| 140 | + | return fmt.Sprintf("%s: lookup failed: %v", host, err) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | line := fmt.Sprintf("%s: resolves to %s", host, strings.Join(addrs, ", ")) | |
| 144 | + | if cname != "" { | |
| 145 | + | line = fmt.Sprintf("%s: CNAME %s, resolves to %s", host, cname, strings.Join(addrs, ", ")) | |
| 146 | + | } | |
| 147 | + | matches := []string{} | |
| 148 | + | for _, target := range c.DomainTargets { | |
| 149 | + | _, targetAddrs, err := lookup(target) | |
| 150 | + | if err != nil { | |
| 151 | + | continue | |
| 152 | + | } | |
| 153 | + | if slices.ContainsFunc(addrs, func(a string) bool { return slices.Contains(targetAddrs, a) }) { | |
| 154 | + | matches = append(matches, target) | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | if len(matches) == 0 { | |
| 158 | + | return fmt.Sprintf("%s, which is not %s", line, strings.Join(c.DomainTargets, ", ")) | |
| 159 | + | } | |
| 160 | + | return fmt.Sprintf("%s, which is %s", line, strings.Join(matches, ", ")) | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // dnsCheck reports what domain points at and, for each service, whether it | |
| 164 | + | // has a TXT record the service would accept. | |
| 112 | 165 | func (c *Cmd) dnsCheck(domain string) error { | |
| 113 | 166 | domain = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), ".") | |
| 114 | 167 | base, wildcard := strings.CutPrefix(domain, "*.") |
| ... | ... | @@ -128,6 +181,12 @@ func (c *Cmd) dnsCheck(domain string) error { | |
| 128 | 181 | host = hex.EncodeToString(label) + "." + base | |
| 129 | 182 | } | |
| 130 | 183 | ||
| 184 | + | resolve := c.lookupHost | |
| 185 | + | if resolve == nil { | |
| 186 | + | resolve = lookupHost | |
| 187 | + | } | |
| 188 | + | c.output(c.checkPointer(host, resolve)) | |
| 189 | + | ||
| 131 | 190 | for _, svc := range customDomainServices { | |
| 132 | 191 | name := router.TXTRecordName(host, svc.space) | |
| 133 | 192 | if wildcard && !svc.wildcard { |
+58,
-0
| ... | ... | @@ -6,6 +6,7 @@ import ( | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "io" | |
| 8 | 8 | "log/slog" | |
| 9 | + | "net" | |
| 9 | 10 | "strings" | |
| 10 | 11 | "testing" | |
| 11 | 12 |
| ... | ... | @@ -124,6 +125,9 @@ func TestDNSCheck(t *testing.T) { | |
| 124 | 125 | Session: session, | |
| 125 | 126 | Log: slog.New(slog.DiscardHandler), | |
| 126 | 127 | Dbpool: dbpool, | |
| 128 | + | lookupHost: func(host string) (string, []string, error) { | |
| 129 | + | return "", nil, &net.DNSError{IsNotFound: true} | |
| 130 | + | }, | |
| 127 | 131 | lookupTXT: func(host, space string) ([]string, error) { | |
| 128 | 132 | if tt.domain == "" && host != "example.com" { | |
| 129 | 133 | t.Fatalf("looked up %q", host) |
| ... | ... | @@ -153,6 +157,60 @@ func TestDNSCheck(t *testing.T) { | |
| 153 | 157 | }) | |
| 154 | 158 | } | |
| 155 | 159 | ||
| 160 | + | pointers := []struct { | |
| 161 | + | name string | |
| 162 | + | hosts map[string][]string | |
| 163 | + | cname string | |
| 164 | + | want string | |
| 165 | + | }{ | |
| 166 | + | {"none", map[string][]string{}, "", "example.com: no CNAME or A record"}, | |
| 167 | + | {"cname to a region", map[string][]string{ | |
| 168 | + | "example.com": {"10.0.0.3"}, | |
| 169 | + | "pgs.sh": {"10.0.0.1"}, | |
| 170 | + | "prose.sh": {"10.0.0.2"}, | |
| 171 | + | "tuns.sh": {"10.0.0.3"}, | |
| 172 | + | "nue.tuns.sh": {"10.0.0.4"}, | |
| 173 | + | }, "tuns.pico.sh", "example.com: CNAME tuns.pico.sh, resolves to 10.0.0.3, which is tuns.sh"}, | |
| 174 | + | {"apex A", map[string][]string{ | |
| 175 | + | "example.com": {"10.0.0.9", "10.0.0.1"}, | |
| 176 | + | "pgs.sh": {"10.0.0.1"}, | |
| 177 | + | }, "", "example.com: resolves to 10.0.0.9, 10.0.0.1, which is pgs.sh"}, | |
| 178 | + | {"elsewhere", map[string][]string{ | |
| 179 | + | "example.com": {"10.0.0.9"}, | |
| 180 | + | "pgs.sh": {"10.0.0.1"}, | |
| 181 | + | }, "", "example.com: resolves to 10.0.0.9, which is not pgs.sh, prose.sh, tuns.sh, nue.tuns.sh"}, | |
| 182 | + | } | |
| 183 | + | for _, tt := range pointers { | |
| 184 | + | t.Run("pointer "+tt.name, func(t *testing.T) { | |
| 185 | + | session := &fakeSession{} | |
| 186 | + | cmd := &Cmd{ | |
| 187 | + | User: me, | |
| 188 | + | Session: session, | |
| 189 | + | Dbpool: dbpool, | |
| 190 | + | DomainTargets: []string{"pgs.sh", "prose.sh", "tuns.sh", "nue.tuns.sh"}, | |
| 191 | + | lookupHost: func(host string) (string, []string, error) { | |
| 192 | + | addrs, ok := tt.hosts[host] | |
| 193 | + | if !ok { | |
| 194 | + | return "", nil, &net.DNSError{IsNotFound: true} | |
| 195 | + | } | |
| 196 | + | if host == "example.com" { | |
| 197 | + | return tt.cname, addrs, nil | |
| 198 | + | } | |
| 199 | + | return "", addrs, nil | |
| 200 | + | }, | |
| 201 | + | lookupTXT: func(host, space string) ([]string, error) { | |
| 202 | + | return nil, router.ErrNoTXTRecord | |
| 203 | + | }, | |
| 204 | + | } | |
| 205 | + | if err := cmd.dnsCheck("example.com"); err != nil { | |
| 206 | + | t.Fatal(err) | |
| 207 | + | } | |
| 208 | + | if out := session.String(); !strings.Contains(out, tt.want+"\r\n") { | |
| 209 | + | t.Errorf("missing %q in output:\n%s", tt.want, out) | |
| 210 | + | } | |
| 211 | + | }) | |
| 212 | + | } | |
| 213 | + | ||
| 156 | 214 | cmd := &Cmd{User: me, Session: &fakeSession{}, Dbpool: dbpool} | |
| 157 | 215 | for _, bad := range []string{"https://example.com/", "*.", "a.*.example.com"} { | |
| 158 | 216 | if err := cmd.dnsCheck(bad); err == nil { |
+3,
-2