feat: «Здесь» from a machine's file pane opens the terminal in that pane's folder (the agent starts the shell there; an older agent, or a folder gone, at home)
This commit is contained in:
1 parent
4fb26a9652
commit
57c2f1338f
10 files changed
+143
-12
No files matched your search
@@ -330,7 +330,7 @@ func (a *Agent) pollOnce(ctx, reqCtx context.Context) error {
|
|||||||
func (a *Agent) dispatch(ctx context.Context, msg proto.ControlMessage) {
|
func (a *Agent) dispatch(ctx context.Context, msg proto.ControlMessage) {
|
||||||
switch msg.Type {
|
switch msg.Type {
|
||||||
case proto.CtrlOpenTerminal:
|
case proto.CtrlOpenTerminal:
|
||||||
go a.runTerminal(ctx, msg.SessionID, msg.User)
|
go a.runTerminal(ctx, msg.SessionID, msg.User, msg.Dir)
|
||||||
case proto.CtrlOpenForward:
|
case proto.CtrlOpenForward:
|
||||||
go a.runForward(ctx, msg.SessionID, msg.Port)
|
go a.runForward(ctx, msg.SessionID, msg.Port)
|
||||||
case proto.CtrlTask:
|
case proto.CtrlTask:
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import (
|
|||||||
|
|
||||||
// runTerminal spawns a PTY and bridges it to the session.
|
// 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).
|
// 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) {
|
func (a *Agent) runTerminal(parent context.Context, sid, user, dir string) {
|
||||||
term, err := StartPTY(user)
|
term, err := StartPTY(user, dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("term %s: start pty: %v", sid, err)
|
log.Printf("term %s: start pty: %v", sid, err)
|
||||||
// Show the reason in the browser terminal before closing it.
|
// Show the reason in the browser terminal before closing it.
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
@@ -41,8 +43,12 @@ func CloseTerminals() {
|
|||||||
// StartPTY launches a login shell attached to a new PTY. With user empty (or
|
// 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,
|
// 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
|
// 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.
|
// `su -l`, which only a root agent can do without a password. dir, when it
|
||||||
func StartPTY(user string) (*PTY, error) {
|
// 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
|
var cmd *exec.Cmd
|
||||||
env := append(os.Environ(), "TERM=xterm-256color")
|
env := append(os.Environ(), "TERM=xterm-256color")
|
||||||
if user == "" || user == currentUser() {
|
if user == "" || user == currentUser() {
|
||||||
@@ -54,6 +60,9 @@ func StartPTY(user string) (*PTY, error) {
|
|||||||
cmd.Dir = home
|
cmd.Dir = home
|
||||||
env = append(env, "HOME="+home)
|
env = append(env, "HOME="+home)
|
||||||
}
|
}
|
||||||
|
if dir != "" {
|
||||||
|
cmd.Dir = dir
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if !validUserName(user) {
|
if !validUserName(user) {
|
||||||
return nil, fmt.Errorf("недопустимое имя пользователя %q", user)
|
return nil, fmt.Errorf("недопустимое имя пользователя %q", user)
|
||||||
@@ -69,6 +78,11 @@ func StartPTY(user string) (*PTY, error) {
|
|||||||
return nil, fmt.Errorf("su не найден: %w", err)
|
return nil, fmt.Errorf("su не найден: %w", err)
|
||||||
}
|
}
|
||||||
cmd = exec.Command(su, "-l", user)
|
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
|
// su -l goes to the user's home; starting there too keeps the shell
|
||||||
// out of "/" should it not.
|
// out of "/" should it not.
|
||||||
if home := homeDir(user); isDir(home) {
|
if home := homeDir(user); isDir(home) {
|
||||||
@@ -126,3 +140,9 @@ func (p *PTY) Close() error {
|
|||||||
})
|
})
|
||||||
return err
|
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, "'", `'\''`) + "'"
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ type ControlMessage struct {
|
|||||||
Port int `json:"port,omitempty"` // CtrlOpenForward: client-side port to dial
|
Port int `json:"port,omitempty"` // CtrlOpenForward: client-side port to dial
|
||||||
TaskID string `json:"task_id,omitempty"` // CtrlTask
|
TaskID string `json:"task_id,omitempty"` // CtrlTask
|
||||||
User string `json:"user,omitempty"` // CtrlOpenTerminal: OS user for the shell ("" = the agent's own)
|
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
|
SSH *SSHTarget `json:"ssh,omitempty"` // CtrlOpenSSH
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ import (
|
|||||||
|
|
||||||
// POST /api/panel/term/{agentID}/open - create a terminal session: a shell
|
// 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
|
// 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) {
|
func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) {
|
||||||
agentID := r.PathValue("agentID")
|
agentID := r.PathValue("agentID")
|
||||||
agent, err := s.db.GetAgent(agentID)
|
agent, err := s.db.GetAgent(agentID)
|
||||||
@@ -34,6 +35,7 @@ func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
var body struct {
|
var body struct {
|
||||||
SSH *proto.SSHTarget `json:"ssh"`
|
SSH *proto.SSHTarget `json:"ssh"`
|
||||||
|
Dir string `json:"dir"`
|
||||||
}
|
}
|
||||||
if r.ContentLength != 0 {
|
if r.ContentLength != 0 {
|
||||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&body); err != nil && err != io.EOF {
|
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})
|
writeJSON(w, map[string]string{"session_id": sess.ID})
|
||||||
return
|
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 {
|
if admin := s.currentAdmin(r); admin != nil {
|
||||||
s.db.Audit(admin.ID, agentID, "open_terminal:"+agent.TerminalUser())
|
s.db.Audit(admin.ID, agentID, "open_terminal:"+agent.TerminalUser())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,9 +94,9 @@ func (h *Hub) newSession(agentID string) *Session {
|
|||||||
|
|
||||||
// CreateSession registers a terminal session and queues it for the agent. user
|
// 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).
|
// 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)
|
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
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -262,9 +262,11 @@ window.TermKeys = (function () {
|
|||||||
class Session {
|
class Session {
|
||||||
// ssh, when given, is where the agent connects by SSH for this shell:
|
// 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.
|
// {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.agent = agent;
|
||||||
this.ssh = ssh || null;
|
this.ssh = ssh || null;
|
||||||
|
this.dir = dir || '';
|
||||||
this.kind = ssh ? 'ssh' : 'term';
|
this.kind = ssh ? 'ssh' : 'term';
|
||||||
// Where its files are, for dropped paths: the machine, or the SSH host
|
// Where its files are, for dropped paths: the machine, or the SSH host
|
||||||
// through it.
|
// through it.
|
||||||
@@ -310,6 +312,7 @@ window.TermKeys = (function () {
|
|||||||
async open() {
|
async open() {
|
||||||
try {
|
try {
|
||||||
let opt = { method: 'POST' };
|
let opt = { method: 'POST' };
|
||||||
|
if (this.dir) opt = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dir: this.dir }) };
|
||||||
if (this.ssh) {
|
if (this.ssh) {
|
||||||
opt = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ssh: 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.
|
// 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);
|
W.paneTypes.ssh = (agent, opts) => new Session(agent, opts.ssh);
|
||||||
// For the touch helpers: the terminal being typed in.
|
// For the touch helpers: the terminal being typed in.
|
||||||
window.TermPane = {active: () => { const p = W.activePane(); return p && p.term ? p : null; }};
|
window.TermPane = {active: () => { const p = W.activePane(); return p && p.term ? p : null; }};
|
||||||
|
|||||||
@@ -565,7 +565,12 @@
|
|||||||
list.appendChild(inner);
|
list.appendChild(inner);
|
||||||
g.after(list);
|
g.after(list);
|
||||||
if (here) item(content, '<span class="ico">+</span><span><b>Здесь</b><small>' + esc(here.name) + ' · ' + esc(here.user + '@' + here.host) + '</small></span>',
|
if (here) item(content, '<span class="ico">+</span><span><b>Здесь</b><small>' + esc(here.name) + ' · ' + esc(here.user + '@' + here.host) + '</small></span>',
|
||||||
() => { 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');
|
const box = document.createElement('div');
|
||||||
box.innerHTML = '<div class="muted" style="padding:8px 10px;font-size:13px">загрузка…</div>';
|
box.innerHTML = '<div class="muted" style="padding:8px 10px;font-size:13px">загрузка…</div>';
|
||||||
content.appendChild(box);
|
content.appendChild(box);
|
||||||
|
|||||||
Reference in new issue
Block a user