diff --git a/internal/agent/client.go b/internal/agent/client.go index 394e37f..6a9a5ad 100644 --- a/internal/agent/client.go +++ b/internal/agent/client.go @@ -330,7 +330,7 @@ func (a *Agent) pollOnce(ctx, reqCtx context.Context) error { func (a *Agent) dispatch(ctx context.Context, msg proto.ControlMessage) { switch msg.Type { case proto.CtrlOpenTerminal: - go a.runTerminal(ctx, msg.SessionID, msg.User) + go a.runTerminal(ctx, msg.SessionID, msg.User, msg.Dir) case proto.CtrlOpenForward: go a.runForward(ctx, msg.SessionID, msg.Port) case proto.CtrlTask: diff --git a/internal/agent/session.go b/internal/agent/session.go index ab2e194..a48c8ae 100644 --- a/internal/agent/session.go +++ b/internal/agent/session.go @@ -16,8 +16,8 @@ import ( // runTerminal spawns a PTY and bridges it to the session. // user is the OS user to run the shell as ("" = the agent's own user). -func (a *Agent) runTerminal(parent context.Context, sid, user string) { - term, err := StartPTY(user) +func (a *Agent) runTerminal(parent context.Context, sid, user, dir string) { + term, err := StartPTY(user, dir) if err != nil { log.Printf("term %s: start pty: %v", sid, err) // Show the reason in the browser terminal before closing it. diff --git a/internal/agent/terminal.go b/internal/agent/terminal.go index 97dc39e..05c025c 100644 --- a/internal/agent/terminal.go +++ b/internal/agent/terminal.go @@ -4,6 +4,8 @@ import ( "fmt" "os" "os/exec" + "path/filepath" + "strings" "sync" "syscall" "time" @@ -41,8 +43,12 @@ func CloseTerminals() { // StartPTY launches a login shell attached to a new PTY. With user empty (or // equal to the agent's own user) it is the agent's shell - root by default, // since the agent runs as a root systemd service. Any other user is entered via -// `su -l`, which only a root agent can do without a password. -func StartPTY(user string) (*PTY, error) { +// `su -l`, which only a root agent can do without a password. dir, when it +// is a folder, is where the shell starts instead of home. +func StartPTY(user, dir string) (*PTY, error) { + if dir != "" && (!filepath.IsAbs(dir) || strings.ContainsAny(dir, "\x00\r\n") || !isDir(dir)) { + dir = "" + } var cmd *exec.Cmd env := append(os.Environ(), "TERM=xterm-256color") if user == "" || user == currentUser() { @@ -54,6 +60,9 @@ func StartPTY(user string) (*PTY, error) { cmd.Dir = home env = append(env, "HOME="+home) } + if dir != "" { + cmd.Dir = dir + } } else { if !validUserName(user) { return nil, fmt.Errorf("недопустимое имя пользователя %q", user) @@ -69,6 +78,11 @@ func StartPTY(user string) (*PTY, error) { return nil, fmt.Errorf("su не найден: %w", err) } cmd = exec.Command(su, "-l", user) + // su -l always goes home; to start elsewhere, its shell changes + // directory first and becomes the login shell (sh and csh alike). + if dir != "" { + cmd = exec.Command(su, "-l", user, "-c", "cd "+shQuote(dir)+"; exec \"$SHELL\" -l") + } // su -l goes to the user's home; starting there too keeps the shell // out of "/" should it not. if home := homeDir(user); isDir(home) { @@ -126,3 +140,9 @@ func (p *PTY) Close() error { }) return err } + +// shQuote puts s in single quotes for sh and csh alike, closing and +// reopening them around each quote inside. +func shQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} diff --git a/internal/agent/terminal_test.go b/internal/agent/terminal_test.go new file mode 100644 index 0000000..d532536 --- /dev/null +++ b/internal/agent/terminal_test.go @@ -0,0 +1,62 @@ +package agent + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// A terminal opened with a folder starts its shell there; a folder that is +// not there leaves it at home. +func TestStartPTYDir(t *testing.T) { + dir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + sub := filepath.Join(dir, "sub dir") + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + if got := ptyPwd(t, sub); got != sub { + t.Fatalf("pwd = %q, want %q", got, sub) + } + home, _ := filepath.EvalSymlinks(homeDir("")) + if got := ptyPwd(t, filepath.Join(dir, "missing")); got != home { + t.Fatalf("pwd for a missing folder = %q, want home %q", got, home) + } +} + +// ptyPwd opens a terminal at dir and returns where its shell is. +func ptyPwd(t *testing.T, dir string) string { + t.Helper() + t.Setenv("SHELL", "/bin/sh") + p, err := StartPTY("", dir) + if err != nil { + t.Fatal(err) + } + defer p.Close() + if _, err := p.Write([]byte("echo \"@@$(pwd -P)@@\"\n")); err != nil { + t.Fatal(err) + } + var out strings.Builder + buf := make([]byte, 4096) + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + n, err := p.Read(buf) + out.Write(buf[:n]) + s := out.String() + // The echoed command line has "@@$(pwd" in it; the answer does not. + for _, part := range strings.Split(s, "@@") { + if strings.HasPrefix(part, "/") && strings.Contains(s, part+"@@") { + return part + } + } + if err != nil { + break + } + } + t.Fatalf("no pwd in %q", out.String()) + return "" +} diff --git a/internal/proto/proto.go b/internal/proto/proto.go index 4a5e208..7a11e35 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -63,6 +63,7 @@ type ControlMessage struct { Port int `json:"port,omitempty"` // CtrlOpenForward: client-side port to dial TaskID string `json:"task_id,omitempty"` // CtrlTask User string `json:"user,omitempty"` // CtrlOpenTerminal: OS user for the shell ("" = the agent's own) + Dir string `json:"dir,omitempty"` // CtrlOpenTerminal: the folder to start in ("" or missing = home) SSH *SSHTarget `json:"ssh,omitempty"` // CtrlOpenSSH } diff --git a/internal/server/handlers_panel_term.go b/internal/server/handlers_panel_term.go index 2a27c0a..958bb25 100644 --- a/internal/server/handlers_panel_term.go +++ b/internal/server/handlers_panel_term.go @@ -23,7 +23,8 @@ import ( // POST /api/panel/term/{agentID}/open - create a terminal session: a shell // on the agent, or with {"ssh": {...}} in the body a shell the agent opens -// by SSH on another host. +// by SSH on another host. {"dir": "/path"} starts the shell in that folder +// (an older agent, or a folder not there: at home). func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) { agentID := r.PathValue("agentID") agent, err := s.db.GetAgent(agentID) @@ -34,6 +35,7 @@ func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) { } var body struct { SSH *proto.SSHTarget `json:"ssh"` + Dir string `json:"dir"` } if r.ContentLength != 0 { if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&body); err != nil && err != io.EOF { @@ -63,7 +65,11 @@ func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]string{"session_id": sess.ID}) return } - sess := s.hub.CreateSession(agentID, agent.LoginUser) + dir := body.Dir + if !strings.HasPrefix(dir, "/") || strings.ContainsAny(dir, "\x00\r\n") || len(dir) > 4096 { + dir = "" + } + sess := s.hub.CreateSession(agentID, agent.LoginUser, dir) if admin := s.currentAdmin(r); admin != nil { s.db.Audit(admin.ID, agentID, "open_terminal:"+agent.TerminalUser()) } diff --git a/internal/server/hub.go b/internal/server/hub.go index a757cda..7d98aa1 100644 --- a/internal/server/hub.go +++ b/internal/server/hub.go @@ -94,9 +94,9 @@ func (h *Hub) newSession(agentID string) *Session { // CreateSession registers a terminal session and queues it for the agent. user // is the OS user to run the shell as ("" = the agent's own user). -func (h *Hub) CreateSession(agentID, user string) *Session { +func (h *Hub) CreateSession(agentID, user, dir string) *Session { s := h.newSession(agentID) - h.enqueueJob(agentID, proto.ControlMessage{Type: proto.CtrlOpenTerminal, SessionID: s.ID, User: user}) + h.enqueueJob(agentID, proto.ControlMessage{Type: proto.CtrlOpenTerminal, SessionID: s.ID, User: user, Dir: dir}) return s } diff --git a/internal/server/termdir_test.go b/internal/server/termdir_test.go new file mode 100644 index 0000000..23afc66 --- /dev/null +++ b/internal/server/termdir_test.go @@ -0,0 +1,34 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// A terminal opened with a folder asks the agent to start there; a folder +// that is not an absolute path is left out (the shell starts at home). +func TestPanelOpenDir(t *testing.T) { + s, cookie := newTestServer(t) + for body, want := range map[string]string{ + `{"dir":"/srv/my app"}`: "/srv/my app", + `{"dir":"relative"}`: "", + ``: "", + } { + req := httptest.NewRequest(http.MethodPost, "/api/panel/term/x1/open", strings.NewReader(body)) + req.AddCookie(cookie) + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("%s: code %d %s", body, w.Code, w.Body) + } + s.hub.mu.Lock() + jobs := s.hub.jobs["x1"] + s.hub.jobs["x1"] = nil + s.hub.mu.Unlock() + if len(jobs) != 1 || jobs[0].Dir != want { + t.Fatalf("%s: jobs %+v, want dir %q", body, jobs, want) + } + } +} diff --git a/internal/server/web/static/termpane.js b/internal/server/web/static/termpane.js index 48623e4..c7b8e64 100644 --- a/internal/server/web/static/termpane.js +++ b/internal/server/web/static/termpane.js @@ -262,9 +262,11 @@ window.TermKeys = (function () { class Session { // ssh, when given, is where the agent connects by SSH for this shell: // {host, port, user} and the secret to sign in with, sent once on open. - constructor(agent, ssh) { + // dir, for a shell on the machine, is the folder it starts in. + constructor(agent, ssh, dir) { this.agent = agent; this.ssh = ssh || null; + this.dir = dir || ''; this.kind = ssh ? 'ssh' : 'term'; // Where its files are, for dropped paths: the machine, or the SSH host // through it. @@ -310,6 +312,7 @@ window.TermKeys = (function () { async open() { try { let opt = { method: 'POST' }; + if (this.dir) opt = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dir: this.dir }) }; if (this.ssh) { opt = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ssh: this.ssh }) }; // The password or key goes once, to the agent; the page keeps no copy. @@ -461,7 +464,7 @@ window.TermKeys = (function () { } - W.paneTypes.term = agent => new Session(agent); + W.paneTypes.term = (agent, opts) => new Session(agent, null, opts.dir); W.paneTypes.ssh = (agent, opts) => new Session(agent, opts.ssh); // For the touch helpers: the terminal being typed in. window.TermPane = {active: () => { const p = W.activePane(); return p && p.term ? p : null; }}; diff --git a/internal/server/web/static/workspace.js b/internal/server/web/static/workspace.js index 24a49eb..a87d834 100644 --- a/internal/server/web/static/workspace.js +++ b/internal/server/web/static/workspace.js @@ -565,7 +565,12 @@ list.appendChild(inner); g.after(list); if (here) item(content, '+Здесь' + esc(here.name) + ' · ' + esc(here.user + '@' + here.host) + '', - () => { closeMenu(); openPane(kind, here); }); + () => { + closeMenu(); + // A terminal from a machine's file pane starts in its folder. + const dir = kind === 'term' && activePane.kind === 'files' ? activePane.cwd : ''; + openPane(kind, here, dir ? {dir} : null); + }); const box = document.createElement('div'); box.innerHTML = '
загрузка…
'; content.appendChild(box);