package contract import ( "bytes" _ "embed" "encoding/json" "errors" "fmt" "strings" ) // Request is one route a consumer asks for, as its manifest's `contributes.route` says it and the controller composes // it: the object Schema describes. Every field is optional to the decoder; Validate says which a route needs. type Request struct { // Name is the public name, served to anyone and certified by the public ACME directory; InternalName the name on // the private network, served inside it alone and certified by the mesh's own authority. The controller composes // both from Label and the node's domains, as far as the endpoint reaches (novox/hq ADR 0138); a manifest writes a // label, never a domain. Name string `json:"name,omitempty"` InternalName string `json:"internal-name,omitempty"` Label string `json:"label,omitempty"` // Endpoint names the manifest's endpoint the route serves; the controller fills Port from it. Endpoint string `json:"endpoint,omitempty"` Port int `json:"port,omitempty"` // Path is the path prefix the rule is scoped to, every path when empty; Priority which rule wins where two match, // higher first. Path string `json:"path,omitempty"` Priority int `json:"priority,omitempty"` // Scheme is how the consumer is reached, http (the default) or https; Insecure skips verifying its certificate, // for a backend that terminates TLS with its own. Scheme string `json:"scheme,omitempty"` Insecure bool `json:"insecure,omitempty"` // MaxRequestBody is the largest body, in bytes, the route carries; no limit when absent. Said, it is whole and // positive: a limit the proxy cannot keep is a route it does not serve. MaxRequestBody *int64 `json:"max-request-body,omitempty"` // The policy of the rule, a closed set (novox/hq ADR 0108): Deny refuses every request, Redirect answers with a // permanent redirect to that address, and Auth names the file of the secret a request must present, never the // secret itself. Deny bool `json:"deny,omitempty"` Redirect string `json:"redirect,omitempty"` Auth string `json:"auth,omitempty"` } // Schema is Request's JSON schema, published with this package for what checks a manifest's `contributes.route`. // //go:embed route.schema.json var Schema []byte // DecodeRequest reads one contribution's object strictly: no field this contract does not name, one document, and // a route Validate admits. What it refuses, it refuses whole, naming why: a route is never served half. func DecodeRequest(raw []byte) (Request, error) { var r Request dec := json.NewDecoder(bytes.NewReader(raw)) dec.DisallowUnknownFields() if err := dec.Decode(&r); err != nil { return Request{}, fmt.Errorf("not a route this contract reads: %w", err) } if dec.More() { return Request{}, errors.New("not a route this contract reads: more than one document") } if err := r.Validate(); err != nil { return Request{}, err } return r, nil } // Encode is r as a contribution's object, refused where DecodeRequest would refuse it. func (r Request) Encode() ([]byte, error) { if err := r.Validate(); err != nil { return nil, err } return json.Marshal(r) } // Validate says what makes r no route: no name of either kind; a port outside 1-65535, or none where the rule // forwards (neither Deny nor Redirect); a scheme other than http or https; a body limit that is not positive; and a // credential written where a secret's file is to be named (novox/hq ADR 0108: refused rather than tolerated). func (r Request) Validate() error { if strings.TrimSpace(r.Name) == "" && strings.TrimSpace(r.InternalName) == "" { return errors.New("the route names nothing to be served under: neither name nor internal-name") } if r.Port < 0 || r.Port > 65535 { return fmt.Errorf("the route's port %d is not a port", r.Port) } if r.Forwards() && r.Port == 0 { return errors.New("the route forwards and gives no port") } switch strings.ToLower(strings.TrimSpace(r.Scheme)) { case "", "http", "https": default: return fmt.Errorf("the route's scheme %q is neither http nor https", r.Scheme) } if r.MaxRequestBody != nil && *r.MaxRequestBody <= 0 { return fmt.Errorf("the route's max-request-body %d is not a positive number of bytes", *r.MaxRequestBody) } if LooksLikeACredential(r.Auth) { return errors.New("the route's auth holds a credential rather than the name of a secret's file (novox/hq ADR 0108)") } return nil } // Forwards is whether the rule sends a request on to the consumer, rather than answering it itself. func (r Request) Forwards() bool { return !r.Deny && r.Redirect == "" } // Prefix is the path prefix the rule is scoped to, beginning with a slash, or "" for every path. func (r Request) Prefix() string { p := strings.TrimSpace(r.Path) if p != "" && !strings.HasPrefix(p, "/") { p = "/" + p } return p } // SchemeOrDefault is how the consumer is reached: the scheme, lower-cased, http when none is said. func (r Request) SchemeOrDefault() string { if s := strings.ToLower(strings.TrimSpace(r.Scheme)); s != "" { return s } return "http" } // LooksLikeACredential is the check that keeps a secret out of a declaration: a value holding a `:` (the htpasswd // separator) or opening with a bcrypt identifier is a credential, not a path, and no path the mesh writes needs // either. It errs towards refusing. func LooksLikeACredential(v string) bool { v = strings.TrimSpace(v) return strings.Contains(v, ":") || strings.HasPrefix(v, "$2") }