clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/ui-updates
tinyrelay / internal / domains / domains.go
domains.go 7.3K
1package domains23import (4 "context"5 "encoding/json"6 "errors"7 "fmt"8 "net"9 "regexp"10 "strings"11 "time"1213 "github.com/FelineStateMachine/tinyrelay/internal/catalog"14 "github.com/FelineStateMachine/tinyrelay/internal/sites"15)1617type ResolveDNS func(context.Context, string) ([]net.IP, error)1819type Config struct {20 // Catalog stores the tenant's primary and secondary host mappings.21 Catalog *catalog.Catalog22 // TenantID scopes all writes and reads performed by Service.23 TenantID string24 // BaseHost is the relay's canonical host and may be empty for a tenant25 // without a primary host.26 BaseHost string27 // RelayURL is returned in discovery responses.28 RelayURL string29 // Owner authorizes management calls made through Execute.30 Owner func(string) bool31 // ResolveDNS checks the configured host's DNS records when Check runs.32 ResolveDNS ResolveDNS33}3435type Service struct{ cfg Config }3637type DNSRecord struct {38 Type string `json:"type"`39 Name string `json:"name"`40 Value string `json:"value"`41 Note string `json:"note"`42}4344type 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}5354var labelPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$`)5556func 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, err66 }67 cfg.BaseHost = base68 return &Service{cfg: cfg}, nil69}7071func (s *Service) Methods() []string {72 return []string{"adddomain", "setdomainsite", "checkdomain", "removedomain", "listdomains"}73}7475func (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 string84 if err := json.Unmarshal(params[i], &v); err != nil {85 return "", errors.New("invalid: hostname must be a string")86 }87 return v, nil88 }89 switch method {90 case "adddomain":91 host, err := str(0)92 if err != nil {93 return nil, err94 }95 site, err := optionalSite(params, 1)96 if err != nil {97 return nil, err98 }99 return s.Add(ctx, host, site)100 case "setdomainsite":101 host, err := str(0)102 if err != nil {103 return nil, err104 }105 site, err := optionalSite(params, 1)106 if err != nil {107 return nil, err108 }109 return s.SetSite(ctx, host, site)110 case "checkdomain":111 host, err := str(0)112 if err != nil {113 return nil, err114 }115 return s.Check(ctx, host)116 case "removedomain":117 host, err := str(0)118 if err != nil {119 return nil, err120 }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}128129func optionalSite(params []json.RawMessage, index int) (string, error) {130 if len(params) <= index {131 return "", nil132 }133 var site string134 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, nil145}146147func (s *Service) Add(ctx context.Context, rawHost, site string) (View, error) {148 host, err := s.validateHost(rawHost)149 if err != nil {150 return View{}, err151 }152 mapping, err := s.cfg.Catalog.AddHost(ctx, s.cfg.TenantID, host, site)153 if err != nil {154 return View{}, err155 }156 return s.view(mapping, nil), nil157}158159func (s *Service) SetSite(ctx context.Context, rawHost, site string) (View, error) {160 host, err := s.validateHost(rawHost)161 if err != nil {162 return View{}, err163 }164 mapping, err := s.cfg.Catalog.SetHostSite(ctx, s.cfg.TenantID, host, site)165 if err != nil {166 return View{}, err167 }168 return s.view(mapping, nil), nil169}170171func (s *Service) Check(ctx context.Context, rawHost string) (View, error) {172 host, err := s.validateHost(rawHost)173 if err != nil {174 return View{}, err175 }176 mapping, err := s.cfg.Catalog.ResolveHostMapping(ctx, host)177 if err != nil {178 return View{}, err179 }180 if mapping.TenantID != s.cfg.TenantID {181 return View{}, catalog.ErrHostMappingNotFound182 }183 var addresses []net.IP184 if s.cfg.ResolveDNS != nil {185 addresses, err = s.cfg.ResolveDNS(ctx, host)186 if err != nil {187 return s.view(mapping, err), nil188 }189 }190 return s.view(mapping, addresses), nil191}192193func (s *Service) Remove(ctx context.Context, rawHost string) error {194 host, err := s.validateHost(rawHost)195 if err != nil {196 return err197 }198 return s.cfg.Catalog.RemoveHost(ctx, s.cfg.TenantID, host)199}200201func (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, err205 }206 result := make([]View, 0, len(mappings))207 for _, mapping := range mappings {208 result = append(result, s.view(mapping, nil))209 }210 return result, nil211}212213func (s *Service) validateHost(raw string) (string, error) {214 host, err := canonicalHost(raw)215 if err != nil {216 return "", err217 }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, nil234}235236func 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, nil247}248249func (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) > 0257 if len(ips) == 0 {258 result.Status = "dns_unresolved"259 }260 }261 if value != nil {262 if _, ok := value.(error); ok {263 result.Ready = false264 result.Status = "dns_error"265 }266 }267 return result268}