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
|
package main
import (
"flag"
"fmt"
"io"
"net/http"
"os"
"strings"
)
// =============================================================================
// hosty serve-ask — on-demand TLS permission endpoint for Caddy
// =============================================================================
// askDecision reports whether a certificate may be issued for domain, and why.
//
// Split out from the HTTP handler so the policy is testable without a socket or
// a systemd. attachedFn abstracts "is this app attached", which is the only
// piece that touches the host.
//
// The rule is deliberately narrow, because a permissive answer here is a
// standing invitation to mint certificates:
//
// 1. the domain must be exactly <label>.<baseDomain> — one label, no deeper
// nesting, and the base domain itself is not an app;
// 2. the label must be a valid app name (the same check `hosty pack` and
// `setup()` apply, so a name that could never be installed is never asked
// about twice);
// 3. an image by that name must be *attached*.
//
// Attachment rather than liveness is the criterion on purpose. It is what
// `hosty start`/`hosty stop` actually toggle (via portablectl attach/detach), so
// it tracks "is this app published" without punishing an app that happens to be
// crash-looping or briefly restarting — certificates outlive such blips by
// months, and losing renewal over one would be worse than serving a cert for a
// temporarily-down app.
func askDecision(domain, baseDomain string, attachedFn func(string) bool) (ok bool, reason string) {
if domain == "" {
return false, "empty domain"
}
// Caddy passes the SNI value verbatim. Normalise the trailing dot of a
// fully-qualified name, and reject anything that is not plainly a hostname.
domain = strings.TrimSuffix(domain, ".")
if domain != strings.ToLower(domain) {
return false, "domain is not lowercase"
}
suffix := "." + baseDomain
if !strings.HasSuffix(domain, suffix) {
return false, "domain is not under " + baseDomain
}
label := strings.TrimSuffix(domain, suffix)
if label == "" {
return false, "no app label"
}
// Exactly one label: a.b.<base> must not be treated as app "a.b". The
// wildcard A record resolves at any depth, so this is reachable.
if strings.Contains(label, ".") {
return false, "domain has more than one label under " + baseDomain
}
if err := validateAppName(label); err != nil {
return false, "invalid app name: " + err.Error()
}
if !attachedFn(label) {
return false, "app " + label + " is not attached"
}
return true, "app " + label + " is attached"
}
// isPublished reports whether an app is currently attached as a portable
// service, by testing for the unit file portabled maintains.
//
// Testing a world-readable path is what allows the ask service to run
// unprivileged. Reading hosty's own state under /var/lib/hosty would need root
// (it is 0700, and app dirs are symlinks into the equally closed
// /var/lib/private) *and* would answer the wrong question, since `hosty stop`
// leaves data.hosty in place — a stopped app would keep renewing certificates
// forever.
//
// Lstat, not Stat: portabled attaches with --copy=symlink, so the unit is a
// *symlink* into /var/lib/hosty/images/<name>/... . Stat follows it and lands
// under that 0700 root directory, so an unprivileged caller gets EACCES and
// every app looks detached. Only the link's own existence is wanted here, and
// that is exactly what portabled adds on attach and removes on detach.
func isPublished(name string) bool {
return isPublishedIn(attachedUnitDir, name)
}
// isPublishedIn is isPublished against an arbitrary attached-unit directory.
func isPublishedIn(dir, name string) bool {
_, err := os.Lstat(attachedUnitPathIn(dir, name))
return err == nil
}
func cmdServeAsk(args []string) error {
fset := flag.NewFlagSet("serve-ask", flag.ExitOnError)
fset.Usage = func() {
fmt.Print(`Usage: hosty serve-ask -base-domain DOMAIN [-addr ADDR]
Serve the on-demand TLS permission endpoint used by Caddy.
Caddy refuses to enable on-demand TLS without such an endpoint, because
otherwise anyone pointing a hostname at this host could make it mint
certificates. Caddy requests GET /ask?domain=<name>; a 2xx reply authorises
issuance for that name and any other status refuses it.
Answers 200 only for <app>.<base-domain> where <app> is a currently attached
hosty app, and 404 otherwise. Read-only: it opens no databases, writes nothing,
and needs no privileges.
Options:
`)
fset.PrintDefaults()
}
baseDomain := fset.String("base-domain", "", "Base domain apps are published under (required)")
addr := fset.String("addr", "127.0.0.1:9123", "Address to listen on")
if err := fset.Parse(args); err != nil {
return err
}
if *baseDomain == "" {
fset.Usage()
return fmt.Errorf("-base-domain is required")
}
mux := askMux(*baseDomain, isPublished, os.Stderr)
fmt.Fprintf(os.Stderr, "hosty serve-ask: listening on %s for *.%s\n", *addr, *baseDomain)
return http.ListenAndServe(*addr, mux)
}
// askMux builds the ask endpoint's routes. Separated from cmdServeAsk so the
// HTTP surface (status codes, query handling) can be exercised in tests without
// binding a socket.
func askMux(baseDomain string, attachedFn func(string) bool, logTo io.Writer) *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("/ask", func(w http.ResponseWriter, r *http.Request) {
domain := r.URL.Query().Get("domain")
ok, reason := askDecision(domain, baseDomain, attachedFn)
// Logged at both outcomes: a refusal here surfaces to the user as an
// opaque TLS handshake failure, so the log is the only place the actual
// reason can be found.
fmt.Fprintf(logTo, "hosty serve-ask: domain=%q allowed=%v (%s)\n", domain, ok, reason)
if !ok {
http.Error(w, reason, http.StatusNotFound)
return
}
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "ok")
})
// Anything else is a misconfiguration (Caddy only ever calls /ask); answer
// 404 rather than Go's default, so a stray probe cannot be read as consent.
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not found", http.StatusNotFound)
})
return mux
}
|