tinyrelayfile
tinyrelay/internal/domains/domains.go/

internal/domains/domains.go raw
1package domains
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "net"
9 "regexp"
10 "strings"
11 "time"
12
13 "github.com/FelineStateMachine/tinyrelay/internal/catalog"
14 "github.com/FelineStateMachine/tinyrelay/internal/sites"
15)
16
17type ResolveDNS func(context.Context, string) ([]net.IP, error)
18
19type Config struct {
20 // Catalog stores the tenant's primary and secondary host mappings.
21 Catalog *catalog.Catalog
22 // TenantID scopes all writes and reads performed by Service.
23 TenantID string
24 // BaseHost is the relay's canonical host and may be empty for a tenant
25 // without a primary host.
26 BaseHost string
27 // RelayURL is returned in discovery responses.
28 RelayURL string
29 // Owner authorizes management calls made through Execute.
30 Owner func(string) bool
31 // ResolveDNS checks the configured host's DNS records when Check runs.
32 ResolveDNS ResolveDNS
33}
34
35type Service struct{ cfg Config }
36
37type DNSRecord struct {
38 Type string `json:"type"`
39 Name string `json:"name"`
40 Value string `json:"value"`
41 Note string `json:"note"`
42}
43
44type View struct {
45 Host string `json:"host"`
46 Site string `json:"site,omitempty"`
47 Status string `json:"status"`
48 Ready bool `json:"ready"`
49 CheckedAt int64 `json:"checkedAt"`
50 Addresses []string `json:"addresses,omitempty"`
51 Records []DNSRecord `json:"records"`
52}
53
54var labelPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$`)
55
56func New(cfg Config) (*Service, error) {
57 if cfg.Catalog == nil {
58 return nil, errors.New("domains: catalog is required")
59 }
60 if cfg.TenantID == "" {
61 return nil, errors.New("domains: tenant id is required")
62 }
63 base, err := canonicalHost(cfg.BaseHost)
64 if cfg.BaseHost != "" && err != nil {
65 return nil, err
66 }
67 cfg.BaseHost = base
68 return &Service{cfg: cfg}, nil
69}
70
71func (s *Service) Methods() []string {
72 return []string{"adddomain", "setdomainsite", "checkdomain", "removedomain", "listdomains"}
73}
74
75func (s *Service) Execute(ctx context.Context, actor, method string, params []json.RawMessage) (any, error) {
76 if method != "listdomains" && (s.cfg.Owner == nil || !s.cfg.Owner(actor)) {
77 return nil, errors.New("restricted: only the owner manages domains")
78 }
79 str := func(i int) (string, error) {
80 if i >= len(params) {
81 return "", errors.New("invalid: missing hostname")
82 }
83 var v string
84 if err := json.Unmarshal(params[i], &v); err != nil {
85 return "", errors.New("invalid: hostname must be a string")
86 }
87 return v, nil
88 }
89 switch method {
90 case "adddomain":
91 host, err := str(0)
92 if err != nil {
93 return nil, err
94 }
95 site, err := optionalSite(params, 1)
96 if err != nil {
97 return nil, err
98 }
99 return s.Add(ctx, host, site)
100 case "setdomainsite":
101 host, err := str(0)
102 if err != nil {
103 return nil, err
104 }
105 site, err := optionalSite(params, 1)
106 if err != nil {
107 return nil, err
108 }
109 return s.SetSite(ctx, host, site)
110 case "checkdomain":
111 host, err := str(0)
112 if err != nil {
113 return nil, err
114 }
115 return s.Check(ctx, host)
116 case "removedomain":
117 host, err := str(0)
118 if err != nil {
119 return nil, err
120 }
121 return true, s.Remove(ctx, host)
122 case "listdomains":
123 return s.List(ctx)
124 default:
125 return nil, fmt.Errorf("unsupported: domain method %q", method)
126 }
127}
128
129func optionalSite(params []json.RawMessage, index int) (string, error) {
130 if len(params) <= index {
131 return "", nil
132 }
133 var site string
134 if string(params[index]) != "null" {
135 if err := json.Unmarshal(params[index], &site); err != nil {
136 return "", errors.New("invalid: site must be a string")
137 }
138 }
139 if site != "" {
140 if _, ok := sites.ParseSite(site); !ok {
141 return "", errors.New("invalid: not a valid site hostname")
142 }
143 }
144 return site, nil
145}
146
147func (s *Service) Add(ctx context.Context, rawHost, site string) (View, error) {
148 host, err := s.validateHost(rawHost)
149 if err != nil {
150 return View{}, err
151 }
152 mapping, err := s.cfg.Catalog.AddHost(ctx, s.cfg.TenantID, host, site)
153 if err != nil {
154 return View{}, err
155 }
156 return s.view(mapping, nil), nil
157}
158
159func (s *Service) SetSite(ctx context.Context, rawHost, site string) (View, error) {
160 host, err := s.validateHost(rawHost)
161 if err != nil {
162 return View{}, err
163 }
164 mapping, err := s.cfg.Catalog.SetHostSite(ctx, s.cfg.TenantID, host, site)
165 if err != nil {
166 return View{}, err
167 }
168 return s.view(mapping, nil), nil
169}
170
171func (s *Service) Check(ctx context.Context, rawHost string) (View, error) {
172 host, err := s.validateHost(rawHost)
173 if err != nil {
174 return View{}, err
175 }
176 mapping, err := s.cfg.Catalog.ResolveHostMapping(ctx, host)
177 if err != nil {
178 return View{}, err
179 }
180 if mapping.TenantID != s.cfg.TenantID {
181 return View{}, catalog.ErrHostMappingNotFound
182 }
183 var addresses []net.IP
184 if s.cfg.ResolveDNS != nil {
185 addresses, err = s.cfg.ResolveDNS(ctx, host)
186 if err != nil {
187 return s.view(mapping, err), nil
188 }
189 }
190 return s.view(mapping, addresses), nil
191}
192
193func (s *Service) Remove(ctx context.Context, rawHost string) error {
194 host, err := s.validateHost(rawHost)
195 if err != nil {
196 return err
197 }
198 return s.cfg.Catalog.RemoveHost(ctx, s.cfg.TenantID, host)
199}
200
201func (s *Service) List(ctx context.Context) ([]View, error) {
202 mappings, err := s.cfg.Catalog.ListHosts(ctx, s.cfg.TenantID)
203 if err != nil {
204 return nil, err
205 }
206 result := make([]View, 0, len(mappings))
207 for _, mapping := range mappings {
208 result = append(result, s.view(mapping, nil))
209 }
210 return result, nil
211}
212
213func (s *Service) validateHost(raw string) (string, error) {
214 host, err := canonicalHost(raw)
215 if err != nil {
216 return "", err
217 }
218 if net.ParseIP(host) != nil {
219 return "", errors.New("invalid: an address is not a hostname")
220 }
221 if strings.Contains(host, "*") {
222 return "", errors.New("invalid: wildcard hostnames are not allowed")
223 }
224 if strings.Count(host, ".") < 1 {
225 return "", errors.New("invalid: hostname needs at least two labels")
226 }
227 if s.cfg.BaseHost != "" && (host == s.cfg.BaseHost || strings.HasSuffix(host, "."+s.cfg.BaseHost)) {
228 return "", fmt.Errorf("invalid: names under %s are relays already", s.cfg.BaseHost)
229 }
230 if host == "localhost" || strings.HasSuffix(host, ".localhost") || strings.HasSuffix(host, ".local") || strings.HasSuffix(host, ".internal") {
231 return "", errors.New("invalid: that name is local")
232 }
233 return host, nil
234}
235
236func canonicalHost(raw string) (string, error) {
237 host := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(raw)), ".")
238 if host == "" || len(host) > 253 || strings.ContainsAny(host, "/\\:@?#") {
239 return "", errors.New("invalid: hostname length or syntax")
240 }
241 for _, label := range strings.Split(host, ".") {
242 if !labelPattern.MatchString(label) {
243 return "", fmt.Errorf("invalid: hostname label %q", label)
244 }
245 }
246 return host, nil
247}
248
249func (s *Service) view(mapping catalog.HostMapping, value any) View {
250 checked := time.Now().Unix()
251 result := View{Host: mapping.Host, Site: mapping.Site, Status: mapping.Status, Ready: mapping.Status == "configured", CheckedAt: checked, Records: []DNSRecord{{Type: "CNAME", Name: mapping.Host, Value: s.cfg.RelayURL, Note: "route this hostname to the tiny listener"}}}
252 if ips, ok := value.([]net.IP); ok {
253 for _, ip := range ips {
254 result.Addresses = append(result.Addresses, ip.String())
255 }
256 result.Ready = result.Ready && len(ips) > 0
257 if len(ips) == 0 {
258 result.Status = "dns_unresolved"
259 }
260 }
261 if value != nil {
262 if _, ok := value.(error); ok {
263 result.Ready = false
264 result.Status = "dns_error"
265 }
266 }
267 return result
268}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs