feat(server,client): remote install mode, install survives missing systemd

This commit is contained in:
hh committed 2026-10-02 22:09:50 +00:00
1 parent c2e9284a63
commit e89cf7864a
6 files changed
+83 -18

No files matched your search

+4 -1
View File
@@ -96,7 +96,10 @@ func (cfg *Config) Install() error {
return err
}
}
return startService(home, binary, filepath.Join(cfg.DataDir, "daemon.log"))
if err := startService(home, binary, filepath.Join(cfg.DataDir, "daemon.log")); err != nil {
fmt.Fprintf(os.Stderr, "lodge: the daemon could not be started as a service: %v\nrun `lodge daemon &` to keep this machine in sync\n", err)
}
return nil
}
func (cfg *Config) installHook(binary string) error {
+4 -3
View File
@@ -85,9 +85,10 @@ type ClaimResponse struct {
}
type CodeResponse struct {
Code string `json:"code"`
Command string `json:"command"`
ExpiresAt int64 `json:"expires_at"`
Code string `json:"code"`
Command string `json:"command"`
RemoteCommand string `json:"remote_command"`
ExpiresAt int64 `json:"expires_at"`
}
func BranchSpace(branch string) string {
+21 -3
View File
@@ -1,6 +1,10 @@
#!/bin/sh
set -eu
url="{{URL}}"
code="{{CODE}}"
mode="{{MODE}}"
os=$(uname -s | tr '[:upper:]' '[:lower:]')
arch=$(uname -m)
case "$arch" in
@@ -11,12 +15,26 @@ esac
bin="$HOME/.local/bin"
mkdir -p "$bin"
curl -fsSL "{{URL}}/dl/lodge-$os-$arch" -o "$bin/lodge.new"
curl -fsSL "$url/dl/lodge-$os-$arch" -o "$bin/lodge.new"
chmod +x "$bin/lodge.new"
mv "$bin/lodge.new" "$bin/lodge"
"$bin/lodge" login "{{URL}}" "{{CODE}}"
"$bin/lodge" install
if [ "$mode" = login ]; then
"$bin/lodge" login "$url" "$code"
"$bin/lodge" install
else
plist="$HOME/Library/LaunchAgents/com.kotikot.lodge.plist"
unit="$HOME/.config/systemd/user/lodge.service"
restarted=yes
if [ -f "$plist" ]; then
launchctl kickstart -k "gui/$(id -u)/com.kotikot.lodge" || restarted=no
elif [ -f "$unit" ]; then
systemctl --user restart lodge.service || restarted=no
fi
[ "$restarted" = yes ] || echo "lodge: restart the running daemon so it runs the new binary" >&2
(cd / && "$bin/lodge" remote "$url" "$code")
[ -f "$plist" ] || [ -f "$unit" ] || "$bin/lodge" install
fi
case ":$PATH:" in
*":$bin:"*) ;;
+15 -11
View File
@@ -59,7 +59,8 @@ type machineKey struct{}
func New(store *Store, config Config) http.Handler {
s := &Server{config: config, store: store, subscribers: map[chan protocol.Event]struct{}{}, streams: map[int64]int{}, seen: map[int64]int64{}}
mux := http.NewServeMux()
mux.HandleFunc("GET /i/{code}", s.install)
mux.HandleFunc("GET /i/{code}", s.install("login"))
mux.HandleFunc("GET /i/{code}/remote", s.install("remote"))
mux.Handle("GET /dl/", http.StripPrefix("/dl/", http.FileServer(http.Dir(config.ClientsDir))))
mux.HandleFunc("POST /api/claim", s.claim)
mux.HandleFunc("POST /api/session", s.login)
@@ -159,14 +160,16 @@ func (s *Server) publicURL(r *http.Request) string {
return scheme + "://" + r.Host
}
func (s *Server) install(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("code")
if !codePattern.MatchString(code) || !s.store.CodeValid(r.Context(), code) {
http.Error(w, "echo 'lodge: this install link has expired, create a new one' >&2; exit 1", http.StatusNotFound)
return
func (s *Server) install(mode string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("code")
if !codePattern.MatchString(code) || !s.store.CodeValid(r.Context(), code) {
http.Error(w, "echo 'lodge: this install link has expired, create a new one' >&2; exit 1", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/x-shellscript")
_, _ = strings.NewReplacer("{{URL}}", s.publicURL(r), "{{CODE}}", code, "{{MODE}}", mode).WriteString(w, installScript)
}
w.Header().Set("Content-Type", "text/x-shellscript")
_, _ = strings.NewReplacer("{{URL}}", s.publicURL(r), "{{CODE}}", code).WriteString(w, installScript)
}
func (s *Server) setSession(w http.ResponseWriter, r *http.Request, token string, expires time.Time) {
@@ -264,9 +267,10 @@ func (s *Server) createCode(w http.ResponseWriter, r *http.Request) {
return
}
writeJSON(w, http.StatusCreated, protocol.CodeResponse{
Code: code,
Command: fmt.Sprintf("curl -fsSL %s/i/%s | sh", s.publicURL(r), code),
ExpiresAt: expires.UnixMilli(),
Code: code,
Command: fmt.Sprintf("curl -fsSL %s/i/%s | sh", s.publicURL(r), code),
RemoteCommand: fmt.Sprintf("curl -fsSL %s/i/%s/remote | sh", s.publicURL(r), code),
ExpiresAt: expires.UnixMilli(),
})
}
+35
View File
@@ -9,8 +9,10 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os/exec"
"path/filepath"
"strings"
"testing"
@@ -204,3 +206,36 @@ func TestMachinesShowWhoIsOnline(t *testing.T) {
time.Sleep(20 * time.Millisecond)
}
}
func TestInstallScriptModes(t *testing.T) {
url := newServer(t)
var code protocol.CodeResponse
call(t, http.MethodPost, url+"/api/codes", adminToken, nil, &code)
if code.RemoteCommand != "curl -fsSL "+url+"/i/"+code.Code+"/remote | sh" {
t.Fatalf("remote command = %q", code.RemoteCommand)
}
for path, mode := range map[string]string{"/i/" + code.Code: "login", "/i/" + code.Code + "/remote": "remote"} {
res, err := http.Get(url + path)
if err != nil {
t.Fatal(err)
}
script, _ := io.ReadAll(res.Body)
res.Body.Close()
if res.StatusCode != http.StatusOK || !strings.Contains(string(script), `mode="`+mode+`"`) || !strings.Contains(string(script), `code="`+code.Code+`"`) {
t.Fatalf("%s: %d\n%s", path, res.StatusCode, script)
}
check := exec.Command("sh", "-n")
check.Stdin = bytes.NewReader(script)
if out, err := check.CombinedOutput(); err != nil {
t.Fatalf("%s is not valid sh: %v %s", path, err, out)
}
}
res, err := http.Get(url + "/i/abcdef/remote")
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if res.StatusCode != http.StatusNotFound {
t.Fatalf("unknown code: %d", res.StatusCode)
}
}