diff --git a/README.md b/README.md index 26903b9..7c4c609 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ It lets you browse, search, connect to, edit, and organize the hosts defined in ### Fast Navigation - Fuzzy search by alias, IP, user, or tag - Press `Enter` to connect to the selected host immediately +- See currently running local SSH sessions in a read-only panel - Organize hosts with tags such as `prod`, `dev`, and `test` - Sort by alias or last connection time, with reversible order diff --git a/README.zh-CN.md b/README.zh-CN.md index 9599c24..21ddcc2 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -38,6 +38,7 @@ lazyssh 是一个运行在终端中的交互式 SSH 管理器,灵感来自 `la ### 快速导航 - 支持按别名、IP、用户或标签进行模糊搜索 - 选中主机后按回车即可直接 SSH 登录 +- 只读展示当前本机正在运行的 SSH 会话 - 支持用标签组织主机,例如 `prod`、`dev`、`test` - 支持按别名或最近连接时间排序,并可反转排序方向 diff --git a/cmd/main.go b/cmd/main.go index 8c6fff8..913495f 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -77,7 +77,7 @@ func main() { } metaDataFile := filepath.Join(home, ".lazyssh", "metadata.json") - var serverRepo = ssh_config_file.NewRepositoryWithWritePath(log, configPath, managedConfigPath, metaDataFile) + serverRepo := ssh_config_file.NewRepositoryWithWritePath(log, configPath, managedConfigPath, metaDataFile) if sshConfigReadOnly { serverRepo = ssh_config_file.NewReadOnlyRepository(log, configPath, metaDataFile) } diff --git a/internal/adapters/ui/active_sessions.go b/internal/adapters/ui/active_sessions.go new file mode 100644 index 0000000..4711130 --- /dev/null +++ b/internal/adapters/ui/active_sessions.go @@ -0,0 +1,83 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ui + +import ( + "fmt" + "strings" + + "github.com/rivo/tview" + "github.com/urzeye/lazyssh/internal/core/domain" +) + +const maxActiveSessionsShown = 4 + +type ActiveSessions struct { + *tview.TextView +} + +func NewActiveSessions() *ActiveSessions { + sessions := &ActiveSessions{ + TextView: tview.NewTextView(), + } + sessions.build() + return sessions +} + +func (as *ActiveSessions) build() { + as.TextView.SetDynamicColors(true). + SetWrap(false). + SetScrollable(false). + SetBorder(true). + SetTitle(" Active Sessions "). + SetTitleAlign(tview.AlignCenter). + SetBorderColor(CurrentTheme.Border). + SetTitleColor(CurrentTheme.Title) + as.SetSessions(nil) +} + +func (as *ActiveSessions) SetSessions(sessions []domain.ActiveSession) { + as.SetTitle(fmt.Sprintf(" Active Sessions: %d ", len(sessions))) + if len(sessions) == 0 { + as.SetText(colorText(CurrentTheme.DimText, "No active SSH sessions")) + return + } + + lines := make([]string, 0, min(len(sessions), maxActiveSessionsShown)+1) + for index, session := range sessions { + if index >= maxActiveSessionsShown { + lines = append(lines, colorText(CurrentTheme.DimText, fmt.Sprintf("+%d more", len(sessions)-index))) + break + } + lines = append(lines, formatActiveSessionLine(session)) + } + as.SetText(strings.Join(lines, "\n")) +} + +func formatActiveSessionLine(session domain.ActiveSession) string { + alias := session.Alias + if alias == "" { + alias = session.Destination + } + + parts := []string{ + colorText(CurrentTheme.MetaText, fmt.Sprintf("%d", session.PID)), + colorText(CurrentTheme.AliasText, tview.Escape(alias)), + } + if len(session.Forwards) > 0 { + parts = append(parts, colorText(CurrentTheme.HostText, strings.Join(session.Forwards, ", "))) + } + return strings.Join(parts, " ") +} diff --git a/internal/adapters/ui/active_sessions_test.go b/internal/adapters/ui/active_sessions_test.go new file mode 100644 index 0000000..409c40a --- /dev/null +++ b/internal/adapters/ui/active_sessions_test.go @@ -0,0 +1,53 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ui + +import ( + "strings" + "testing" + + "github.com/urzeye/lazyssh/internal/core/domain" +) + +func TestActiveSessionsSetSessionsShowsCountAndLimit(t *testing.T) { + view := NewActiveSessions() + view.SetSessions([]domain.ActiveSession{ + {PID: 1, Alias: "alpha"}, + {PID: 2, Alias: "beta"}, + {PID: 3, Alias: "gamma"}, + {PID: 4, Alias: "delta"}, + {PID: 5, Alias: "epsilon"}, + }) + + if got := view.GetTitle(); got != " Active Sessions: 5 " { + t.Fatalf("title = %q, want count title", got) + } + text := view.GetText(false) + if !strings.Contains(text, "alpha") || !strings.Contains(text, "+1 more") { + t.Fatalf("text = %q, want first session and overflow indicator", text) + } + if strings.Contains(text, "epsilon") { + t.Fatalf("text = %q, should not include hidden overflow session", text) + } +} + +func TestActiveSessionsSetSessionsShowsEmptyState(t *testing.T) { + view := NewActiveSessions() + view.SetSessions(nil) + + if text := view.GetText(false); !strings.Contains(text, "No active SSH sessions") { + t.Fatalf("text = %q, want empty state", text) + } +} diff --git a/internal/adapters/ui/handlers.go b/internal/adapters/ui/handlers.go index fe4a0d2..f153536 100644 --- a/internal/adapters/ui/handlers.go +++ b/internal/adapters/ui/handlers.go @@ -408,11 +408,15 @@ func (t *tui) handleRefreshBackground() { }) return } + sessions, sessionsErr := t.serverService.ListActiveSessions() if strings.TrimSpace(q) == "" { sortServersForUI(servers, t.sortMode) } t.app.QueueUpdateDraw(func() { t.serverList.UpdateServers(servers) + if sessionsErr == nil && t.sessions != nil { + t.sessions.SetSessions(sessions) + } // Try to restore selection if still valid if prevIdx >= 0 && prevIdx < t.serverList.List.GetItemCount() { t.serverList.SetCurrentItem(prevIdx) @@ -420,7 +424,11 @@ func (t *tui) handleRefreshBackground() { t.details.UpdateServer(srv) } } - t.showStatusTemp(fmt.Sprintf("Refreshed %d servers", len(servers))) + if sessionsErr != nil { + t.showStatusTempColor(fmt.Sprintf("Refreshed %d servers; active sessions failed", len(servers)), CurrentTheme.Warning) + return + } + t.showStatusTemp(fmt.Sprintf("Refreshed %d servers, %d active sessions", len(servers), len(sessions))) }) }(currentIdx, query) } @@ -648,6 +656,9 @@ func (t *tui) showSearchBar() { t.left.Clear() t.left.AddItem(t.searchBar, 3, 0, true) t.left.AddItem(t.serverList, 0, 1, false) + if t.sessions != nil { + t.left.AddItem(t.sessions, 6, 0, false) + } t.searchVisible = true if t.app != nil { t.app.SetFocus(t.searchBar) @@ -666,6 +677,9 @@ func (t *tui) hideSearchBar() { t.left.Clear() t.left.AddItem(t.hintBar, 1, 0, false) t.left.AddItem(t.serverList, 0, 1, true) + if t.sessions != nil { + t.left.AddItem(t.sessions, 6, 0, false) + } t.searchVisible = false if t.app != nil { t.app.SetFocus(t.serverList) @@ -686,6 +700,7 @@ func (t *tui) refreshServerList() { sortServersForUI(filtered, t.sortMode) } t.serverList.UpdateServers(filtered) + t.loadActiveSessions() } func (t *tui) returnToMain() { diff --git a/internal/adapters/ui/tui.go b/internal/adapters/ui/tui.go index 1d9699f..a1f0891 100644 --- a/internal/adapters/ui/tui.go +++ b/internal/adapters/ui/tui.go @@ -41,6 +41,7 @@ type tui struct { hintBar *tview.TextView searchBar *SearchBar serverList *ServerList + sessions *ActiveSessions details *ServerDetails statusBar *tview.TextView @@ -123,6 +124,7 @@ func (t *tui) buildComponents() *tui { t.serverList = NewServerList(). OnSelectionChange(t.handleServerSelectionChange). OnReturnToSearch(t.handleReturnToSearch) + t.sessions = NewActiveSessions() t.details = NewServerDetails() t.statusBar = NewStatusBar() @@ -153,7 +155,8 @@ func (t *tui) loadPreferences() *tui { func (t *tui) buildLayout() *tui { t.left = tview.NewFlex().SetDirection(tview.FlexRow). AddItem(t.hintBar, 1, 0, false). - AddItem(t.serverList, 0, 1, true) + AddItem(t.serverList, 0, 1, true). + AddItem(t.sessions, 6, 0, false) right := tview.NewFlex().SetDirection(tview.FlexRow). AddItem(t.details, 0, 1, false) @@ -175,6 +178,7 @@ func (t *tui) bindEvents() { func (t *tui) loadInitialData() { t.loadServerList("") + t.loadActiveSessions() } func (t *tui) loadServerList(query string) { @@ -186,6 +190,18 @@ func (t *tui) loadServerList(query string) { t.serverList.UpdateServers(servers) } +func (t *tui) loadActiveSessions() { + if t.sessions == nil { + return + } + sessions, err := t.serverService.ListActiveSessions() + if err != nil { + t.sessions.SetText(colorText(CurrentTheme.Error, "Failed to load active sessions")) + return + } + t.sessions.SetSessions(sessions) +} + func (t *tui) updateListTitle() { if t.serverList != nil { t.serverList.SetTitle(" 1 Servers — Sort: " + t.sortMode.String() + " ") @@ -258,6 +274,7 @@ func (t *tui) restoreMainUIState(state mainUIState) { } t.loadServerList(state.query) + t.loadActiveSessions() t.restoreSelectedServer(state) t.restoreFocus(state) } diff --git a/internal/core/domain/server.go b/internal/core/domain/server.go index e7b8771..478bd6b 100644 --- a/internal/core/domain/server.go +++ b/internal/core/domain/server.go @@ -117,3 +117,11 @@ type Server struct { // Debugging settings LogLevel string } + +type ActiveSession struct { + PID int + Alias string + Destination string + Command string + Forwards []string +} diff --git a/internal/core/ports/services.go b/internal/core/ports/services.go index 565ea47..4337886 100644 --- a/internal/core/ports/services.go +++ b/internal/core/ports/services.go @@ -22,6 +22,7 @@ import ( type ServerService interface { ListServers(query string) ([]domain.Server, error) + ListActiveSessions() ([]domain.ActiveSession, error) UpdateServer(server domain.Server, newServer domain.Server) error AddServer(server domain.Server) error DeleteServer(server domain.Server) error diff --git a/internal/core/services/active_sessions.go b/internal/core/services/active_sessions.go new file mode 100644 index 0000000..02b5857 --- /dev/null +++ b/internal/core/services/active_sessions.go @@ -0,0 +1,220 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package services + +import ( + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/urzeye/lazyssh/internal/core/domain" +) + +type processInfo struct { + PID int + Args []string + Command string +} + +// ListActiveSessions returns running local ssh client processes. +func (s *serverService) ListActiveSessions() ([]domain.ActiveSession, error) { + processes, err := listProcesses() + if err != nil { + s.logger.Warnw("failed to list active ssh sessions", "error", err) + return nil, err + } + + sessions := make([]domain.ActiveSession, 0) + for _, process := range processes { + session, ok := parseSSHProcess(process) + if ok { + sessions = append(sessions, session) + } + } + + sort.SliceStable(sessions, func(i, j int) bool { + if sessions[i].Alias != sessions[j].Alias { + return strings.ToLower(sessions[i].Alias) < strings.ToLower(sessions[j].Alias) + } + return sessions[i].PID < sessions[j].PID + }) + + return sessions, nil +} + +func parseSSHProcessLine(line string) (domain.ActiveSession, bool) { + process, ok := parseProcessLine(line) + if !ok { + return domain.ActiveSession{}, false + } + return parseSSHProcess(process) +} + +func parseProcessLine(line string) (processInfo, bool) { + line = strings.TrimSpace(line) + if line == "" { + return processInfo{}, false + } + + pidText, command, ok := strings.Cut(line, " ") + if !ok { + return processInfo{}, false + } + pid, err := strconv.Atoi(strings.TrimSpace(pidText)) + if err != nil { + return processInfo{}, false + } + + command = strings.TrimSpace(command) + return processInfo{ + PID: pid, + Args: splitCommandLine(command), + Command: command, + }, true +} + +func parseSSHProcess(process processInfo) (domain.ActiveSession, bool) { + if len(process.Args) == 0 || filepath.Base(process.Args[0]) != "ssh" { + return domain.ActiveSession{}, false + } + + destination, forwards := parseSSHDestination(process.Args[1:]) + if destination == "" { + return domain.ActiveSession{}, false + } + + return domain.ActiveSession{ + PID: process.PID, + Alias: displayAlias(destination), + Destination: destination, + Command: process.Command, + Forwards: forwards, + }, true +} + +func parseSSHDestination(args []string) (string, []string) { + forwards := make([]string, 0) + + for index := 0; index < len(args); index++ { + arg := args[index] + if arg == "" { + continue + } + if arg == "--" { + if index+1 < len(args) { + return args[index+1], forwards + } + return "", forwards + } + if !strings.HasPrefix(arg, "-") { + return arg, forwards + } + + if forwarding, ok := extractForwardSpec(arg); ok { + if forwarding == "" && index+1 < len(args) { + forwarding = args[index+1] + index++ + } + forwards = append(forwards, forwarding) + continue + } + + if optionConsumesNextArg(arg) && index+1 < len(args) { + index++ + } + } + + return "", forwards +} + +func extractForwardSpec(arg string) (string, bool) { + for _, flag := range []string{"-L", "-R", "-D"} { + if arg == flag { + return "", true + } + if strings.HasPrefix(arg, flag) { + return arg[2:], true + } + } + return "", false +} + +func optionConsumesNextArg(arg string) bool { + if len(arg) < 2 || !strings.HasPrefix(arg, "-") || strings.HasPrefix(arg, "--") { + return false + } + if len(arg) > 2 { + return false + } + return strings.ContainsRune("BbcEeFIJilmOoPpQRSWw", rune(arg[1])) +} + +func displayAlias(destination string) string { + alias := strings.TrimSpace(destination) + if at := strings.LastIndex(alias, "@"); at >= 0 && at < len(alias)-1 { + alias = alias[at+1:] + } + alias = strings.TrimPrefix(alias, "[") + if end := strings.Index(alias, "]"); end >= 0 { + return alias[:end] + } + if strings.Count(alias, ":") == 1 { + colon := strings.LastIndex(alias, ":") + return alias[:colon] + } + return alias +} + +func splitCommandLine(command string) []string { + fields := make([]string, 0) + var current strings.Builder + var quote rune + escaped := false + + for _, r := range command { + switch { + case escaped: + current.WriteRune(r) + escaped = false + case r == '\\': + escaped = true + case quote != 0: + if r == quote { + quote = 0 + } else { + current.WriteRune(r) + } + case r == '\'' || r == '"': + quote = r + case r == ' ' || r == '\t' || r == '\n': + if current.Len() > 0 { + fields = append(fields, current.String()) + current.Reset() + } + default: + current.WriteRune(r) + } + } + + if escaped { + current.WriteRune('\\') + } + if current.Len() > 0 { + fields = append(fields, current.String()) + } + + return fields +} diff --git a/internal/core/services/active_sessions_linux.go b/internal/core/services/active_sessions_linux.go new file mode 100644 index 0000000..86ab4da --- /dev/null +++ b/internal/core/services/active_sessions_linux.go @@ -0,0 +1,94 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package services + +import ( + "os" + "path/filepath" + "strconv" + "strings" +) + +func listProcesses() ([]processInfo, error) { + entries, err := os.ReadDir("/proc") + if err != nil { + return nil, err + } + + currentUID := os.Getuid() + processes := make([]processInfo, 0) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + pid, err := strconv.Atoi(entry.Name()) + if err != nil { + continue + } + procDir := filepath.Join("/proc", entry.Name()) + if !procStatusMatchesUID(filepath.Join(procDir, "status"), currentUID) { + continue + } + args, ok := readProcCmdline(filepath.Join(procDir, "cmdline")) + if !ok || len(args) == 0 { + continue + } + processes = append(processes, processInfo{ + PID: pid, + Args: args, + Command: strings.Join(args, " "), + }) + } + return processes, nil +} + +func procStatusMatchesUID(path string, currentUID int) bool { + // #nosec G304 -- path is built from a numeric /proc pid directory and a fixed status filename. + data, err := os.ReadFile(path) + if err != nil { + return false + } + for _, line := range strings.Split(string(data), "\n") { + if !strings.HasPrefix(line, "Uid:") { + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + return false + } + uid, err := strconv.Atoi(fields[1]) + return err == nil && uid == currentUID + } + return false +} + +func readProcCmdline(path string) ([]string, bool) { + // #nosec G304 -- path is built from a numeric /proc pid directory and a fixed cmdline filename. + data, err := os.ReadFile(path) + if err != nil || len(data) == 0 { + return nil, false + } + + rawArgs := strings.Split(strings.TrimRight(string(data), "\x00"), "\x00") + args := make([]string, 0, len(rawArgs)) + for _, arg := range rawArgs { + if arg != "" { + args = append(args, arg) + } + } + return args, len(args) > 0 +} diff --git a/internal/core/services/active_sessions_test.go b/internal/core/services/active_sessions_test.go new file mode 100644 index 0000000..ea14dd2 --- /dev/null +++ b/internal/core/services/active_sessions_test.go @@ -0,0 +1,134 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package services + +import ( + "slices" + "testing" +) + +func TestParseSSHProcessLine(t *testing.T) { + tests := []struct { + name string + line string + wantAlias string + wantDest string + wantForward []string + }{ + { + name: "simple alias", + line: "12345 ssh prod", + wantAlias: "prod", + wantDest: "prod", + }, + { + name: "config file option", + line: "12346 ssh -F /tmp/config prod", + wantAlias: "prod", + wantDest: "prod", + }, + { + name: "local forward", + line: "12347 ssh -L 8080:localhost:80 prod", + wantAlias: "prod", + wantDest: "prod", + wantForward: []string{"8080:localhost:80"}, + }, + { + name: "compact forward and user destination", + line: "12348 /usr/bin/ssh -N -D1080 ops@example.com", + wantAlias: "example.com", + wantDest: "ops@example.com", + wantForward: []string{"1080"}, + }, + { + name: "quoted destination", + line: "12349 ssh -o StrictHostKeyChecking=no 'prod web'", + wantAlias: "prod web", + wantDest: "prod web", + }, + { + name: "bind interface option", + line: "12350 ssh -B en0 prod", + wantAlias: "prod", + wantDest: "prod", + }, + { + name: "escape char option", + line: "12351 ssh -e none prod", + wantAlias: "prod", + wantDest: "prod", + }, + { + name: "unbracketed ipv6 destination", + line: "12352 ssh user@2001:db8::1", + wantAlias: "2001:db8::1", + wantDest: "user@2001:db8::1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := parseSSHProcessLine(tt.line) + if !ok { + t.Fatalf("parseSSHProcessLine() ok = false, want true") + } + if got.Alias != tt.wantAlias { + t.Fatalf("Alias = %q, want %q", got.Alias, tt.wantAlias) + } + if got.Destination != tt.wantDest { + t.Fatalf("Destination = %q, want %q", got.Destination, tt.wantDest) + } + if !slices.Equal(got.Forwards, tt.wantForward) { + t.Fatalf("Forwards = %#v, want %#v", got.Forwards, tt.wantForward) + } + }) + } +} + +func TestParseSSHProcessPreservesArgvBoundaries(t *testing.T) { + got, ok := parseSSHProcess(processInfo{ + PID: 12345, + Args: []string{ + "ssh", + "-F", + "/tmp/ssh configs/config", + "prod", + }, + Command: "ssh -F /tmp/ssh configs/config prod", + }) + if !ok { + t.Fatalf("parseSSHProcess() ok = false, want true") + } + if got.Destination != "prod" { + t.Fatalf("Destination = %q, want prod", got.Destination) + } +} + +func TestParseSSHProcessLineRejectsNonSessions(t *testing.T) { + tests := []string{ + "", + "not-a-pid ssh prod", + "12345 ssh-agent", + "12346 scp file prod:/tmp", + "12347 ssh", + } + + for _, line := range tests { + if got, ok := parseSSHProcessLine(line); ok { + t.Fatalf("parseSSHProcessLine(%q) = %#v, true; want false", line, got) + } + } +} diff --git a/internal/core/services/active_sessions_unix.go b/internal/core/services/active_sessions_unix.go new file mode 100644 index 0000000..63ce1eb --- /dev/null +++ b/internal/core/services/active_sessions_unix.go @@ -0,0 +1,45 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !windows && !linux + +package services + +import ( + "os/exec" + "os/user" + "strings" +) + +func listProcesses() ([]processInfo, error) { + currentUser, err := user.Current() + if err != nil { + return nil, err + } + + // #nosec G204 -- currentUser.Username comes from the local OS account and scopes ps to this user. + out, err := exec.Command("ps", "-U", currentUser.Username, "-o", "pid=,command=").Output() + if err != nil { + return nil, err + } + + processes := make([]processInfo, 0) + for _, line := range strings.Split(string(out), "\n") { + process, ok := parseProcessLine(line) + if ok { + processes = append(processes, process) + } + } + return processes, nil +} diff --git a/internal/core/services/active_sessions_windows.go b/internal/core/services/active_sessions_windows.go new file mode 100644 index 0000000..a3707f2 --- /dev/null +++ b/internal/core/services/active_sessions_windows.go @@ -0,0 +1,55 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package services + +import ( + "os/exec" + "strconv" + "strings" +) + +func listProcesses() ([]processInfo, error) { + command := "Get-CimInstance Win32_Process -Filter \"Name = 'ssh.exe'\" | " + + "ForEach-Object { [string]$_.ProcessId + \"`t\" + $_.CommandLine }" + out, err := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", command).Output() + if err != nil { + return nil, err + } + + processes := make([]processInfo, 0) + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + pidText, commandLine, ok := strings.Cut(line, "\t") + if !ok { + continue + } + pid, err := strconv.Atoi(strings.TrimSpace(pidText)) + if err != nil { + continue + } + commandLine = strings.TrimSpace(commandLine) + processes = append(processes, processInfo{ + PID: pid, + Args: splitCommandLine(commandLine), + Command: commandLine, + }) + } + return processes, nil +}