/
procsolver.go
334 lines (299 loc) · 9.02 KB
/
procsolver.go
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
321
322
323
324
325
326
327
328
329
330
331
332
333
334
package acmecli
import (
"encoding/base64"
"encoding/csv"
"encoding/json"
"fmt"
"io"
"os"
"strconv"
"syscall"
"github.com/tommie/acme-go"
"github.com/tommie/acme-go/protocol"
"gopkg.in/square/go-jose.v2"
)
// A SolverMode is an identifier passed in the ACME_MODE environment
// variable. It corresponds with acme.Solver methods.
type SolverMode string
const (
ModeCost SolverMode = "cost"
ModeSolve SolverMode = "solve"
)
// A ProcessSolver is an acme.Solver that uses a child process to
// solve challenges. Note an individual solver may
// be instantiated multiple times. It is up to calling code and the
// solver child to block or handle concurrency.
//
// The parent communicates to the child via environment variables,
// stdin and stdout. ACME_MODE is one of the SolverMode
// constants. ACME_ACCOUNT_JWK is a base64-encoded jose.JSONWebKey.
//
// For stdin and stdout, CSV with new-line (record) and tab (field)
// separators are used. stdin receives challenges where the first
// field is the challenge type. stdout provides responses, also with
// the first field being the challenge type.
//
// A non-zero exit code will cause the solver to return failure.
type ProcessSolver struct {
accKey *jose.JSONWebKey
name string
argv []string
attr os.ProcAttr
}
// NewProcessSolver creates a new process solver. name, argv and attr
// follow the os.StartProcess semantics.
func NewProcessSolver(accKey *jose.JSONWebKey, name string, argv []string, attr *os.ProcAttr) *ProcessSolver {
if attr == nil {
attr = &os.ProcAttr{}
}
return &ProcessSolver{accKey, name, argv, *attr}
}
// Cost computes the cost of solving the challenges.
//
// It runs the solver in ModeCost, feeds the challenges as CSV
// records and expects a single float64 on stdout. If stdout is empty,
// it is assumed the challenges cannot be solved together.
func (s *ProcessSolver) Cost(cs []protocol.Challenge) (cost float64, errRet error) {
if err := canSolve(cs); err != nil {
return 0, err
}
p, r, stop, err := s.start(cs, ModeCost)
if err != nil {
return 0, fmt.Errorf("starting solver %q: %v", s.name, err)
}
defer func() {
if err := stop(); err != nil && errRet == nil {
errRet = err
}
}()
cr := csv.NewReader(r)
cr.Comma = '\t'
cr.FieldsPerRecord = -1
cost, err = readCost(cr)
if err == io.EOF {
return 0, acme.ErrUnsolvable
} else if err != nil {
p.Kill()
return 0, fmt.Errorf("reading cost from %q: %v", s.name, err)
}
return cost, nil
}
// canSolve returns whether all challenges are solvable.
func canSolve(cs []protocol.Challenge) error {
for _, c := range cs {
switch c.(type) {
case *protocol.DNS01Challenge,
*protocol.HTTP01Challenge,
*protocol.Possession01Challenge,
*protocol.TLSALPN01Challenge:
// continue
default:
return acme.ErrUnsolvable
}
}
return nil
}
// readCost attempts to read a single field of a single CSV record and
// parsing it as a float64. Returns io.EOF if no record is found.
func readCost(cr *csv.Reader) (float64, error) {
rec, err := cr.Read()
if err == io.EOF {
// Empty reply: cannot solve the challenges.
return 0, io.EOF
} else if err != nil {
return 0, err
}
if len(rec) != 1 {
return 0, fmt.Errorf("expected one field, got %v", rec)
}
cost, err := strconv.ParseFloat(rec[0], 64)
if err != nil {
return 0, fmt.Errorf("expected number, got %q", rec[0])
}
return cost, nil
}
// Solve instantiates the solver for the given challenges.
//
// It passes the challenges to the child, emits the blank trailing
// record and waits for responses. The child must output one response
// per challenge, in order.
//
// To stop the instance, call the returned stop function. This will
// close stdin, signaling the child to exit.
func (s *ProcessSolver) Solve(cs []protocol.Challenge) ([]protocol.Response, func() error, error) {
if err := canSolve(cs); err != nil {
return nil, nil, err
}
p, r, stop, err := s.start(cs, ModeSolve)
if err != nil {
return nil, nil, fmt.Errorf("starting solver %q: %v", s.name, err)
}
cr := csv.NewReader(r)
cr.Comma = '\t'
cr.FieldsPerRecord = -1
resps, err := readResponses(cr, cs)
if err != nil {
p.Kill()
stop()
return nil, nil, err
}
return resps, stop, nil
}
// readResponses reads len(cs) records from r and parses them into
// protocol.Response objects.
func readResponses(cr *csv.Reader, cs []protocol.Challenge) ([]protocol.Response, error) {
var ret []protocol.Response
for i, c := range cs {
resp, err := readResponse(cr)
if err == io.EOF {
return nil, fmt.Errorf("got %d responses, want %d", i, len(cs))
} else if err != nil {
return nil, err
}
if resp.GetType() != c.GetType() {
return nil, fmt.Errorf("mismatching response type: got %q, want %q", resp.GetType(), c.GetType())
}
ret = append(ret, resp)
}
return ret, nil
}
// readResponse reads a single record and parses it.
func readResponse(r *csv.Reader) (protocol.Response, error) {
rec, err := r.Read()
if err != nil {
return nil, err
}
t := protocol.ChallengeType(rec[0])
switch t {
case protocol.ChallengeDNS01:
if len(rec) != 2 {
return nil, fmt.Errorf("expected two fields for %s response, got %v", t, rec)
}
return &protocol.DNS01Response{Resource: protocol.ResourceChallenge, Type: t, KeyAuthorization: rec[1]}, nil
case protocol.ChallengeHTTP01:
if len(rec) != 2 {
return nil, fmt.Errorf("expected two fields for %s response, got %v", t, rec)
}
return &protocol.HTTP01Response{Resource: protocol.ResourceChallenge, Type: t, KeyAuthorization: rec[1]}, nil
case protocol.ChallengePossession01:
if len(rec) != 2 {
return nil, fmt.Errorf("expected two fields for %s response, got %v", t, rec)
}
jws, err := jose.ParseSigned(rec[1])
if err != nil {
return nil, err
}
return &protocol.Possession01Response{Resource: protocol.ResourceChallenge, Type: t, Authorization: protocol.JSONWebSignature(*jws)}, nil
case protocol.ChallengeTLSALPN01:
if len(rec) != 1 {
return nil, fmt.Errorf("expected one field for %s response, got %v", t, rec)
}
return &protocol.TLSALPN01Response{Resource: protocol.ResourceChallenge, Type: t}, nil
default:
return nil, fmt.Errorf("unknown challenge response type: %v", rec)
}
}
// start starts a new child process and feeds it the provided
// challenges. Returns the process, the stdout reader, and a function
// to stop the process.
func (s *ProcessSolver) start(cs []protocol.Challenge, mode SolverMode) (*os.Process, io.ReadCloser, func() error, error) {
attr := *&s.attr
jwk, err := json.Marshal(s.accKey)
if err != nil {
return nil, nil, nil, err
}
if attr.Env == nil {
attr.Env = os.Environ()
}
attr.Env = append(attr.Env, "ACME_MODE="+string(mode), "ACME_ACCOUNT_JWK="+string(jwk))
stdin, stdinw, err := os.Pipe()
if err != nil {
return nil, nil, nil, err
}
defer stdin.Close()
go func() {
cw := csv.NewWriter(stdinw)
cw.Comma = '\t'
for _, c := range cs {
if err := writeChallenge(cw, c, s.accKey); err != nil {
panic(fmt.Errorf("error: writing challenge %+v: %v\n", c, err))
}
}
// Terminate challenges with an empty record.
cw.Write(nil)
cw.Flush()
}()
stdoutr, stdout, err := os.Pipe()
if err != nil {
return nil, nil, nil, err
}
defer stdout.Close()
if len(attr.Files) <= syscall.Stderr {
attr.Files = make([]*os.File, syscall.Stderr+1)
}
attr.Files[syscall.Stdin] = stdin
attr.Files[syscall.Stdout] = stdout
attr.Files[syscall.Stderr] = os.Stderr
p, err := os.StartProcess(s.name, s.argv, &attr)
stop := func() error {
go func() {
defer stdoutr.Close()
bs := make([]byte, 1024)
for {
// Continue reading the stdout pipe and then close,
// to avoid SIGPIPE in the child.
_, err := stdoutr.Read(bs)
if err == io.EOF {
break
} else if err != nil {
return
}
}
}()
// Closing stdin signals to the child to stop the solver and terminate.
err := stdinw.Close()
if err != nil {
return err
}
ps, err := p.Wait()
if err != nil {
return err
}
if !ps.Success() {
return fmt.Errorf("solver %q failed: %s", s.name, ps)
}
return nil
}
return p, stdoutr, stop, err
}
// writeChallenge marshals the challenge and writes it as CSV.
func writeChallenge(w *csv.Writer, c protocol.Challenge, accKey *jose.JSONWebKey) error {
switch cc := c.(type) {
case *protocol.DNS01Challenge:
ka, err := protocol.KeyAuthz(cc.Token, accKey)
if err != nil {
return err
}
return w.Write([]string{string(cc.GetType()), cc.Token, ka, protocol.DNS01TXTRecord(ka)})
case *protocol.HTTP01Challenge:
ka, err := protocol.KeyAuthz(cc.Token, accKey)
if err != nil {
return err
}
return w.Write([]string{string(cc.GetType()), cc.Token, ka})
case *protocol.Possession01Challenge:
rec := []string{string(cc.GetType())}
for _, bs := range cc.Certs {
rec = append(rec, base64.URLEncoding.EncodeToString(bs))
}
return w.Write(rec)
case *protocol.TLSALPN01Challenge:
bs, err := protocol.TLSALPN01Validation(cc.Token, accKey)
if err != nil {
return err
}
return w.Write([]string{string(cc.GetType()), base64.URLEncoding.EncodeToString(bs)})
default:
return fmt.Errorf("unknown challenge type: %#v", c)
}
}