1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
package main

import (
	"database/sql"
	"fmt"
	"os"
	"path/filepath"
	"strconv"
)

// =============================================================================
// The environment database
// =============================================================================
//
// hosty.db sits at the root of a state directory and describes that whole
// environment: settings that apply to all apps, plus one row per installed app
// with its port and its sensitive config values.
//
// It replaces the per-app runtime.hosty, and the reason is a security bug
// rather than tidiness. In system mode systemd's StateDirectory= chowns the
// app's directory to the app's own DynamicUser, so a file inside it belongs to
// the app:
//
//	-rw------- 1 rss-parrot rss-parrot  runtime.hosty
//
// That file held _hosty_config_shadow, the cleartext source for every sensitive
// value. Mode 0600 sounds right, but the owner *is* the app: it could read its
// own secret store and — being the owner — rewrite it, after which hosty would
// dutifully re-materialise the altered values as credentials on the next start.
// That defeats the whole point of the LoadCredential path (see
// writeCredentials), which exists to hand an app only its own secrets through a
// channel hosty controls.
//
// The environment root is not exposed to apps: an app's namespace shows only
// its own subdirectory of the state dir (verified on the running services), so
// a database at the root is unreachable from inside any app. Hence 0600 root
// here is a real boundary, where inside the app directory it was not.
//
// Two smaller things fall out of the move:
//
//   - Ports become an enforced invariant. They used to live in each app's own
//     file — i.e. in a place the app could rewrite — with no cross-app view, so
//     "is this port taken" could not be asked. UNIQUE(port) now answers it.
//   - Listing apps stops creating files. infoAll called openRuntimeDB on every
//     directory, which *created* runtime.hosty as a side effect of merely
//     listing; several never-started apps had one.

// envDBName is the file name of the environment database, at the state dir root.
//
// Deliberately a sibling of the per-app directories rather than a hidden file:
// it is the state directory's index, and `images` is already a sibling of the
// same kind. Both are kept out of the app namespace by validateAppName.
const envDBName = "hosty.db"

// envDBPath returns the environment database path for a state directory.
func envDBPath(stateDir string) string { return filepath.Join(stateDir, envDBName) }

// openEnvDB opens (and initialises) the environment database for a state
// directory, creating the directory if needed.
//
// Created 0600: it holds every app's sensitive config values in cleartext. The
// state directory itself is 0700 (root, in system mode), so this is defence in
// depth rather than the only barrier.
func openEnvDB(stateDir string) (*sql.DB, error) {
	if err := os.MkdirAll(stateDir, 0700); err != nil {
		return nil, fmt.Errorf("creating state dir: %w", err)
	}
	path := envDBPath(stateDir)
	isNew := !fileExists(path)
	if isNew {
		// Create with restricted permissions *before* opening, so there is no
		// window in which the file exists world-readable.
		f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL, 0600)
		if err != nil && !os.IsExist(err) {
			return nil, fmt.Errorf("creating %s: %w", envDBName, err)
		}
		if f != nil {
			f.Close()
		}
	}
	db, err := openEnvSQL(path)
	if err != nil {
		return nil, err
	}
	if isNew {
		if err := os.Chmod(path, 0600); err != nil {
			db.Close()
			return nil, fmt.Errorf("chmod %s: %w", envDBName, err)
		}
	}
	if err := initEnvTables(db); err != nil {
		db.Close()
		return nil, err
	}
	return db, nil
}

// openEnvSQL opens the environment database with foreign keys enforced.
//
// SQLite defaults foreign_keys to OFF, and an unenforced constraint is worse
// than none: it silently accepts orphan rows while reading as if it guaranteed
// they cannot exist. It must therefore be requested on every connection, which
// is what the _pragma DSN parameter does (the driver runs each one per
// connection and fails if it cannot).
//
// Note the spelling matters — see the comment on openDB: DSN parameters the
// driver does not recognise are ignored silently, which is how an earlier
// attempt at _journal_mode= set nothing at all. `_pragma=foreign_keys(1)` is
// the form this driver actually executes.
func openEnvSQL(path string) (*sql.DB, error) {
	db, err := sql.Open("sqlite",
		"file:"+path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)")
	if err != nil {
		return nil, fmt.Errorf("opening %s: %w", envDBName, err)
	}
	return db, nil
}

// openEnvSQLReadOnly opens an existing environment database read-only, without
// creating or initialising anything.
//
// For inspection paths (`hosty info`), where opening must not have side effects
// and must not fail merely because the file is absent or unreadable.
func openEnvSQLReadOnly(path string) (*sql.DB, error) {
	db, err := sql.Open("sqlite",
		"file:"+path+"?mode=ro&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)")
	if err != nil {
		return nil, fmt.Errorf("opening %s read-only: %w", envDBName, err)
	}
	return db, nil
}

// initEnvTables creates the environment schema.
func initEnvTables(db *sql.DB) error {
	if _, err := db.Exec(`
		-- Settings for the environment as a whole (schema version, and from
		-- Phase 2 the base domain apps are published under). Key/value because
		-- these are few and unrelated.
		CREATE TABLE IF NOT EXISTS _hosty_env (
			key   TEXT PRIMARY KEY,
			value TEXT NOT NULL
		);

		-- One row per installed app.
		--
		-- UNIQUE(port) is the point: a port used to be recorded only in the
		-- app's own file, so nothing could tell whether another app already had
		-- it. freePort() asks the kernel for an unused port, which answers
		-- "unused right now" — not "unclaimed by a hosty app that happens to be
		-- stopped".
		CREATE TABLE IF NOT EXISTS _hosty_apps (
			name TEXT PRIMARY KEY,
			port INTEGER UNIQUE
		);

		-- Sensitive config values, keyed by app.
		--
		-- ON DELETE CASCADE ties a secret's lifetime to its app. Previously
		-- secrets lived in the app directory and 'hosty stop' (without
		-- -remove-data) left them behind indefinitely, with nothing recording
		-- that they were still there.
		CREATE TABLE IF NOT EXISTS _hosty_secrets (
			app   TEXT NOT NULL REFERENCES _hosty_apps(name) ON DELETE CASCADE,
			key   TEXT NOT NULL,
			value TEXT NOT NULL,
			PRIMARY KEY (app, key)
		);
	`); err != nil {
		return fmt.Errorf("init environment tables: %w", err)
	}
	// Record the schema version on first creation so a future change has
	// something to migrate from.
	if _, err := db.Exec(
		`INSERT OR IGNORE INTO _hosty_env (key, value) VALUES ('schema_version', '1')`,
	); err != nil {
		return fmt.Errorf("recording schema version: %w", err)
	}
	return nil
}

// envGet reads an environment-wide setting.
func envGet(db *sql.DB, key string) (string, error) {
	var val string
	if err := db.QueryRow(`SELECT value FROM _hosty_env WHERE key = ?`, key).Scan(&val); err != nil {
		return "", fmt.Errorf("_hosty_env[%q]: %w", key, err)
	}
	return val, nil
}

// envSet writes an environment-wide setting.
func envSet(db *sql.DB, key, value string) error {
	_, err := db.Exec(`INSERT OR REPLACE INTO _hosty_env (key, value) VALUES (?, ?)`, key, value)
	return err
}

// appRegister ensures an app has a row, so that secrets can reference it.
// Called before any secret is written, since the foreign key is enforced.
func appRegister(db *sql.DB, name string) error {
	_, err := db.Exec(`INSERT OR IGNORE INTO _hosty_apps (name) VALUES (?)`, name)
	if err != nil {
		return fmt.Errorf("registering app %q: %w", name, err)
	}
	return nil
}

// appPort returns the port assigned to an app, or "" if it has none yet.
//
// No error for "not installed": callers ask this to decide whether to allocate,
// so absence is an ordinary answer rather than a failure.
func appPort(db *sql.DB, name string) (string, error) {
	var port sql.NullInt64
	err := db.QueryRow(`SELECT port FROM _hosty_apps WHERE name = ?`, name).Scan(&port)
	if err == sql.ErrNoRows {
		return "", nil
	}
	if err != nil {
		return "", fmt.Errorf("reading port for %q: %w", name, err)
	}
	if !port.Valid {
		return "", nil
	}
	return strconv.FormatInt(port.Int64, 10), nil
}

// appSetPort records an app's port assignment.
//
// A port already held by *another* app is refused rather than stolen: the
// UNIQUE constraint makes that a real error instead of two apps silently
// sharing a number and one of them failing to bind at start.
func appSetPort(db *sql.DB, name, port string) error {
	p, err := strconv.Atoi(port)
	if err != nil {
		return fmt.Errorf("port %q is not a number: %w", port, err)
	}
	if _, err := db.Exec(`
		INSERT INTO _hosty_apps (name, port) VALUES (?, ?)
		ON CONFLICT(name) DO UPDATE SET port = excluded.port
	`, name, p); err != nil {
		var other string
		if qErr := db.QueryRow(`SELECT name FROM _hosty_apps WHERE port = ?`, p).Scan(&other); qErr == nil && other != name {
			return fmt.Errorf("port %s is already assigned to app %q", port, other)
		}
		return fmt.Errorf("assigning port %s to %q: %w", port, name, err)
	}
	return nil
}

// appForget removes an app's row, and with it (via ON DELETE CASCADE) its
// secrets and its claim on a port.
func appForget(db *sql.DB, name string) error {
	if _, err := db.Exec(`DELETE FROM _hosty_apps WHERE name = ?`, name); err != nil {
		return fmt.Errorf("removing app %q from environment: %w", name, err)
	}
	return nil
}

// secretGet reads a sensitive config value for an app.
func secretGet(db *sql.DB, app, key string) (string, error) {
	var val string
	err := db.QueryRow(`SELECT value FROM _hosty_secrets WHERE app = ? AND key = ?`, app, key).Scan(&val)
	if err != nil {
		return "", err
	}
	return val, nil
}

// secretSet writes a sensitive config value for an app.
func secretSet(db *sql.DB, app, key, value string) error {
	// The foreign key is enforced, so the app row must exist first. Registering
	// here keeps callers from having to remember the ordering.
	if err := appRegister(db, app); err != nil {
		return err
	}
	_, err := db.Exec(
		`INSERT OR REPLACE INTO _hosty_secrets (app, key, value) VALUES (?, ?, ?)`,
		app, key, value)
	return err
}

// secretUnset removes a sensitive config value for an app.
func secretUnset(db *sql.DB, app, key string) error {
	_, err := db.Exec(`DELETE FROM _hosty_secrets WHERE app = ? AND key = ?`, app, key)
	return err
}

// envAppPortOrEmpty and envSecretOrMissing are the nil-tolerant variants used
// by the inspection commands, which may have no environment database at all
// (absent file, or unreadable because the caller is not root). Reporting
// "unknown" is the right answer there; failing is not.
func envAppPortOrEmpty(db *sql.DB, app string) (string, error) {
	if db == nil {
		return "", nil
	}
	return appPort(db, app)
}

func envSecretOrMissing(db *sql.DB, app, key string) (string, error) {
	if db == nil {
		return "", sql.ErrNoRows
	}
	return secretGet(db, app, key)
}

// secretsAll returns every sensitive config value for an app.
func secretsAll(db *sql.DB, app string) (map[string]string, error) {
	rows, err := db.Query(`SELECT key, value FROM _hosty_secrets WHERE app = ?`, app)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := map[string]string{}
	for rows.Next() {
		var k, v string
		if err := rows.Scan(&k, &v); err != nil {
			return nil, err
		}
		out[k] = v
	}
	return out, rows.Err()
}