package host import ( "bytes" "context" "encoding/json" "errors" "io" "net" "os" "path/filepath" "strconv" "strings" "github.com/carlosframework/platform/internal/hostguard" "github.com/carlosframework/platform/internal/instancectl" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/rootstate" ) type claimUnitShape struct { Host, Account, App, Kind, Unit, Backing, Socket, Addr, DBPath string Program string `json:",omitempty"` PredecessorDigest string `json:",omitempty"` } type claimUnitBinding struct { Schema int Owner hostguard.RootInstallation Shape claimUnitShape Retiring, Retired bool } type claimUnitBindings struct { admit func() error handover func(context.Context, claimUnitBinding, claimUnitBinding) error state rootstate.Store installation func(context.Context, string) (hostguard.RootInstallation, error) canonical func(context.Context, string) (claimUnitShape, error) } func (s claimUnitShape) valid() bool { if (s.Program != "" && !claimHexDigest(s.Program)) || (s.PredecessorDigest != "" && !claimHexDigest(s.PredecessorDigest)) { return false } if !provision.ValidHostShape(s.Host) || !release.ValidAccount(s.Account) || !release.ValidApp(s.App) || s.Kind != "instance" || !instancectl.ValidBoundClaimUnit(s.Unit) { return false } if s.Backing != "" && (s.Backing != registry.BackingUnit || s.Unit != "carlos-app@"+s.Host+".service") { return false } if (s.Socket == "") == (s.Addr == "") { return false } for _, p := range []string{s.Socket, s.DBPath} { if p != "" && (!filepath.IsAbs(p) || filepath.Clean(p) != p || p == "/" || strings.ContainsAny(p, "\x00\r\n")) { return false } } if s.Addr != "" { host, port, err := net.SplitHostPort(s.Addr) n, e := strconv.Atoi(port) if err != nil || e != nil || n < 1 || n > 65535 || net.ParseIP(host) == nil || !net.ParseIP(host).IsLoopback() { return false } } return true } func (s claimUnitShape) route() registry.Route { unit := s.Unit if s.Backing == registry.BackingUnit { unit = "" } return registry.Route{Host: s.Host, Account: s.Account, App: s.App, Kind: s.Kind, Unit: unit, Backing: s.Backing, Socket: s.Socket, Addr: s.Addr, DBPath: s.DBPath} } func (s claimUnitShape) matches(r registry.Route) bool { return s.Host == r.Host && s.Account == r.Account && s.App == r.App && s.Kind == r.Kind && s.route().Unit == r.Unit && s.Backing == r.Backing && s.Socket == r.Socket && s.Addr == r.Addr && s.DBPath == r.DBPath && r.Addrs == "" && r.Tunnel == "" && r.Seed == "" && !r.Replica } func unitBindingOwner(in hostguard.RootInstallation) hostguard.RootInstallation { in.Artifacts = hostguard.InstallationArtifacts{} in.Registration = hostguard.InstallationRegistration{} in.InventoryRevision = 0 in.Empty = false return in } func (b claimUnitBinding) valid() bool { in := b.Owner return b.Schema == 1 && b.Shape.valid() && in == unitBindingOwner(in) && in.Host == b.Shape.Host && in.Account == b.Shape.Account && in.App == b.Shape.App && in.Incarnation != "" && in.Stamp > 0 && in.Migration > 0 && in.Record != "" && in.Box != "" && in.Enrollment != "" && (!b.Retired || b.Retiring) } func (s *claimUnitBindings) read(host string) (claimUnitBinding, error) { var b claimUnitBinding if s == nil || s.state == nil || !provision.ValidHostShape(host) { return b, os.ErrPermission } raw, err := s.state.Read(host + ".json") if err != nil { return b, err } dec := json.NewDecoder(bytes.NewReader(raw)) dec.DisallowUnknownFields() if len(raw) > rootstate.MaxRecord || dec.Decode(&b) != nil || dec.Decode(new(any)) != io.EOF || !b.valid() || b.Owner.Host != host { return claimUnitBinding{}, os.ErrPermission } return b, nil } func (s *claimUnitBindings) write(b claimUnitBinding) error { if !b.valid() { return os.ErrPermission } raw, err := json.Marshal(b) if err != nil { return err } return s.state.Write(b.Owner.Host+".json", raw) } func (s *claimUnitBindings) current(ctx context.Context, host string) (claimUnitBinding, error) { var b claimUnitBinding if s == nil || s.installation == nil || s.canonical == nil { return b, os.ErrPermission } in, err := s.installation(ctx, host) if err != nil { return b, err } shape, err := s.canonical(ctx, host) if err != nil { return b, err } b = claimUnitBinding{Schema: 1, Owner: unitBindingOwner(in), Shape: shape} if in.InventoryRevision == 0 || b.Owner.Host != host || !b.valid() { return claimUnitBinding{}, os.ErrPermission } return b, nil } func (s *claimUnitBindings) establish(ctx context.Context, host string, resolved ...claimUnitBinding) (claimUnitBinding, error) { if s.admit != nil { if err := s.admit(); err != nil { return claimUnitBinding{}, err } } if s == nil || s.state == nil { return claimUnitBinding{}, os.ErrPermission } unlock, err := s.state.Lock(ctx, "bindings") if err != nil { return claimUnitBinding{}, err } defer unlock() var next claimUnitBinding if len(resolved) == 1 { next = resolved[0] } else { next, err = s.current(ctx, host) } if err != nil { return next, err } if !next.valid() || next.Owner.Host != host { return next, os.ErrPermission } names, err := s.state.Names() if err != nil { return claimUnitBinding{}, err } for _, name := range names { if name == "bindings" || name == host+".json" { continue } otherHost, ok := strings.CutSuffix(name, ".json") if !ok { return claimUnitBinding{}, os.ErrPermission } other, err := s.read(otherHost) if err != nil { continue } if other.Shape.Unit == next.Shape.Unit { return claimUnitBinding{}, os.ErrPermission } } old, err := s.read(host) if err == nil { if old != next { if !old.Retired || old.Shape != next.Shape || old.Owner == next.Owner || s.handover == nil { return claimUnitBinding{}, os.ErrPermission } if err := s.handover(ctx, old, next); err != nil { return claimUnitBinding{}, err } } } else if !errors.Is(err, os.ErrNotExist) { return claimUnitBinding{}, err } // Replay syncs the original binding before relying on an earlier rename. return next, s.write(next) } func (s *claimUnitBindings) check(ctx context.Context, host string, candidate *registry.Route) (claimUnitBinding, error) { saved, err := s.read(host) if err != nil { return saved, err } current, err := s.current(ctx, host) if err != nil { return claimUnitBinding{}, err } if saved.Retiring || saved.Retired || saved != current || (candidate != nil && !saved.Shape.matches(*candidate)) { return claimUnitBinding{}, os.ErrPermission } return saved, nil } func (s *claimUnitBindings) retire(ctx context.Context, captured claimUnitBinding, verify func(claimUnitBinding) error) error { if s == nil || s.state == nil || !captured.valid() || captured.Retiring || verify == nil { return os.ErrPermission } unlock, err := s.state.Lock(ctx, "bindings") if err != nil { return err } defer unlock() old, err := s.read(captured.Owner.Host) if err != nil { return err } if old.Owner != captured.Owner || old.Shape != captured.Shape { return os.ErrPermission } if old.Retired { return s.write(old) } old.Retiring = true if err := s.write(old); err != nil { return err } if err := verify(old); err != nil { return err } old.Retired = true return s.write(old) }