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