feat(server,client): remote install mode, install survives missing systemd
This commit is contained in:
1 parent
c2e9284a63
commit
e89cf7864a
6 files changed
+71
-6
No files matched your search
@@ -47,6 +47,10 @@ Running the returned `curl ... | sh` on a Mac or a Linux machine downloads the c
|
|||||||
|
|
||||||
A repository can live on another lodge server than the rest of the machine, for example a team's server during an event, while everything else stays on the personal one. Inside the repository, `lodge remote <url> <code>` claims a code from that server and binds the repository to it; further repositories on the same server need only `lodge remote <url>`. Outside a repository the same command only stores the server's token. The binding belongs to the repository key, so worktrees and later clones follow it, and the daemon listens to every server it holds a token for. `lodge remote` prints the server of the current repository, `lodge remote --default` sends it back to the default one. `lodge login` and `lodge update` only touch the default server, and a machine with no default server at all syncs just the repositories bound with `lodge remote`.
|
A repository can live on another lodge server than the rest of the machine, for example a team's server during an event, while everything else stays on the personal one. Inside the repository, `lodge remote <url> <code>` claims a code from that server and binds the repository to it; further repositories on the same server need only `lodge remote <url>`. Outside a repository the same command only stores the server's token. The binding belongs to the repository key, so worktrees and later clones follow it, and the daemon listens to every server it holds a token for. `lodge remote` prints the server of the current repository, `lodge remote --default` sends it back to the default one. `lodge login` and `lodge update` only touch the default server, and a machine with no default server at all syncs just the repositories bound with `lodge remote`.
|
||||||
|
|
||||||
|
On a machine that is logged in elsewhere, the code's `remote_command`, `curl -fsSL https://lodge.example.com/i/<code>/remote | sh`, installs or updates the client, connects the machine to that server without touching the default one, restarts a running daemon so it runs the new binary, and installs the daemon only if it is not there yet. Repositories are then bound with `lodge remote <url>`. Keep the servers a machine talks to on the same version: an older client ignores bindings and syncs every repository with the default server, and `lodge update` brings the default server's client.
|
||||||
|
|
||||||
|
Where no service manager runs, in a container for example, `lodge install` says so and leaves it to `lodge daemon &`.
|
||||||
|
|
||||||
## The admin
|
## The admin
|
||||||
|
|
||||||
The server serves the admin from `frontend/build`. History is drawn as a borehole log: every revision is a bed, newest on top, hatched by the machine that wrote it and labelled with the branch and commit of the main repository. Dragging along the column, or the arrow keys, shows the notes as they were at that revision, and every depth, file and view is a link. A file can be compared with its previous version and put back on the surface; a repository can be deleted with its history, which leaves the files on the machines alone. Machines are listed with their hatch and can be revoked.
|
The server serves the admin from `frontend/build`. History is drawn as a borehole log: every revision is a bed, newest on top, hatched by the machine that wrote it and labelled with the branch and commit of the main repository. Dragging along the column, or the arrow keys, shows the notes as they were at that revision, and every depth, file and view is a link. A file can be compared with its previous version and put back on the surface; a repository can be deleted with its history, which leaves the files on the machines alone. Machines are listed with their hatch and can be revoked.
|
||||||
|
|||||||
@@ -96,7 +96,10 @@ func (cfg *Config) Install() error {
|
|||||||
return err
|
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 {
|
func (cfg *Config) installHook(binary string) error {
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ type ClaimResponse struct {
|
|||||||
type CodeResponse struct {
|
type CodeResponse struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
|
RemoteCommand string `json:"remote_command"`
|
||||||
ExpiresAt int64 `json:"expires_at"`
|
ExpiresAt int64 `json:"expires_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
|
url="{{URL}}"
|
||||||
|
code="{{CODE}}"
|
||||||
|
mode="{{MODE}}"
|
||||||
|
|
||||||
os=$(uname -s | tr '[:upper:]' '[:lower:]')
|
os=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||||
arch=$(uname -m)
|
arch=$(uname -m)
|
||||||
case "$arch" in
|
case "$arch" in
|
||||||
@@ -11,12 +15,26 @@ esac
|
|||||||
|
|
||||||
bin="$HOME/.local/bin"
|
bin="$HOME/.local/bin"
|
||||||
mkdir -p "$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"
|
chmod +x "$bin/lodge.new"
|
||||||
mv "$bin/lodge.new" "$bin/lodge"
|
mv "$bin/lodge.new" "$bin/lodge"
|
||||||
|
|
||||||
"$bin/lodge" login "{{URL}}" "{{CODE}}"
|
if [ "$mode" = login ]; then
|
||||||
|
"$bin/lodge" login "$url" "$code"
|
||||||
"$bin/lodge" install
|
"$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
|
case ":$PATH:" in
|
||||||
*":$bin:"*) ;;
|
*":$bin:"*) ;;
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ type machineKey struct{}
|
|||||||
func New(store *Store, config Config) http.Handler {
|
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{}}
|
s := &Server{config: config, store: store, subscribers: map[chan protocol.Event]struct{}{}, streams: map[int64]int{}, seen: map[int64]int64{}}
|
||||||
mux := http.NewServeMux()
|
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.Handle("GET /dl/", http.StripPrefix("/dl/", http.FileServer(http.Dir(config.ClientsDir))))
|
||||||
mux.HandleFunc("POST /api/claim", s.claim)
|
mux.HandleFunc("POST /api/claim", s.claim)
|
||||||
mux.HandleFunc("POST /api/session", s.login)
|
mux.HandleFunc("POST /api/session", s.login)
|
||||||
@@ -159,14 +160,16 @@ func (s *Server) publicURL(r *http.Request) string {
|
|||||||
return scheme + "://" + r.Host
|
return scheme + "://" + r.Host
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) install(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) install(mode string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
code := r.PathValue("code")
|
code := r.PathValue("code")
|
||||||
if !codePattern.MatchString(code) || !s.store.CodeValid(r.Context(), 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)
|
http.Error(w, "echo 'lodge: this install link has expired, create a new one' >&2; exit 1", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/x-shellscript")
|
w.Header().Set("Content-Type", "text/x-shellscript")
|
||||||
_, _ = strings.NewReplacer("{{URL}}", s.publicURL(r), "{{CODE}}", code).WriteString(w, installScript)
|
_, _ = strings.NewReplacer("{{URL}}", s.publicURL(r), "{{CODE}}", code, "{{MODE}}", mode).WriteString(w, installScript)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) setSession(w http.ResponseWriter, r *http.Request, token string, expires time.Time) {
|
func (s *Server) setSession(w http.ResponseWriter, r *http.Request, token string, expires time.Time) {
|
||||||
@@ -266,6 +269,7 @@ func (s *Server) createCode(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusCreated, protocol.CodeResponse{
|
writeJSON(w, http.StatusCreated, protocol.CodeResponse{
|
||||||
Code: code,
|
Code: code,
|
||||||
Command: fmt.Sprintf("curl -fsSL %s/i/%s | sh", s.publicURL(r), 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(),
|
ExpiresAt: expires.UnixMilli(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -204,3 +206,36 @@ func TestMachinesShowWhoIsOnline(t *testing.T) {
|
|||||||
time.Sleep(20 * time.Millisecond)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user