| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/sha256" |
| 7 | "encoding/hex" |
| 8 | "encoding/json" |
| 9 | "errors" |
| 10 | "io" |
| 11 | "os" |
| 12 | "path/filepath" |
| 13 | "strings" |
| 14 | |
| 15 | "github.com/carlosframework/platform/internal/controlname" |
| 16 | "github.com/carlosframework/platform/internal/hostfloor" |
| 17 | "github.com/carlosframework/platform/internal/provision" |
| 18 | "github.com/carlosframework/platform/internal/release" |
| 19 | "github.com/carlosframework/platform/internal/rootstate" |
| 20 | ) |
| 21 | |
| 22 | const rootNamedUnits = "/etc/carlos/named-units.json" |
| 23 | |
| 24 | type namedUnit struct { |
| 25 | Host string `json:"host"` |
| 26 | Account string `json:"account,omitempty"` |
| 27 | App string `json:"app"` |
| 28 | Unit string `json:"unit"` |
| 29 | Addr string `json:"addr"` |
| 30 | Health *provision.Health `json:"health"` |
| 31 | } |
| 32 | |
| 33 | type proofRecord struct { |
| 34 | record *provision.Record |
| 35 | key, revision, execPath string |
| 36 | local bool |
| 37 | files []proofUnitFile |
| 38 | } |
| 39 | |
| 40 | func (s *proofBindingSource) trustedBytes(path string) ([]byte, proofUnitFile, error) { |
| 41 | f, err := s.openFile(path) |
| 42 | if err != nil { |
| 43 | return nil, proofUnitFile{}, err |
| 44 | } |
| 45 | defer f.Close() |
| 46 | if path == s.namedPath { |
| 47 | if err := rootstate.CheckPublicConfig(f); err != nil { |
| 48 | return nil, proofUnitFile{}, err |
| 49 | } |
| 50 | } |
| 51 | identity, err := rootstate.Identity(f) |
| 52 | if err != nil { |
| 53 | return nil, proofUnitFile{}, err |
| 54 | } |
| 55 | raw, err := io.ReadAll(io.LimitReader(f, rootstate.MaxRecord+1)) |
| 56 | if err != nil { |
| 57 | return nil, proofUnitFile{}, err |
| 58 | } |
| 59 | if len(raw) > rootstate.MaxRecord { |
| 60 | return nil, proofUnitFile{}, os.ErrPermission |
| 61 | } |
| 62 | file := proofUnitFile{Path: path, Identity: identity} |
| 63 | if s.persistent != nil { |
| 64 | file.Persistent, err = s.persistent(f) |
| 65 | if err != nil { |
| 66 | return nil, proofUnitFile{}, err |
| 67 | } |
| 68 | } |
| 69 | return raw, file, nil |
| 70 | } |
| 71 | |
| 72 | func (s *proofBindingSource) record(ctx context.Context, host string) (proofRecord, error) { |
| 73 | rec, etag, err := s.findRecord(ctx, host) |
| 74 | if err == nil { |
| 75 | return proofRecord{record: rec, key: provision.RecordKey(rec.Account, rec.App, host), revision: etag}, nil |
| 76 | } |
| 77 | if !errors.Is(err, os.ErrNotExist) || s.namedPath == "" { |
| 78 | return proofRecord{}, err |
| 79 | } |
| 80 | var selected *namedUnit |
| 81 | var evidence []proofUnitFile |
| 82 | hash := sha256.New() |
| 83 | add := func(path string) ([]byte, error) { |
| 84 | raw, file, err := s.trustedBytes(path) |
| 85 | if err != nil { |
| 86 | return nil, err |
| 87 | } |
| 88 | evidence = append(evidence, file) |
| 89 | identity := proofFileSource(file) |
| 90 | if file.Persistent.Boot == "" { |
| 91 | identity, _ = json.Marshal(struct { |
| 92 | Path string |
| 93 | Identity rootstate.FileIdentity |
| 94 | }{file.Path, file.Identity}) |
| 95 | } |
| 96 | hash.Write(identity) |
| 97 | hash.Write(raw) |
| 98 | return raw, nil |
| 99 | } |
| 100 | raw, err := add(s.namedPath) |
| 101 | if err == nil { |
| 102 | var list struct { |
| 103 | Schema int `json:"schema"` |
| 104 | Units []namedUnit `json:"units"` |
| 105 | } |
| 106 | dec := json.NewDecoder(bytes.NewReader(raw)) |
| 107 | dec.DisallowUnknownFields() |
| 108 | if dec.Decode(&list) != nil || dec.Decode(new(any)) != io.EOF || list.Schema != 1 { |
| 109 | return proofRecord{}, os.ErrPermission |
| 110 | } |
| 111 | for _, u := range list.Units { |
| 112 | if !controlname.ValidHostShape(u.Host) || !release.ValidApp(u.App) || (u.Account != "" && !release.ValidAccount(u.Account)) { |
| 113 | return proofRecord{}, os.ErrPermission |
| 114 | } |
| 115 | if u.Host == host { |
| 116 | if selected != nil { |
| 117 | return proofRecord{}, os.ErrPermission |
| 118 | } |
| 119 | copy := u |
| 120 | selected = © |
| 121 | } |
| 122 | } |
| 123 | } else if !errors.Is(err, os.ErrNotExist) { |
| 124 | return proofRecord{}, err |
| 125 | } |
| 126 | marker := filepath.Join(s.optDir, "system", "guard", "console-ready.json") |
| 127 | console, consoleErr := add(marker) |
| 128 | if consoleErr != nil && !errors.Is(consoleErr, os.ErrNotExist) { |
| 129 | return proofRecord{}, consoleErr |
| 130 | } |
| 131 | guarded := consoleErr == nil && string(console) == release.LiveLink(s.optDir, host) |
| 132 | execPath := release.LiveLink(s.optDir, host) |
| 133 | if guarded { |
| 134 | dropin := filepath.Join(s.unitDirs[0], "console.service.d", "90-host-floor.conf") |
| 135 | raw, err := add(dropin) |
| 136 | if err != nil || string(raw) != hostfloor.ConsoleGuardDropIn(string(console)) { |
| 137 | return proofRecord{}, os.ErrPermission |
| 138 | } |
| 139 | if selected == nil { |
| 140 | selected = &namedUnit{Host: host, App: "console", Unit: "console.service", Addr: "127.0.0.1:8090", Health: &provision.Health{Path: "/healthz"}} |
| 141 | } |
| 142 | if selected.Unit != "console.service" || selected.App != "console" || selected.Account != "" { |
| 143 | return proofRecord{}, os.ErrPermission |
| 144 | } |
| 145 | execPath = "/opt/carlos/system/guard/carlos" |
| 146 | } |
| 147 | if selected == nil { |
| 148 | return proofRecord{}, os.ErrNotExist |
| 149 | } |
| 150 | if selected.Health == nil || selected.Health.Path == "" || selected.Addr == "" || strings.HasPrefix(selected.Unit, "carlos-app@") { |
| 151 | return proofRecord{}, os.ErrPermission |
| 152 | } |
| 153 | rec = &provision.Record{Schema: 1, Host: host, Account: selected.Account, App: selected.App, Unit: selected.Unit, Kind: "instance", Addr: selected.Addr, Health: selected.Health} |
| 154 | return proofRecord{record: rec, key: s.namedPath, revision: hex.EncodeToString(hash.Sum(nil)), execPath: execPath, local: true, files: evidence}, nil |
| 155 | } |
| 156 | |
| 157 | func proofExecPath(b proofBinding, opt string) string { |
| 158 | if b.ExecPath != "" { |
| 159 | return b.ExecPath |
| 160 | } |
| 161 | return release.LiveLink(opt, b.Route.Host) |
| 162 | } |
| 163 | |
| 164 | func (s *proofBindingSource) findRecord(ctx context.Context, host string) (*provision.Record, string, error) { |
| 165 | if !s.cacheIndex { |
| 166 | return provision.FindRecordByHost(ctx, s.store, host) |
| 167 | } |
| 168 | s.indexMu.Lock() |
| 169 | defer s.indexMu.Unlock() |
| 170 | if s.index == nil && s.indexErr == nil { |
| 171 | s.index, s.indexErr = provision.IndexRecords(ctx, s.store) |
| 172 | } |
| 173 | if s.indexErr != nil { |
| 174 | return nil, "", s.indexErr |
| 175 | } |
| 176 | return s.index.Find(ctx, s.store, host) |
| 177 | } |
| 178 | |