Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions host/host_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@ import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"os"
"regexp"
"strings"
"sync"
"time"

"golang.org/x/sys/unix"

Expand Down Expand Up @@ -83,6 +86,12 @@ func UsersWithContext(ctx context.Context) ([]UserStat, error) {

file, err := os.Open(utmpfile)
if err != nil {
if os.IsNotExist(err) {
// utmp is no longer populated on some newer distributions
// (e.g. Debian 13), which track sessions via systemd-logind
// instead. Fall back to loginctl in that case.
return usersFromLoginctlWithContext(ctx)
}
return nil, err
}
defer file.Close()
Expand Down Expand Up @@ -120,6 +129,79 @@ func UsersWithContext(ctx context.Context) ([]UserStat, error) {
return ret, nil
}

type loginctlSession struct {
Session string `json:"session"`
}

func usersFromLoginctlWithContext(ctx context.Context) ([]UserStat, error) {
out, err := invoke.CommandWithContext(ctx, "loginctl", "list-sessions", "-o", "json")
if err != nil {
return nil, err
}

var sessions []loginctlSession
if err := json.Unmarshal(out, &sessions); err != nil {
return nil, err
}

type sessionResult struct {
stat UserStat
err error
}

results := make([]sessionResult, len(sessions))
var wg sync.WaitGroup
for i, s := range sessions {
wg.Add(1)
go func(i int, id string) {
defer wg.Done()
stat, err := loginctlShowSessionWithContext(ctx, id)
results[i] = sessionResult{stat: stat, err: err}
}(i, s.Session)
}
wg.Wait()

ret := make([]UserStat, 0, len(sessions))
for _, r := range results {
if r.err != nil {
continue
}
ret = append(ret, r.stat)
}

return ret, nil
}

func loginctlShowSessionWithContext(ctx context.Context, id string) (UserStat, error) {
out, err := invoke.CommandWithContext(ctx, "loginctl", "show-session", id,
"-p", "Name", "-p", "TTY", "-p", "RemoteHost", "-p", "Timestamp")
if err != nil {
return UserStat{}, err
}

var stat UserStat
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
key, value, found := strings.Cut(line, "=")
if !found {
continue
}
switch key {
case "Name":
stat.User = value
case "TTY":
stat.Terminal = value
case "RemoteHost":
stat.Host = value
case "Timestamp":
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", value); err == nil {
stat.Started = int(t.Unix())
}
}
}

return stat, nil
}

func getlsbStruct(ctx context.Context) (*lsbStruct, error) {
ret := &lsbStruct{}
if common.PathExists(common.HostEtcWithContext(ctx, "lsb-release")) {
Expand Down
65 changes: 65 additions & 0 deletions host/host_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,79 @@ package host

import (
"context"
"fmt"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/shirou/gopsutil/v4/common"
)

type fakeLoginctlInvoke struct {
sessions string
sessionOutputs map[string]string
}

func (f fakeLoginctlInvoke) Command(name string, arg ...string) ([]byte, error) {
return f.CommandWithContext(context.Background(), name, arg...)
}

func (f fakeLoginctlInvoke) CommandWithContext(_ context.Context, name string, arg ...string) ([]byte, error) {
if name != "loginctl" || len(arg) == 0 {
return nil, fmt.Errorf("unexpected command: %s %v", name, arg)
}
switch arg[0] {
case "list-sessions":
return []byte(f.sessions), nil
case "show-session":
out, ok := f.sessionOutputs[arg[1]]
if !ok {
return nil, fmt.Errorf("unexpected session id: %s", arg[1])
}
return []byte(out), nil
default:
return nil, fmt.Errorf("unexpected subcommand: %s", arg[0])
}
}

func TestUsersFromLoginctl(t *testing.T) {
fake := fakeLoginctlInvoke{
sessions: `[{"session":"2771","uid":0,"user":"root","seat":null,"tty":null,"state":"closing","idle":false,"since":null}]`,
sessionOutputs: map[string]string{
"2771": "Name=root\nTTY=pts/1\nRemoteHost=10.5.22.31\nTimestamp=Thu 2026-01-22 14:51:57 CET\n",
},
}

old := invoke
invoke = fake
defer func() { invoke = old }()

got, err := usersFromLoginctlWithContext(context.Background())
require.NoError(t, err)
require.Len(t, got, 1)
assert.Equal(t, "root", got[0].User)
assert.Equal(t, "pts/1", got[0].Terminal)
assert.Equal(t, "10.5.22.31", got[0].Host)

wantTime, err := time.Parse("Mon 2006-01-02 15:04:05 MST", "Thu 2026-01-22 14:51:57 CET")
require.NoError(t, err)
assert.Equal(t, int(wantTime.Unix()), got[0].Started)
}

func TestUsersFromLoginctlNoSessions(t *testing.T) {
fake := fakeLoginctlInvoke{sessions: `[]`}

old := invoke
invoke = fake
defer func() { invoke = old }()

got, err := usersFromLoginctlWithContext(context.Background())
require.NoError(t, err)
assert.Empty(t, got)
}

func TestGetRedhatishVersion(t *testing.T) {
var ret string
c := []string{"Rawhide"}
Expand Down
Loading