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
|
package main
import (
"fmt"
"strings"
)
// =============================================================================
// app names
// =============================================================================
// maxAppNameLen is the DNS label limit (RFC 1035 §2.3.4). An app name becomes a
// subdomain label, so it inherits the label limit rather than the 253-byte
// limit on a whole name.
const maxAppNameLen = 63
// reservedAppNames are names an app may not take because something else already
// answers to them once apps are published as <name>.<baseDomain>.
var reservedAppNames = map[string]bool{
"www": true,
"admin": true,
"api": true,
// Siblings of the app directories inside a state dir. An app by either name
// would collide with the extracted image trees (images/<name>) or with the
// environment database file itself (hosty.db — reserved as "hosty" below,
// but listed here in spirit: the state dir is a namespace shared between
// app directories and hosty's own entries).
"images": true,
// hosty's own future admin UI (see DESIGN.md, Phase 3). Reserving it here
// only blocks *user* installs: Phase 3 would claim the name from inside
// hosty, which does not go through this check.
"hosty": true,
}
// validateAppName enforces that an app name is a valid DNS label.
//
// The name is not just a label for humans: it is simultaneously a directory
// name under the state dir, a systemd unit name, the PORTABLE_PREFIXES value
// baked into the image, and (from Phase 2) a subdomain label. Restricting it to
// what a DNS label may contain is what keeps those four from ever disagreeing.
//
// Uppercase is rejected rather than normalised to lowercase. DNS is
// case-insensitive but Linux paths are not, so accepting "MyApp" would make it
// one subdomain but two images — the same aliasing that made is-attached
// misreport which app it was talking about (see DESIGN.md, Phase 1c).
//
// Callers must run this *before* creating any state, so that a rejected name
// cannot leave a half-installed app behind.
func validateAppName(name string) error {
if name == "" {
return fmt.Errorf("app name must not be empty")
}
if len(name) > maxAppNameLen {
return fmt.Errorf("app name %q is %d characters, but a DNS label may be at most %d",
name, len(name), maxAppNameLen)
}
for i := 0; i < len(name); i++ {
c := name[i]
switch {
case c >= 'a' && c <= 'z', c >= '0' && c <= '9':
// fine anywhere
case c == '-':
if i == 0 || i == len(name)-1 {
return fmt.Errorf("app name %q must not start or end with a dash", name)
}
case c >= 'A' && c <= 'Z':
return fmt.Errorf(
"app name %q must be lowercase (DNS is case-insensitive but file paths are not, so %q and its lowercase form would be one subdomain but two images)",
name, name)
default:
return fmt.Errorf(
"app name %q contains %q; only lowercase letters, digits and inner dashes are allowed",
name, string(c))
}
}
// hosty gives every app a companion FUSE unit called <name>-fs.service, so
// an app literally named "<something>-fs" would collide with the companion
// unit of the app "<something>".
if strings.HasSuffix(name, "-fs") {
return fmt.Errorf(
"app name %q must not end in -fs: that would collide with the companion unit %s.service of the app %q",
name, name, strings.TrimSuffix(name, "-fs"))
}
if reservedAppNames[name] {
return fmt.Errorf("app name %q is reserved", name)
}
return nil
}
|