bastion 0.53.2
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Linux drivers for computer:*.
|
||||
//
|
||||
// Inventory reads freedesktop .desktop entries — the same files the app menu
|
||||
// reads, so "installed" means "the user can launch it", which is the map the
|
||||
// host wants. Flatpak and snap export theirs into the same format. A .desktop
|
||||
// file carries no application version (its `Version=` key is the spec
|
||||
// version of the file itself), so Version stays empty rather than invented.
|
||||
//
|
||||
// The bastion runs as a system service (deploy/install.sh), so its own
|
||||
// environment has no DISPLAY and its HOME is /root while the person's desktop
|
||||
// lives under /home/<user>. Both drivers look where the person is: per-user
|
||||
// application dirs under every home, and the X socket in /tmp/.X11-unix when
|
||||
// DISPLAY is unset. What cannot be reached is reported as the reason.
|
||||
//
|
||||
// Windows: wmctrl -lp (X11, incl. XWayland) — a fixed argv, parsed here.
|
||||
// `xprop -root _NET_ACTIVE_WINDOW` names the focused one.
|
||||
|
||||
const (
|
||||
computerObserveSupported = true
|
||||
computerInventoryKind = "desktop-entries"
|
||||
computerWindowsKind = "wmctrl"
|
||||
)
|
||||
|
||||
var desktopDirs = []string{
|
||||
"/usr/share/applications",
|
||||
"/usr/local/share/applications",
|
||||
"/var/lib/flatpak/exports/share/applications",
|
||||
"/var/lib/snapd/desktop/applications",
|
||||
}
|
||||
|
||||
// userHomes is every home a person could have installed an app into: the
|
||||
// service's own, plus /home/*. Reading a home that is not ours fails on
|
||||
// permissions and is simply skipped.
|
||||
func userHomes() []string {
|
||||
var homes []string
|
||||
if h, err := os.UserHomeDir(); err == nil && h != "" {
|
||||
homes = append(homes, h)
|
||||
}
|
||||
if entries, err := os.ReadDir("/home"); err == nil {
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
homes = append(homes, filepath.Join("/home", e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
return dedupeStrings(homes)
|
||||
}
|
||||
|
||||
func listInstalledApps() ([]computerApp, error) {
|
||||
dirs := append([]string{}, desktopDirs...)
|
||||
for _, home := range userHomes() {
|
||||
dirs = append(dirs,
|
||||
filepath.Join(home, ".local/share/applications"),
|
||||
filepath.Join(home, ".local/share/flatpak/exports/share/applications"))
|
||||
}
|
||||
var apps []computerApp
|
||||
found := false
|
||||
for _, d := range dirs {
|
||||
entries, err := os.ReadDir(d)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".desktop") {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(d, e.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if a, ok := parseDesktopEntry(string(raw), d); ok {
|
||||
apps = append(apps, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, errors.New("no applications directory found (not a desktop install?)")
|
||||
}
|
||||
return apps, nil
|
||||
}
|
||||
|
||||
// parseDesktopEntry reads the [Desktop Entry] group: Type=Application only,
|
||||
// NoDisplay/Hidden skipped (menu conventions), Exec's program as the path
|
||||
// with the %-field codes stripped. dir tells flatpak/snap from a plain install.
|
||||
func parseDesktopEntry(text, dir string) (computerApp, bool) {
|
||||
a := computerApp{Source: "desktop"}
|
||||
if strings.Contains(dir, "flatpak") {
|
||||
a.Source = "flatpak"
|
||||
} else if strings.Contains(dir, "snap") {
|
||||
a.Source = "snap"
|
||||
}
|
||||
inMain := false
|
||||
typ := ""
|
||||
sc := bufio.NewScanner(strings.NewReader(text))
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "[") {
|
||||
inMain = line == "[Desktop Entry]"
|
||||
continue
|
||||
}
|
||||
if !inMain {
|
||||
continue
|
||||
}
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
|
||||
switch k {
|
||||
case "Type":
|
||||
typ = v
|
||||
case "Name":
|
||||
if a.Name == "" {
|
||||
a.Name = v
|
||||
}
|
||||
case "Exec":
|
||||
a.Path = execProgram(v)
|
||||
case "NoDisplay", "Hidden":
|
||||
if strings.EqualFold(v, "true") {
|
||||
return computerApp{}, false
|
||||
}
|
||||
}
|
||||
}
|
||||
if typ != "Application" || a.Name == "" {
|
||||
return computerApp{}, false
|
||||
}
|
||||
return a, true
|
||||
}
|
||||
|
||||
// execProgram is the program of an Exec= line: the first token, unquoted, with
|
||||
// an `env VAR=x` prefix skipped, and the field codes (%f %u …) never included.
|
||||
// A flatpak export (`flatpak run <app-id> …`) names the APP, "flatpak:<id>",
|
||||
// not the flatpak binary — otherwise every flatpak would earn a cli rung for
|
||||
// a program the user never runs by hand.
|
||||
func execProgram(v string) string {
|
||||
fields := strings.Fields(v)
|
||||
for i, raw := range fields {
|
||||
f := strings.Trim(raw, `"`)
|
||||
if f == "env" || strings.Contains(f, "=") {
|
||||
continue
|
||||
}
|
||||
if filepath.Base(f) == "flatpak" {
|
||||
for _, arg := range fields[i+1:] {
|
||||
arg = strings.Trim(arg, `"`)
|
||||
if arg == "run" || strings.HasPrefix(arg, "-") || strings.Contains(arg, "=") {
|
||||
continue
|
||||
}
|
||||
return "flatpak:" + arg
|
||||
}
|
||||
return "flatpak:"
|
||||
}
|
||||
return f
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// x11Display is the display the person is on: DISPLAY when the service has
|
||||
// one, else the first socket in /tmp/.X11-unix (":0" for X0). "" when neither.
|
||||
func x11Display() string {
|
||||
if d := os.Getenv("DISPLAY"); d != "" {
|
||||
return d
|
||||
}
|
||||
entries, err := os.ReadDir("/tmp/.X11-unix")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, e := range entries {
|
||||
if n, ok := strings.CutPrefix(e.Name(), "X"); ok && n != "" {
|
||||
return ":" + n
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func listWindows() ([]computerWindow, error) {
|
||||
display := x11Display()
|
||||
if display == "" {
|
||||
return nil, errors.New("no X display on this machine — headless, or Wayland without XWayland; no window list")
|
||||
}
|
||||
if !execOnPath("wmctrl") {
|
||||
return nil, errors.New("wmctrl not installed — install it (apt/dnf: wmctrl) to list windows")
|
||||
}
|
||||
out, err := runFixedEnv([]string{"DISPLAY=" + display}, "wmctrl", "-lp")
|
||||
if err != nil {
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) && len(ee.Stderr) > 0 {
|
||||
return nil, errors.New("wmctrl could not read display " + display + " (the bastion runs as a " +
|
||||
"service without the desktop session's X authority): " + strings.TrimSpace(string(ee.Stderr)))
|
||||
}
|
||||
return nil, errors.New("wmctrl failed: " + err.Error())
|
||||
}
|
||||
active := ""
|
||||
if execOnPath("xprop") {
|
||||
if x, err := runFixedEnv([]string{"DISPLAY=" + display}, "xprop", "-root", "_NET_ACTIVE_WINDOW"); err == nil {
|
||||
active = parseActiveWindow(string(x))
|
||||
}
|
||||
}
|
||||
wins := parseWmctrl(string(out), active)
|
||||
for i := range wins {
|
||||
if wins[i].PID > 0 {
|
||||
if comm, err := os.ReadFile("/proc/" + strconv.Itoa(wins[i].PID) + "/comm"); err == nil {
|
||||
wins[i].App = strings.TrimSpace(string(comm))
|
||||
}
|
||||
}
|
||||
}
|
||||
return wins, nil
|
||||
}
|
||||
|
||||
// wmctrl -lp: id, desktop, pid, client host, then the title — which keeps
|
||||
// its own spacing (a recorded step may match it byte for byte).
|
||||
var wmctrlLine = regexp.MustCompile(`^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)(?:\s(.*))?$`)
|
||||
|
||||
// parseWmctrl reads `wmctrl -lp` lines:
|
||||
//
|
||||
// 0x03400003 0 12345 hostname Window title with spaces
|
||||
//
|
||||
// Sticky windows carry desktop -1. activeID (from xprop) marks the focused
|
||||
// one; ids compare numerically so 0x3400003 and 0x03400003 are the same.
|
||||
func parseWmctrl(text, activeID string) []computerWindow {
|
||||
var wins []computerWindow
|
||||
act := hexID(activeID)
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
m := wmctrlLine.FindStringSubmatch(strings.TrimRight(line, "\r"))
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
title := m[5]
|
||||
if strings.TrimSpace(title) == "" {
|
||||
continue
|
||||
}
|
||||
pid, _ := strconv.Atoi(m[3])
|
||||
w := computerWindow{Title: title, PID: pid}
|
||||
if act != 0 && hexID(m[1]) == act {
|
||||
w.Focused = true
|
||||
}
|
||||
wins = append(wins, w)
|
||||
}
|
||||
return wins
|
||||
}
|
||||
|
||||
// parseActiveWindow reads `_NET_ACTIVE_WINDOW(WINDOW): window id # 0x3400003`.
|
||||
func parseActiveWindow(text string) string {
|
||||
if i := strings.LastIndex(text, "#"); i >= 0 {
|
||||
return strings.TrimSpace(text[i+1:])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func hexID(s string) uint64 {
|
||||
s = strings.TrimPrefix(strings.TrimSpace(strings.ToLower(s)), "0x")
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
v, err := strconv.ParseUint(s, 16, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
Reference in New Issue
Block a user