deploy.go raw
1 package app
2
3 import (
4 "archive/tar"
5 "bytes"
6 "crypto/sha256"
7 "encoding/hex"
8 "fmt"
9 "io"
10 "net/http"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "strconv"
15 "strings"
16 "sync"
17
18 "git.smesh.lol/orly/pkg/lol/log"
19 "git.smesh.lol/orly/pkg/nostr/interfaces/signer/p8k"
20 )
21
22 const (
23 deployMaxSize = 50 << 20 // 50 MB total
24 deployMaxChunk = 1 << 20 // 1 MB per chunk
25 )
26
27 // deploySession holds chunks being uploaded.
28 type deploySession struct {
29 parts map[int][]byte
30 total int
31 }
32
33 var (
34 deploySessions = map[string]*deploySession{}
35 deploySessionsMu sync.Mutex
36 )
37
38 func (s *Smesh3Server) handleDeploy(w http.ResponseWriter, r *http.Request) {
39 if r.Method != http.MethodPost {
40 http.Error(w, "POST only", http.StatusMethodNotAllowed)
41 return
42 }
43 if s.dir == "" {
44 http.Error(w, "deploy requires disk mode (ORLY_SMESH3_DIR)", http.StatusBadRequest)
45 return
46 }
47
48 action := r.URL.Query().Get("action")
49
50 switch action {
51 case "begin":
52 s.deployBegin(w, r)
53 case "part":
54 s.deployPart(w, r)
55 case "apply":
56 s.deployApply(w, r)
57 default:
58 http.Error(w, "unknown action", http.StatusBadRequest)
59 }
60 }
61
62 func (s *Smesh3Server) deployBegin(w http.ResponseWriter, r *http.Request) {
63 hash := r.URL.Query().Get("hash")
64 totalStr := r.URL.Query().Get("parts")
65 total, err := strconv.Atoi(totalStr)
66 if err != nil || total <= 0 || total > 1000 || len(hash) != 64 {
67 http.Error(w, "bad params", http.StatusBadRequest)
68 return
69 }
70
71 deploySessionsMu.Lock()
72 deploySessions[hash] = &deploySession{
73 parts: make(map[int][]byte),
74 total: total,
75 }
76 deploySessionsMu.Unlock()
77
78 log.I.F("smesh deploy: begin hash=%s parts=%d", hash[:16], total)
79 fmt.Fprint(w, "ok")
80 }
81
82 func (s *Smesh3Server) deployPart(w http.ResponseWriter, r *http.Request) {
83 hash := r.URL.Query().Get("hash")
84 indexStr := r.URL.Query().Get("index")
85 index, err := strconv.Atoi(indexStr)
86 if err != nil || len(hash) != 64 {
87 http.Error(w, "bad params", http.StatusBadRequest)
88 return
89 }
90
91 deploySessionsMu.Lock()
92 sess, ok := deploySessions[hash]
93 deploySessionsMu.Unlock()
94 if !ok {
95 http.Error(w, "no session", http.StatusBadRequest)
96 return
97 }
98
99 if index < 0 || index >= sess.total {
100 http.Error(w, "bad index", http.StatusBadRequest)
101 return
102 }
103
104 body, err := io.ReadAll(io.LimitReader(r.Body, deployMaxChunk+1))
105 if err != nil {
106 http.Error(w, "read error", http.StatusBadRequest)
107 return
108 }
109 if len(body) > deployMaxChunk {
110 http.Error(w, "chunk too large", http.StatusRequestEntityTooLarge)
111 return
112 }
113
114 deploySessionsMu.Lock()
115 sess.parts[index] = body
116 got := len(sess.parts)
117 deploySessionsMu.Unlock()
118
119 fmt.Fprintf(w, "ok %d/%d", got, sess.total)
120 }
121
122 func (s *Smesh3Server) deployApply(w http.ResponseWriter, r *http.Request) {
123 hash := r.URL.Query().Get("hash")
124 sigHex := r.Header.Get("X-Sig")
125 if len(hash) != 64 || sigHex == "" {
126 http.Error(w, "bad params", http.StatusBadRequest)
127 return
128 }
129 sig, err := hex.DecodeString(sigHex)
130 if err != nil || len(sig) != 64 {
131 http.Error(w, "invalid signature", http.StatusUnauthorized)
132 return
133 }
134
135 deploySessionsMu.Lock()
136 sess, ok := deploySessions[hash]
137 if ok {
138 delete(deploySessions, hash)
139 }
140 deploySessionsMu.Unlock()
141 if !ok {
142 http.Error(w, "no session", http.StatusBadRequest)
143 return
144 }
145
146 if len(sess.parts) != sess.total {
147 http.Error(w, fmt.Sprintf("incomplete: %d/%d parts", len(sess.parts), sess.total), http.StatusBadRequest)
148 return
149 }
150
151 // Reassemble.
152 var bundle []byte
153 for i := 0; i < sess.total; i++ {
154 p, ok := sess.parts[i]
155 if !ok {
156 http.Error(w, fmt.Sprintf("missing part %d", i), http.StatusBadRequest)
157 return
158 }
159 bundle = append(bundle, p...)
160 }
161
162 if len(bundle) > deployMaxSize {
163 http.Error(w, "bundle too large", http.StatusRequestEntityTooLarge)
164 return
165 }
166
167 // Verify hash matches.
168 computed := sha256.Sum256(bundle)
169 if hex.EncodeToString(computed[:]) != hash {
170 http.Error(w, "hash mismatch", http.StatusBadRequest)
171 return
172 }
173
174 // Verify BIP-340 signature.
175 verifier, err := p8k.New()
176 if err != nil {
177 http.Error(w, "internal error", http.StatusInternalServerError)
178 return
179 }
180 if err := verifier.InitPub(s.deployPub); err != nil {
181 http.Error(w, "internal error", http.StatusInternalServerError)
182 return
183 }
184 ok2, err := verifier.Verify(computed[:], sig)
185 if err != nil || !ok2 {
186 log.W.F("smesh deploy: signature verification failed")
187 http.Error(w, "signature verification failed", http.StatusForbidden)
188 return
189 }
190
191 // Extract and swap via symlink pivot.
192 s.extractAndSwap(w, bundle, computed)
193 }
194
195 func (s *Smesh3Server) extractAndSwap(w http.ResponseWriter, bundle []byte, hash [32]byte) {
196 hashHex := hex.EncodeToString(hash[:8])
197 newDir := s.dir + "-" + hashHex
198
199 os.RemoveAll(newDir)
200 if err := extractTarXz(bundle, newDir); err != nil {
201 os.RemoveAll(newDir)
202 log.E.F("smesh deploy: extract failed: %v", err)
203 http.Error(w, "extract failed: "+err.Error(), http.StatusBadRequest)
204 return
205 }
206
207 // Resolve s.dir: could be a symlink (subsequent deploy) or real dir (first deploy).
208 tmpLink := s.dir + ".new"
209 os.Remove(tmpLink)
210
211 if err := os.Symlink(newDir, tmpLink); err != nil {
212 os.RemoveAll(newDir)
213 log.E.F("smesh deploy: symlink failed: %v", err)
214 http.Error(w, "deploy failed", http.StatusInternalServerError)
215 return
216 }
217
218 // Read old target before swap (if s.dir is already a symlink).
219 oldTarget, _ := os.Readlink(s.dir)
220
221 // Try atomic rename (works when s.dir is a symlink).
222 err := os.Rename(tmpLink, s.dir)
223 if err != nil {
224 // First deploy: s.dir is a real directory. Migrate to symlink.
225 os.Remove(tmpLink)
226 oldDir := s.dir + ".old"
227 os.RemoveAll(oldDir)
228 if err := os.Rename(s.dir, oldDir); err != nil {
229 os.RemoveAll(newDir)
230 log.E.F("smesh deploy: rename liveāold failed: %v", err)
231 http.Error(w, "deploy failed", http.StatusInternalServerError)
232 return
233 }
234 if err := os.Symlink(newDir, s.dir); err != nil {
235 os.Rename(oldDir, s.dir) // rollback
236 os.RemoveAll(newDir)
237 log.E.F("smesh deploy: symlink failed: %v", err)
238 http.Error(w, "deploy failed", http.StatusInternalServerError)
239 return
240 }
241 os.RemoveAll(oldDir)
242 oldTarget = "" // nothing more to clean
243 }
244
245 // Clean up previous version.
246 if oldTarget != "" {
247 os.RemoveAll(oldTarget)
248 }
249
250 s.notifyFullRefresh()
251
252 log.I.F("smesh deploy: applied %s (%d bytes xz)", hashHex, len(bundle))
253 w.Header().Set("Content-Type", "text/plain")
254 fmt.Fprintf(w, "ok version=%d\n", s.version)
255 }
256
257 // extractTarXz decompresses xz data and extracts the tar archive to dest.
258 func extractTarXz(data []byte, dest string) error {
259 cmd := exec.Command("xz", "-d", "--stdout")
260 cmd.Stdin = bytes.NewReader(data)
261
262 tarData, err := cmd.Output()
263 if err != nil {
264 return fmt.Errorf("xz decompress: %w", err)
265 }
266
267 tr := tar.NewReader(bytes.NewReader(tarData))
268 for {
269 hdr, err := tr.Next()
270 if err == io.EOF {
271 break
272 }
273 if err != nil {
274 return fmt.Errorf("tar: %w", err)
275 }
276
277 clean := filepath.Clean(hdr.Name)
278 if filepath.IsAbs(clean) || strings.HasPrefix(clean, "..") {
279 return fmt.Errorf("bad path in tar: %s", hdr.Name)
280 }
281 target := filepath.Join(dest, clean)
282
283 switch hdr.Typeflag {
284 case tar.TypeDir:
285 if err := os.MkdirAll(target, 0755); err != nil {
286 return err
287 }
288 case tar.TypeReg:
289 if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
290 return err
291 }
292 f, err := os.Create(target)
293 if err != nil {
294 return err
295 }
296 if _, err := io.Copy(f, tr); err != nil {
297 f.Close()
298 return err
299 }
300 f.Close()
301 }
302 }
303 return nil
304 }
305