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
|
package main
import (
"database/sql"
"fmt"
"os"
"strings"
)
// =============================================================================
// _hosty_config helpers
// =============================================================================
type configEntry struct {
key string
desc string
required bool
sensitive bool
defaultVal *string
}
// splitConfigSpec splits a config spec string on ':' treating '::' as a
// literal ':'. Returns the unescaped fields.
func splitConfigSpec(s string) []string {
var fields []string
var cur strings.Builder
for i := 0; i < len(s); i++ {
if s[i] == ':' && i+1 < len(s) && s[i+1] == ':' {
cur.WriteByte(':')
i++ // skip second ':'
} else if s[i] == ':' {
fields = append(fields, cur.String())
cur.Reset()
} else {
cur.WriteByte(s[i])
}
}
fields = append(fields, cur.String())
return fields
}
// parseConfigEntry parses a -config flag value of the form:
//
// KEY:description (may contain :: escaped colons):modifier1,modifier2,...
//
// Modifiers: required (default), optional, sensitive, default=VALUE
func parseConfigEntry(s string) (configEntry, error) {
fields := splitConfigSpec(s)
if len(fields) < 2 {
return configEntry{}, fmt.Errorf("config spec %q: must have at least KEY:modifiers", s)
}
e := configEntry{
key: fields[0],
required: true, // default
}
if e.key == "" {
return configEntry{}, fmt.Errorf("config spec %q: key may not be empty", s)
}
// Middle fields (between key and last field) are the description.
modifiersField := fields[len(fields)-1]
if len(fields) > 2 {
e.desc = strings.Join(fields[1:len(fields)-1], ":")
}
// If only 2 fields and last field looks like modifiers (no spaces), treat as modifiers only.
// If it looks like a description (has spaces or no known modifier keywords), treat as description
// with empty modifiers.
if len(fields) == 2 {
// Heuristic: if second field contains only modifier keywords separated by commas, it's modifiers.
// Otherwise treat as description with no modifiers.
looksLikeModifiers := true
for mod := range strings.SplitSeq(modifiersField, ",") {
mod = strings.TrimSpace(mod)
if mod != "required" && mod != "optional" && mod != "sensitive" && !strings.HasPrefix(mod, "default=") {
looksLikeModifiers = false
break
}
}
if !looksLikeModifiers {
e.desc = modifiersField
modifiersField = ""
}
}
// Parse modifiers.
for mod := range strings.SplitSeq(modifiersField, ",") {
mod = strings.TrimSpace(mod)
switch {
case mod == "" || mod == "required":
// required is the default, nothing to do
case mod == "optional":
e.required = false
case mod == "sensitive":
e.sensitive = true
case strings.HasPrefix(mod, "default="):
val := mod[len("default="):]
e.defaultVal = &val
e.required = false // having a default implies optional
default:
return configEntry{}, fmt.Errorf("config spec %q: unknown modifier %q", s, mod)
}
}
return e, nil
}
// stripSensitiveFromData moves any sensitive values that arrived in data.hosty
// (e.g. from an exported .hosty with -include-secrets) into the environment
// database, then NULLs them in data.hosty.
// Write order: environment first, then NULL in data — so a crash between the
// two leaves the value in both places (harmless; we re-strip on next start).
func stripSensitiveFromData(dataDB, envDB *sql.DB, app string) error {
rows, err := dataDB.Query(`
SELECT key, value FROM _hosty_config
WHERE sensitive = 1 AND value IS NOT NULL
`)
if err != nil {
return nil // table may not have value column yet
}
defer rows.Close()
var keys []string
vals := map[string]string{}
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return err
}
keys = append(keys, k)
vals[k] = v
}
if err := rows.Err(); err != nil {
return err
}
rows.Close()
for _, k := range keys {
// 1. Write to the environment database (durable first).
if err := secretSet(envDB, app, k, vals[k]); err != nil {
return fmt.Errorf("writing secret %q: %w", k, err)
}
// 2. NULL in data.hosty.
if _, err := dataDB.Exec(`UPDATE _hosty_config SET value = NULL WHERE key = ?`, k); err != nil {
return fmt.Errorf("nulling %q in data.hosty: %w", k, err)
}
fmt.Fprintf(os.Stderr, "hosty: moved sensitive config %q from data.hosty → %s\n", k, envDBName)
}
return nil
}
// missingRequiredConfig returns two lists of required config entries that have
// no value and no default:
// - hard: non-sensitive; hosty start will refuse to proceed
// - soft: sensitive; hosty start will warn but continue (user may inject externally)
func missingRequiredConfig(dataDB, envDB *sql.DB, app string) (hard, soft []configEntry, err error) {
rows, err := dataDB.Query(`
SELECT key, description, sensitive, default_val, value
FROM _hosty_config
WHERE required = 1
ORDER BY key
`)
if err != nil {
// Table may not exist for apps packed before this feature.
return nil, nil, nil
}
defer rows.Close()
for rows.Next() {
var e configEntry
var defaultVal, value sql.NullString
var sens int
e.required = true
if err := rows.Scan(&e.key, &e.desc, &sens, &defaultVal, &value); err != nil {
return nil, nil, err
}
e.sensitive = sens == 1
if defaultVal.Valid {
continue // has a default — not missing
}
if !e.sensitive && value.Valid {
continue // non-sensitive value set in data.hosty
}
if e.sensitive {
if _, err := secretGet(envDB, app, e.key); err == nil {
continue // sensitive value set in the environment database
}
soft = append(soft, e)
} else {
hard = append(hard, e)
}
}
return hard, soft, rows.Err()
}
|