Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8a9ec47
profiles, bwrap: carry the host timezone with TZ, not a mount
pjbgf Sep 10, 2026
6961ea2
host-run: authorize the command against the profile's display
pjbgf Sep 10, 2026
8193787
host-run: return while the command runs
pjbgf Sep 10, 2026
aa114f2
gateway: keep the gateway's log where it can be read
pjbgf Sep 10, 2026
ee71317
host-run: leave the launching session's variables behind
pjbgf Sep 10, 2026
781c5cf
xkb: prefer the running layout on an X11 session
pjbgf Sep 10, 2026
d9e2878
profiles: name the profile's display for the window manager
pjbgf Sep 10, 2026
27079da
gateway: narrow the logs to one profile or one workload
pjbgf Sep 10, 2026
4d64b64
doctor: report what the gateway has been doing with egress
pjbgf Sep 10, 2026
c9c10e8
gateway: do not report a config the running gateway may not have
pjbgf Sep 10, 2026
72e04b0
bwrap: tell a workload where to ask the gateway for a tunnel
pjbgf Sep 10, 2026
d57908a
gateway: say when the log will not close
pjbgf Sep 10, 2026
dcddd79
gateway: wait for a stopped gateway to go before forgetting it
pjbgf Sep 10, 2026
8196091
gateway: describe the address record, not a gateway that is not running
pjbgf Sep 10, 2026
5cd2927
gateway: say the subnet mismatch the same way in both places
pjbgf Sep 10, 2026
9ffd3f6
cmd: write an error to stderr, not to stdout
pjbgf Sep 12, 2026
52f132c
gateway, bwrap: give a workload's ssh a way through the gateway
pjbgf Sep 12, 2026
870e1ad
gateway: keep a log's lines whole, and bound what reading one costs
pjbgf Sep 12, 2026
d14b6e9
gateway: undo slog's escapes when reading a log field
pjbgf Sep 12, 2026
3b41110
cmd: keep the Wayland warning off stdout
pjbgf Sep 12, 2026
84e1233
gateway: record which config the gateway was started from
pjbgf Sep 12, 2026
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
137 changes: 130 additions & 7 deletions cmd/cli/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ import (
"fmt"
"os"

"github.com/qubesome/cli/internal/files"
"github.com/qubesome/cli/internal/gateway"
"github.com/qubesome/cli/internal/sandbox"
"github.com/qubesome/cli/internal/session"
"github.com/qubesome/cli/internal/types"
"github.com/urfave/cli/v3"
)

Expand All @@ -23,6 +26,12 @@ import (
// Neither subcommand takes a profile. There is one gateway per session and
// not one per profile, because the policy it applies is keyed by workload
// across every profile.
var (
logsFollow bool
logsLast int
logsWorkload string
)

func gatewayCommand() *cli.Command {
cmd := &cli.Command{
Name: "gateway",
Expand All @@ -33,6 +42,7 @@ so this is for the cases where that is not enough:

qubesome gateway status - Report what qubesome knows about the session's gateway
qubesome gateway stop - Stop the session's gateway, leaving the session itself up
qubesome gateway logs - Show what the session's gateway has said

A running gateway is reused whatever image it came from, so a change to
the gateway block of the config reaches nothing until it is stopped. The
Expand All @@ -41,6 +51,7 @@ next launch then starts a fresh one inside the same session.
Commands: []*cli.Command{
gatewayStatusCommand(),
gatewayStopCommand(),
gatewayLogsCommand(),
},
}
return cmd
Expand All @@ -53,19 +64,63 @@ func gatewayStatusCommand() *cli.Command {
Action: func(ctx context.Context, cmd *cli.Command) error {
g := gateway.Current()

// The same route doctor takes to a config, and it may find
// none. The gateway is per session, so there is no profile to
// name here, and without a running profile or a user-level
// file there is nothing that says which image, policy or
// subnet a gateway was meant to have. Inspect reports that it
// could not tell rather than leaving the lines blank.
status := g.Inspect(session.Current(), profileConfigOrDefault(""), g.StatusReady)
cfg, problem := sessionConfig()

// Inspect reports that it could not tell rather than leaving
// the lines blank, so a config that could not be identified
// is passed on as none with the reason it could not.
status := g.Inspect(session.Current(), cfg, g.StatusReady)
if problem != "" {
status.ConfigProblem = problem
}

return status.Write(os.Stdout)
},
}
}

// sessionConfig returns the config describing this session's gateway, and
// why it could not be told when it cannot.
//
// It is read from the record the gateway's own launch wrote rather than
// inferred from whichever profiles happen to be active. Inference answered
// wrongly exactly when it mattered: a gateway is session wide and outlives
// the profile that started it, so once that profile has stopped there is
// nothing left among the active ones to point at, and the nearest config
// is a guess that names an image, a policy and a subnet the running
// gateway need have nothing to do with.
//
// Not profileConfigOrDefault, for the same reason. Its fallbacks are right
// for a launch, which is choosing a config to act on, and wrong here,
// where the question is which config something already running came from.
//
// With no record and no gateway running, the user-level file is the
// answer: there is nothing whose provenance could be got wrong, and what a
// status then describes is the gateway this host would start.
func sessionConfig() (*types.Config, string) {
g := gateway.Current()

if path, ok := g.RecordedConfig(); ok {
if cfg := config(path); cfg != nil {
return cfg, ""
}

return nil, fmt.Sprintf(
"the running gateway was started from %s, which no longer reads as a config, "+
"so the image, policy and subnet it names are unknown", path)
}

// A gateway with no record is one started before qubesome kept one, or
// one whose record could not be written. Either way nothing here can
// say where it came from, and a nearby config would be a guess.
if sandbox.Alive(files.GatewayStatePath()) {
return nil, "the running gateway has no record of the config it was started from, " +
"so the image, policy and subnet it names are unknown"
}

return profileConfigOrDefault(""), ""
}

func gatewayStopCommand() *cli.Command {
return &cli.Command{
Name: "stop",
Expand All @@ -89,3 +144,71 @@ func gatewayStopCommand() *cli.Command {
},
}
}

// gatewayLogsCommand shows what the gateway has said.
//
// The gateway is started by whichever qubesome run found none running, and
// it is put in a session of its own so that a Ctrl-C at that terminal does
// not take the session's egress with it. Its output has nowhere to go that
// anybody is still watching, so it is written to a file, and this is how it
// is read back. It is the record of which host a workload was allowed or
// refused, which is the one thing needed when a workload cannot reach
// something it should.
func gatewayLogsCommand() *cli.Command {
return &cli.Command{
Name: "logs",
Usage: "show the session gateway's logs",
Description: `Examples:

qubesome gateway logs - Print the log of the gateway this session is running
qubesome gateway logs -n 50 - Print its last 50 lines
qubesome gateway logs -f - Print it and keep printing what is added
qubesome gateway logs -profile work - Only the lines about that profile's workloads
qubesome gateway logs -workload chrome - Only the lines about that workload, in any profile
qubesome gateway logs -profile work -workload chrome
- Only the lines about that one workload

The log covers the gateway that is running. Starting a gateway begins it
afresh, so there is nothing here for a session that has not started one.

The gateway knows a workload as its name and its profile's joined by a
dash, and either half may hold a dash of its own, so naming only one of
the two matches the other loosely. Naming both is exact. A line about no
workload, such as the gateway's own startup, is not shown when either
filter is given.
`,
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "follow",
Aliases: []string{"f"},
Usage: "keep printing what is added to the log",
Destination: &logsFollow,
},
&cli.StringFlag{
Name: "profile",
Usage: "only the lines about the workloads of this profile",
Destination: &targetProfile,
},
&cli.StringFlag{
Name: "workload",
Usage: "only the lines about this workload",
Destination: &logsWorkload,
},
&cli.IntFlag{
Name: "lines",
Aliases: []string{"n"},
Usage: "print only this many of the log's last matching lines",
Destination: &logsLast,
},
},
Action: func(ctx context.Context, _ *cli.Command) error {
return gateway.ShowLogs(ctx, os.Stdout, gateway.LogOptions{
Path: files.GatewayLogPath(),
Profile: targetProfile,
Workload: logsWorkload,
Last: logsLast,
Follow: logsFollow,
})
},
}
}
55 changes: 55 additions & 0 deletions cmd/cli/gateway_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package cli

import (
"os"
"path/filepath"
"testing"

"github.com/qubesome/cli/internal/gateway"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// The gateway belongs to the session and not to a profile, and it outlives
// the profile that started it, so its provenance is read from the record
// that launch wrote rather than inferred from whichever profiles happen to
// be active now.
func TestRecordedConfigReadsBackWhatWasWritten(t *testing.T) {
t.Parallel()

dir := t.TempDir()
g := gateway.Gateway{ConfigPath: filepath.Join(dir, "gateway-config")}

require.NoError(t, os.WriteFile(g.ConfigPath, []byte("/home/u/dotfiles/qubesome.yaml\n"), 0o600))

got, ok := g.RecordedConfig()
assert.True(t, ok)
assert.Equal(t, "/home/u/dotfiles/qubesome.yaml", got)
}

// No record is not an empty answer. A caller has to tell the two apart,
// because one means the provenance is unknown and the other would name a
// config called "".
func TestRecordedConfigWithNoRecord(t *testing.T) {
t.Parallel()

g := gateway.Gateway{ConfigPath: filepath.Join(t.TempDir(), "gateway-config")}

got, ok := g.RecordedConfig()
assert.False(t, ok)
assert.Empty(t, got)
}

// A record that was created but never filled in says nothing, and must not
// read as a config whose path is empty.
func TestRecordedConfigWithAnEmptyRecord(t *testing.T) {
t.Parallel()

dir := t.TempDir()
g := gateway.Gateway{ConfigPath: filepath.Join(dir, "gateway-config")}

require.NoError(t, os.WriteFile(g.ConfigPath, []byte("\n"), 0o600))

_, ok := g.RecordedConfig()
assert.False(t, ok)
}
104 changes: 100 additions & 4 deletions cmd/cli/host_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,91 @@ package cli
import (
"context"
"fmt"
"os"
"os/exec"
"slices"
"strconv"
"strings"

"github.com/qubesome/cli/internal/files"
"github.com/urfave/cli/v3"
)

// hostRunEnv returns the environment for a command the host runs on a
// profile's display.
//
// The host environment is inherited rather than replaced. The command runs
// on the host as the user, with the user's own privileges, so withholding
// anything from it isolates nothing. Starting from an empty environment
// only takes away HOME and PATH, without which most host applications
// cannot find their own configuration, or a shell to run.
//
// DISPLAY and XAUTHORITY then point it at the profile instead of the host
// session. The cookie is the one the profile's workloads authenticate with,
// and the profile's X server refuses a connection that arrives without it,
// saying only that no authorization protocol was specified.
//
// What is dropped is the set of variables naming the session the command
// was launched from, which is not the session it is about to appear in.
// See launchingSession.
//
// The two entries are appended rather than substituted for the inherited
// ones. os/exec keeps the last value of a repeated key, so these are the
// values the command reads.
func hostRunEnv(base []string, display uint8, cookie string) []string {
env := make([]string, 0, len(base)+2)
for _, e := range base {
if namesTheLaunchingSession(e) {
continue
}
env = append(env, e)
}

return append(env,
"DISPLAY=:"+strconv.Itoa(int(display)),
"XAUTHORITY="+cookie,
)
}

// launchingSession are the variables describing the display session the
// command was typed or bound in, rather than the profile it is being sent
// to. Each one is a handle on the host session, and the command is about
// to connect to a different display server, so each is either ignored
// there or acted on as if it meant something.
//
// WAYLAND_DISPLAY is a path to the host compositor. A toolkit that finds
// one connects to it and ignores DISPLAY, opening the window on the host
// desktop rather than in the profile. The profile's own window manager is
// started without it for the same reason.
//
// DESKTOP_STARTUP_ID is an X11 startup notification handed out by
// whatever launched qubesome. A window manager that spawns through
// startup notification records the workspace it spawned from against that
// id, and the application exports it to the next window it opens as
// _NET_STARTUP_ID. The profile's window manager then reads an id for a
// launch it never saw, and places the window by what that resolves to
// rather than on the workspace being looked at. It is also single use: it
// belongs to the launch that created it and to no later one.
//
// XDG_ACTIVATION_TOKEN is the Wayland spelling of the same thing, with
// the same two problems.
var launchingSession = []string{
"WAYLAND_DISPLAY",
"DESKTOP_STARTUP_ID",
"XDG_ACTIVATION_TOKEN",
}

// namesTheLaunchingSession reports whether an environment entry is one of
// launchingSession.
func namesTheLaunchingSession(entry string) bool {
name, _, ok := strings.Cut(entry, "=")
if !ok {
return false
}

return slices.Contains(launchingSession, name)
}

func hostRunCommand() *cli.Command {
cmd := &cli.Command{
Name: "host-run",
Expand Down Expand Up @@ -37,12 +117,28 @@ qubesome host-run -profile <profile> firefox - Run firefox on the host and d
return err
}

cookie, err := files.ClientCookiePath(prof.Name)
if err != nil {
return err
}

c := exec.Command(commandName, cmd.Args().Slice()...) //nolint
c.Env = append(c.Env, fmt.Sprintf("DISPLAY=:%d", prof.Display))
out, err := c.CombinedOutput()
fmt.Println(string(out))
c.Env = hostRunEnv(os.Environ(), prof.Display, cookie)

// This returns while the command keeps running, as launching
// a workload does, so the terminal it was typed at is free
// again. Its stdin is left closed rather than pointed at that
// terminal, which the shell has taken back. Its output still
// goes there, because a command that fails to reach the
// profile's display says why on it.
c.Stdout = os.Stdout
c.Stderr = os.Stderr

if err := c.Start(); err != nil {
return fmt.Errorf("failed to start %q: %w", commandName, err)
}

return err
return nil
},
}
return cmd
Expand Down
Loading
Loading