diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..442087c --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 083103e..6b56a1e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,14 +5,17 @@ on: tags: - "v*" -permissions: - contents: write # create GH releases - id-token: write # ephemeral keys (a.k.a. "keyless") signing +permissions: {} jobs: goreleaser: runs-on: ubuntu-latest + permissions: + contents: write # create GH releases + id-token: write # ephemeral keys (a.k.a. "keyless") signing + attestations: write # write GH attestations + steps: - name: Checkout uses: actions/checkout@v4 @@ -24,14 +27,19 @@ jobs: with: go-version: stable - - uses: anchore/sbom-action/download-syft@v0.15.0 - - uses: sigstore/cosign-installer@v3.4.0 + - uses: anchore/sbom-action/download-syft@v0.17.7 + - uses: sigstore/cosign-installer@v3.7.0 - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v5 + uses: goreleaser/goreleaser-action@v6 with: distribution: goreleaser - version: latest + version: '~> v2' args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Attest release artefacts + uses: actions/attest-build-provenance@v1 + with: + subject-path: "dist/qubesome*" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7da4a3e..91f3ff1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,13 +3,15 @@ name: tests on: push: -permissions: - contents: read +permissions: {} jobs: tests: runs-on: ubuntu-latest + permissions: + contents: read + steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index a5ebc93..a60cd0d 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,3 +1,4 @@ +version: 2 project_name: qubesome builds: - id: qubesome diff --git a/README.md b/README.md index d1d6f48..32496ab 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ go install github.com/qubesome/cli/cmd/qubesome@latest ##### For Tumbleweed users ``` -zypper install qubesome +zypper install -y qubesome ``` #### Start Profile @@ -45,7 +45,7 @@ To transfer clipboards between profiles use `qubesome clipboard`. Check whether dependency requirements are met: ``` -qubesome deps show +qubesome deps ``` Use a local copy, and if not found fallback to a fresh clone: @@ -129,8 +129,8 @@ Ability to control network/internet access for each workload, and run the window manager without internet access. Auditing access violations, for visibility of when workloads are trying to access things they should not. -#### Is Rootless docker support? -Not at this point, potentially this could be introduced in the future. +#### Is rootless supported? +Yes, but only with podman. ### Why do I need to run xhost +SI:localuser:${USER}? Some Linux distros (e.g. Tumbleweed) have X11 access controls enabled diff --git a/cmd/cli/autocomplete/zsh_autocomplete b/cmd/cli/autocomplete/zsh_autocomplete new file mode 100644 index 0000000..769083c --- /dev/null +++ b/cmd/cli/autocomplete/zsh_autocomplete @@ -0,0 +1,23 @@ +#compdef qubesome +compdef _qubesome qubesome + +_qubesome() { + local -a opts + local cur + cur=${words[-1]} + if [[ "$cur" == "-"* ]]; then + opts=("${(@f)$(${words[@]:0:#words[@]-1} ${cur} --generate-shell-completion)}") + else + opts=("${(@f)$(${words[@]:0:#words[@]-1} --generate-shell-completion)}") + fi + + if [[ "${opts[1]}" != "" ]]; then + _describe 'values' opts + else + _files + fi +} + +if [ "$funcstack[1]" = "_qubesome" ]; then + _qubesome +fi diff --git a/cmd/cli/completion.go b/cmd/cli/completion.go new file mode 100644 index 0000000..d422e9a --- /dev/null +++ b/cmd/cli/completion.go @@ -0,0 +1,25 @@ +package cli + +import ( + "context" + _ "embed" + "fmt" + + "github.com/urfave/cli/v3" +) + +//go:embed autocomplete/zsh_autocomplete +var autocompleteZSH string + +func completionCommand() *cli.Command { + cmd := &cli.Command{ + Name: "autocomplete", + Usage: "Generate autocomplete", + UsageText: "source <(qubesome autocomplete)", + Action: func(ctx context.Context, cmd *cli.Command) error { + fmt.Println(autocompleteZSH) + return nil + }, + } + return cmd +} diff --git a/cmd/cli/images.go b/cmd/cli/images.go index 2a55444..a20e938 100644 --- a/cmd/cli/images.go +++ b/cmd/cli/images.go @@ -20,11 +20,18 @@ func imagesCommand() *cli.Command { Name: "profile", Destination: &targetProfile, }, + &cli.StringFlag{ + Name: "runner", + Destination: &runner, + }, }, Action: func(ctx context.Context, cmd *cli.Command) error { cfg := profileConfigOrDefault(targetProfile) - return images.Run(images.WithConfig(cfg)) + return images.Run( + images.WithConfig(cfg), + images.WithRunner(runner), + ) }, }, }, diff --git a/cmd/cli/root.go b/cmd/cli/root.go index b0dc175..488e4bb 100644 --- a/cmd/cli/root.go +++ b/cmd/cli/root.go @@ -18,6 +18,7 @@ var ( workload string path string local string + runner string debug bool ) @@ -31,6 +32,7 @@ func RootCommand() *cli.Command { xdgCommand(), depsCommand(), versionCommand(), + completionCommand(), }, } diff --git a/cmd/cli/run.go b/cmd/cli/run.go index ea177a4..1961ef8 100644 --- a/cmd/cli/run.go +++ b/cmd/cli/run.go @@ -24,6 +24,10 @@ func runCommand() *cli.Command { Name: "profile", Destination: &targetProfile, }, + &cli.StringFlag{ + Name: "runner", + Destination: &runner, + }, }, Usage: "execute workloads", Action: func(ctx context.Context, cmd *cli.Command) error { @@ -33,6 +37,7 @@ func runCommand() *cli.Command { qubesome.WithWorkload(workload), qubesome.WithProfile(targetProfile), qubesome.WithConfig(cfg), + qubesome.WithRunner(runner), qubesome.WithExtraArgs(cmd.Args().Slice()), ) }, diff --git a/cmd/cli/start.go b/cmd/cli/start.go index f27bfc8..c81b7b8 100644 --- a/cmd/cli/start.go +++ b/cmd/cli/start.go @@ -27,6 +27,10 @@ func startCommand() *cli.Command { Usage: "local is the local path for a git repository. This is to be used in combination with --git.", Destination: &local, }, + &cli.StringFlag{ + Name: "runner", + Destination: &runner, + }, }, Arguments: []cli.Argument{ &cli.StringArg{ @@ -46,6 +50,7 @@ func startCommand() *cli.Command { profiles.WithGitURL(gitURL), profiles.WithPath(path), profiles.WithLocal(local), + profiles.WithRunner(runner), ) }, } diff --git a/cmd/cli/xdg.go b/cmd/cli/xdg.go index 2dd684b..006c79b 100644 --- a/cmd/cli/xdg.go +++ b/cmd/cli/xdg.go @@ -17,6 +17,10 @@ func xdgCommand() *cli.Command { Name: "profile", Destination: &targetProfile, }, + &cli.StringFlag{ + Name: "runner", + Destination: &runner, + }, }, Action: func(ctx context.Context, cmd *cli.Command) error { cfg := profileConfigOrDefault(targetProfile) @@ -24,6 +28,7 @@ func xdgCommand() *cli.Command { return qubesome.XdgRun( qubesome.WithConfig(cfg), qubesome.WithExtraArgs(cmd.Args().Slice()), + qubesome.WithRunner(runner), ) }, } diff --git a/internal/deps/deps.go b/internal/deps/deps.go index da2b222..3d78b5f 100644 --- a/internal/deps/deps.go +++ b/internal/deps/deps.go @@ -2,28 +2,42 @@ package deps import ( "fmt" + "os" "os/exec" + "text/tabwriter" + "github.com/qubesome/cli/internal/command" "github.com/qubesome/cli/internal/files" ) +var ( + red = "\033[31m" + green = "\033[32m" + amber = "\033[33m" + reset = "\033[0m" +) + var deps map[string][]string = map[string][]string{ - "clipboard": { + "clip": { files.XclipBinary, files.ShBinary, }, "run": { - files.ContainerRunnerBinary, + files.PodmanBinary, + files.DockerBinary, }, "xdg-open": { - files.ContainerRunnerBinary, + files.PodmanBinary, + files.DockerBinary, }, "images": { - files.ContainerRunnerBinary, + files.PodmanBinary, + files.DockerBinary, }, - "profiles": { - files.ContainerRunnerBinary, + "start": { + files.PodmanBinary, + files.DockerBinary, files.ShBinary, files.XrandrBinary, }, @@ -40,46 +54,42 @@ var optionalDeps map[string][]string = map[string][]string{ "images": { files.FireCrackerBinary, }, - "profiles": { + "start": { files.FireCrackerBinary, files.DbusBinary, }, } func Run(_ ...command.Option[interface{}]) error { - for name, d := range deps { - fmt.Printf("%s: ", name) - - if len(d) == 0 { - fmt.Println("OK") - continue - } else { - fmt.Println() - } + writer := tabwriter.NewWriter(os.Stdout, 0, 0, 5, ' ', 0) + fmt.Fprintln(writer, "Command\tDependency\tStatus") + fmt.Fprintln(writer, "-------\t----------\t------") + for name, d := range deps { for _, dn := range d { _, err := exec.LookPath(dn) - status := "OK" + status := green + "OK" + reset if err != nil { - status = "NOT FOUND" + status = red + "NOT FOUND" + reset } - fmt.Printf("- %s: %s\n", dn, status) + fmt.Fprintf(writer, "%s\t%s\t%s\n", name, dn, status) } if opt, ok := optionalDeps[name]; ok { for _, dn := range opt { _, err := exec.LookPath(dn) - status := "OK" + status := green + "OK" + reset if err != nil { - status = "NOT FOUND (Optional)" + status = amber + "NOT FOUND (Optional)" + reset } - fmt.Printf("- %s: %s\n", dn, status) + fmt.Fprintf(writer, "%s\t%s\t%s\n", name, dn, status) } } - - fmt.Println() } + + writer.Flush() + return nil } diff --git a/internal/files/binaries.go b/internal/files/binaries.go index b770f16..2b290df 100644 --- a/internal/files/binaries.go +++ b/internal/files/binaries.go @@ -1,31 +1,53 @@ package files import ( - "fmt" + "log/slog" "os/exec" ) -func init() { //nolint - p, err := exec.LookPath("podman") - if err != nil { - p2, err := exec.LookPath("docker") - if err == nil { - fmt.Println("falling back to docker") - ContainerRunnerBinary = p2 - } - } - - ContainerRunnerBinary = p -} - -var ( - ContainerRunnerBinary = "/usr/bin/podman" -) - const ( ShBinary = "/bin/sh" XclipBinary = "/usr/bin/xclip" FireCrackerBinary = "/usr/bin/firecracker" XrandrBinary = "/usr/bin/xrandr" DbusBinary = "/usr/bin/dbus-send" + PodmanBinary = "/usr/bin/podman" + DockerBinary = "/usr/bin/docker" ) + +func ContainerRunnerBinary(runner string) string { + switch runner { + case "podman": + p, err := exec.LookPath("podman") + if err == nil { + return p + } + + slog.Debug("could not find podman on PATH", "binary", PodmanBinary) + return PodmanBinary + case "docker": + p, err := exec.LookPath("docker") + if err == nil { + return p + } + + slog.Debug("could not find docker on PATH", "binary", DockerBinary) + return DockerBinary + } + + slog.Debug("auto-detecting runner") + p, err := exec.LookPath("podman") + if err == nil { + slog.Debug("found podman", "path", p) + return p + } + + p, err = exec.LookPath("docker") + if err == nil { + slog.Debug("found docker", "path", p) + return p + } + + slog.Debug("fallback to static path", "path", PodmanBinary) + return PodmanBinary +} diff --git a/internal/files/binaries_test.go b/internal/files/binaries_test.go new file mode 100644 index 0000000..4881061 --- /dev/null +++ b/internal/files/binaries_test.go @@ -0,0 +1,35 @@ +package files_test + +import ( + "testing" + + "github.com/qubesome/cli/internal/files" + "github.com/stretchr/testify/assert" +) + +func TestContainerRunnerBinary(t *testing.T) { + tests := []struct { + in string + want string + }{ + { + in: "", + want: "/usr/bin/podman", + }, + { + in: "podman", + want: "/usr/bin/podman", + }, + { + in: "docker", + want: "/usr/bin/docker", + }, + } + + for _, tc := range tests { + t.Run(tc.in, func(t *testing.T) { + got := files.ContainerRunnerBinary(tc.in) + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/internal/images/images.go b/internal/images/images.go index e42bf15..54c919c 100644 --- a/internal/images/images.go +++ b/internal/images/images.go @@ -20,17 +20,19 @@ func Run(opts ...command.Option[Options]) error { opt(o) } + bin := files.ContainerRunnerBinary(o.Runner) + slog.Debug("images.Run", "options", o) - return PullAll(o.Config) + return PullAll(bin, o.Config) } -func Pull(cfg *types.Config, wg *sync.WaitGroup) error { +func Pull(bin string, cfg *types.Config, wg *sync.WaitGroup) error { switch cfg.WorkloadPullMode { case types.Background: wg.Add(1) go func() { if exp, _ := pullExpired(); exp { - err := PullAll(cfg) + err := PullAll(bin, cfg) if err != nil { slog.Error("error pulling images", "error", err) } @@ -74,7 +76,7 @@ func pullExpired() (bool, error) { return false, nil } -func PreemptWorkloadImages(cfg *types.Config) { +func PreemptWorkloadImages(bin string, cfg *types.Config) { slog.Debug("Check need for the preemptive pull of workload images") fn := files.ImagesLastCheckedPath() @@ -82,12 +84,12 @@ func PreemptWorkloadImages(cfg *types.Config) { if err != nil && os.IsNotExist(err) { fmt.Println("INFO: Preemptively pulling workload images. This only happens on first execution and aims to avoid delays opening apps.") - _ = PullAll(cfg) + _ = PullAll(bin, cfg) _ = os.WriteFile(fn, []byte{}, files.FileMode) } } -func PullAll(cfg *types.Config) error { +func PullAll(bin string, cfg *types.Config) error { wf, err := cfg.WorkloadFiles() if err != nil { return fmt.Errorf("cannot get workloads files: %w", err) @@ -122,7 +124,7 @@ func PullAll(cfg *types.Config) error { if _, ok := seen[w.Image]; !ok { seen[w.Image] = struct{}{} - err = PullImage(w.Image) + err = PullImage(bin, w.Image) if err != nil { slog.Error("cannot pull image %q: %w", w.Image, err) } @@ -132,22 +134,22 @@ func PullAll(cfg *types.Config) error { return nil } -func PullImage(image string) error { +func PullImage(bin, image string) error { slog.Info("pulling container image", "image", image) - cmd := execabs.Command(files.ContainerRunnerBinary, "pull", image) //nolint + cmd := execabs.Command(bin, "pull", image) cmd.Stdout = os.Stdout return cmd.Run() } -func PullImageIfNotPresent(image string) error { +func PullImageIfNotPresent(bin, image string) error { slog.Debug("checking if container image is present", "image", image) - cmd := execabs.Command(files.ContainerRunnerBinary, "images", "-q", image) //nolint + cmd := execabs.Command(bin, "images", "-q", image) out, err := cmd.Output() if len(out) > 0 && err == nil { return nil } - return PullImage(image) + return PullImage(bin, image) } diff --git a/internal/images/options.go b/internal/images/options.go index a8c19d7..8af344f 100644 --- a/internal/images/options.go +++ b/internal/images/options.go @@ -7,6 +7,7 @@ import ( type Options struct { Config *types.Config + Runner string } func WithConfig(cfg *types.Config) command.Option[Options] { @@ -14,3 +15,9 @@ func WithConfig(cfg *types.Config) command.Option[Options] { o.Config = cfg } } + +func WithRunner(runner string) command.Option[Options] { + return func(o *Options) { + o.Runner = runner + } +} diff --git a/internal/profiles/options.go b/internal/profiles/options.go index 3a01c37..e5f00f2 100644 --- a/internal/profiles/options.go +++ b/internal/profiles/options.go @@ -10,6 +10,7 @@ type Options struct { Path string Local string Profile string + Runner string Config *types.Config } @@ -41,3 +42,9 @@ func WithConfig(config *types.Config) command.Option[Options] { o.Config = config } } + +func WithRunner(runner string) command.Option[Options] { + return func(o *Options) { + o.Runner = runner + } +} diff --git a/internal/profiles/profiles.go b/internal/profiles/profiles.go index 5930960..76df000 100644 --- a/internal/profiles/profiles.go +++ b/internal/profiles/profiles.go @@ -1,7 +1,6 @@ package profiles import ( - "bytes" "fmt" "log/slog" "os" @@ -22,6 +21,7 @@ import ( "github.com/qubesome/cli/internal/files" "github.com/qubesome/cli/internal/images" "github.com/qubesome/cli/internal/inception" + "github.com/qubesome/cli/internal/runners/util/container" "github.com/qubesome/cli/internal/socket" "github.com/qubesome/cli/internal/types" "github.com/qubesome/cli/internal/util/dbus" @@ -43,7 +43,7 @@ func Run(opts ...command.Option[Options]) error { } if o.GitURL != "" { - return StartFromGit(o.Profile, o.GitURL, o.Path, o.Local) + return StartFromGit(o.Runner, o.Profile, o.GitURL, o.Path, o.Local) } if o.Config == nil { @@ -54,7 +54,7 @@ func Run(opts ...command.Option[Options]) error { return fmt.Errorf("cannot start profile: profile %q not found", o.Profile) } - return Start(profile, o.Config) + return Start(o.Runner, profile, o.Config) } func validGitDir(path string) bool { @@ -72,7 +72,7 @@ func validGitDir(path string) bool { return err == nil } -func StartFromGit(name, gitURL, path, local string) error { +func StartFromGit(runner, name, gitURL, path, local string) error { ln := files.ProfileConfig(name) if _, err := os.Lstat(ln); err == nil { @@ -167,10 +167,10 @@ func StartFromGit(name, gitURL, path, local string) error { slog.Debug("start from git", "profile", p.Name, "p", path, "path", p.Path, "config", cfgPath) - return Start(p, cfg) + return Start(runner, p, cfg) } -func Start(profile *types.Profile, cfg *types.Config) (err error) { +func Start(runner string, profile *types.Profile, cfg *types.Config) (err error) { if cfg == nil { return fmt.Errorf("cannot start profile: config is nil") } @@ -184,17 +184,18 @@ func Start(profile *types.Profile, cfg *types.Config) (err error) { return err } - fi, err := os.Lstat(files.ContainerRunnerBinary) + binary := files.ContainerRunnerBinary(runner) + fi, err := os.Lstat(binary) if err != nil || !fi.Mode().IsRegular() { - return fmt.Errorf("could not find docker or podman") + return fmt.Errorf("could not find container runner %q", binary) } - err = images.PullImageIfNotPresent(profile.Image) + err = images.PullImageIfNotPresent(binary, profile.Image) if err != nil { return fmt.Errorf("cannot pull profile image: %w", err) } - go images.PreemptWorkloadImages(cfg) + go images.PreemptWorkloadImages(binary, cfg) if profile.Gpus != "" { if !gpu.Supported() { @@ -254,7 +255,7 @@ func Start(profile *types.Profile, cfg *types.Config) (err error) { return err } - err = createNewDisplay(profile, strconv.Itoa(int(profile.Display))) + err = createNewDisplay(binary, profile, strconv.Itoa(int(profile.Display))) if err != nil { return err } @@ -267,13 +268,13 @@ func Start(profile *types.Profile, cfg *types.Config) (err error) { // If xhost access control is enabled, it may block qubesome // execution. A tail sign is the profile container dying early. - if !containerRunning(name) { + if !container.Running(binary, name) { msg := os.ExpandEnv("run xhost +SI:localhost:${USER} and try again") dbus.NotifyOrLog("qubesome start error", msg) return fmt.Errorf("failed to start profile: %s", msg) } - err = startWindowManager(name, strconv.Itoa(int(profile.Display)), profile.WindowManager) + err = startWindowManager(binary, name, strconv.Itoa(int(profile.Display)), profile.WindowManager) if err != nil { return err } @@ -283,20 +284,6 @@ func Start(profile *types.Profile, cfg *types.Config) (err error) { return nil } -func containerRunning(name string) bool { - args := fmt.Sprintf("ps -q -f name=%s", name) - cmd := execabs.Command(files.ContainerRunnerBinary, //nolint:gosec - strings.Split(args, " ")...) - - out, err := cmd.Output() - id := string(bytes.TrimSuffix(out, []byte("\n"))) - if err != nil || id == "" { - return false - } - - return true -} - func createMagicCookie(profile *types.Profile) error { serverPath, err := files.ServerCookiePath(profile.Name) if err != nil { @@ -339,11 +326,11 @@ func createMagicCookie(profile *types.Profile) error { return xauth.AuthPair(profile.Display, parent, server, client) } -func startWindowManager(name, display, wm string) error { +func startWindowManager(bin, name, display, wm string) error { args := []string{"exec", name, files.ShBinary, "-c", fmt.Sprintf("DISPLAY=:%s %s", display, wm)} - slog.Debug(files.ContainerRunnerBinary+" exec", "container-name", name, "args", args) - cmd := execabs.Command(files.ContainerRunnerBinary, args...) //nolint + slog.Debug(bin+" exec", "container-name", name, "args", args) + cmd := execabs.Command(bin, args...) output, err := cmd.CombinedOutput() if err != nil { @@ -352,7 +339,7 @@ func startWindowManager(name, display, wm string) error { return nil } -func createNewDisplay(profile *types.Profile, display string) error { +func createNewDisplay(bin string, profile *types.Profile, display string) error { command := "Xephyr" res, err := resolution.Primary() if err != nil { @@ -454,7 +441,7 @@ func createNewDisplay(profile *types.Profile, display string) error { "--cap-drop=ALL", } - if strings.HasSuffix(files.ContainerRunnerBinary, "podman") { + if strings.HasSuffix(bin, "podman") { dockerArgs = append(dockerArgs, "--userns=keep-id") } if strings.EqualFold(os.Getenv("XDG_SESSION_TYPE"), "wayland") { @@ -528,8 +515,8 @@ func createNewDisplay(profile *types.Profile, display string) error { "INFO: For best experience use input grabber shortcuts:", grabberShortcut()) - slog.Debug("exec: docker", "args", dockerArgs) - cmd := execabs.Command(files.ContainerRunnerBinary, dockerArgs...) //nolint + slog.Debug("exec: "+bin, "args", dockerArgs) + cmd := execabs.Command(bin, dockerArgs...) output, err := cmd.CombinedOutput() if err != nil { diff --git a/internal/qubesome/mime.go b/internal/qubesome/mime.go index b31b3bf..856b810 100644 --- a/internal/qubesome/mime.go +++ b/internal/qubesome/mime.go @@ -7,7 +7,7 @@ import ( "strings" ) -func (q *Qubesome) HandleMime(in *WorkloadInfo, args []string) error { +func (q *Qubesome) HandleMime(in *WorkloadInfo, args []string, runnerOverride string) error { slog.Debug("handle mime", "profile", in, "args", args) if len(args) != 1 { @@ -31,7 +31,7 @@ func (q *Qubesome) HandleMime(in *WorkloadInfo, args []string) error { return fmt.Errorf("cannot handle schemeless mime type: default mime handler is not set") } - return q.runner(q.defaultWorkload(in, args)) + return q.runner(q.defaultWorkload(in, args), runnerOverride) } if m, ok := in.Config.MimeHandlers[u.Scheme]; ok { @@ -43,7 +43,7 @@ func (q *Qubesome) HandleMime(in *WorkloadInfo, args []string) error { } q.overrideWithProfile(in, &wi) - return q.runner(wi) + return q.runner(wi, runnerOverride) } if in.Config.DefaultMimeHandler == nil { @@ -53,7 +53,7 @@ func (q *Qubesome) HandleMime(in *WorkloadInfo, args []string) error { slog.Debug("no scheme specific handler: falling back to default mime handler") // falls back to default - return q.runner(q.defaultWorkload(in, args)) + return q.runner(q.defaultWorkload(in, args), runnerOverride) } func (q *Qubesome) overrideWithProfile(in *WorkloadInfo, wi *WorkloadInfo) { diff --git a/internal/qubesome/mime_test.go b/internal/qubesome/mime_test.go index d07b025..19bf359 100644 --- a/internal/qubesome/mime_test.go +++ b/internal/qubesome/mime_test.go @@ -143,13 +143,13 @@ func Test_HandleMime(t *testing.T) { called := 0 q := New() - q.runner = func(wi WorkloadInfo) error { + q.runner = func(wi WorkloadInfo, _ string) error { actual = &wi called++ return nil } - err := q.HandleMime(&WorkloadInfo{Config: tc.cfg, Profile: tc.profile}, tc.args) + err := q.HandleMime(&WorkloadInfo{Config: tc.cfg, Profile: tc.profile}, tc.args, "") if tc.errContains == "" { assert.Nil(err) diff --git a/internal/qubesome/options.go b/internal/qubesome/options.go index 18294db..dd1f834 100644 --- a/internal/qubesome/options.go +++ b/internal/qubesome/options.go @@ -11,6 +11,7 @@ type Options struct { Workload string Config *types.Config Profile string + Runner string ExtraArgs []string } @@ -26,6 +27,12 @@ func WithProfile(profile string) command.Option[Options] { } } +func WithRunner(runner string) command.Option[Options] { + return func(o *Options) { + o.Runner = runner + } +} + func WithWorkload(workload string) command.Option[Options] { return func(o *Options) { o.Workload = workload diff --git a/internal/qubesome/qubesome.go b/internal/qubesome/qubesome.go index 68f4724..6c91af4 100644 --- a/internal/qubesome/qubesome.go +++ b/internal/qubesome/qubesome.go @@ -17,7 +17,7 @@ var ( ) type Qubesome struct { - runner func(in WorkloadInfo) error + runner func(in WorkloadInfo, runnerOverride string) error } func New() *Qubesome { diff --git a/internal/qubesome/run.go b/internal/qubesome/run.go index 95e52fd..b6f8469 100644 --- a/internal/qubesome/run.go +++ b/internal/qubesome/run.go @@ -18,6 +18,7 @@ import ( "github.com/qubesome/cli/internal/inception" "github.com/qubesome/cli/internal/runners/docker" "github.com/qubesome/cli/internal/runners/firecracker" + "github.com/qubesome/cli/internal/runners/podman" "github.com/qubesome/cli/internal/types" "github.com/qubesome/cli/internal/util/dbus" "gopkg.in/yaml.v3" @@ -66,7 +67,7 @@ func XdgRun(opts ...command.Option[Options]) error { Config: o.Config, } - return q.HandleMime(in, o.ExtraArgs) + return q.HandleMime(in, o.ExtraArgs, o.Runner) } func Run(opts ...command.Option[Options]) error { @@ -86,7 +87,8 @@ func Run(opts ...command.Option[Options]) error { } wg := sync.WaitGroup{} - if err := images.Pull(o.Config, &wg); err != nil { + bin := files.ContainerRunnerBinary(o.Runner) + if err := images.Pull(bin, o.Config, &wg); err != nil { return err } in := WorkloadInfo{ @@ -98,10 +100,10 @@ func Run(opts ...command.Option[Options]) error { // Wait for any background operation that is in-flight. defer wg.Wait() - return runner(in) + return runner(in, o.Runner) } -func runner(in WorkloadInfo) error { +func runner(in WorkloadInfo, runnerOverride string) error { if err := in.Validate(); err != nil { return err } @@ -214,9 +216,15 @@ func runner(in WorkloadInfo) error { ew.Workload.Args = append(ew.Workload.Args, in.Args...) + if runnerOverride != "" { + ew.Workload.Runner = runnerOverride + } + switch ew.Workload.Runner { case "firecracker": return firecracker.Run(ew) + case "podman": + return podman.Run(ew) default: return docker.Run(ew) diff --git a/internal/runners/docker/run.go b/internal/runners/docker/run.go index f1a4fe5..6cfde9c 100644 --- a/internal/runners/docker/run.go +++ b/internal/runners/docker/run.go @@ -11,6 +11,9 @@ import ( "github.com/qubesome/cli/internal/env" "github.com/qubesome/cli/internal/files" + "github.com/qubesome/cli/internal/runners/util/container" + "github.com/qubesome/cli/internal/runners/util/mime" + "github.com/qubesome/cli/internal/runners/util/usb" "github.com/qubesome/cli/internal/types" "github.com/qubesome/cli/internal/util/dbus" "github.com/qubesome/cli/internal/util/gpu" @@ -18,64 +21,9 @@ import ( ) var ( - defaultMimeHandler = `[Desktop Entry] -Version=1.0 -Type=Application -Name=qubesome -Exec=/usr/local/bin/qubesome xdg-open %u -StartupNotify=false -` - mimesList = `[Default Applications] -x-scheme-handler/slack=qubesome-default-handler.desktop; - -application/x-yaml=qubesome-default-handler.desktop; -text/english=qubesome-default-handler.desktop; -text/html=qubesome-default-handler.desktop; -text/plain=qubesome-default-handler.desktop; -text/x-c=qubesome-default-handler.desktop; -text/x-c++=qubesome-default-handler.desktop; -text/x-makefile=qubesome-default-handler.desktop; -text/xml=qubesome-default-handler.desktop; -x-www-browser=qubesome-default-handler.desktop; - -x-scheme-handler/http=qubesome-default-handler.desktop; -x-scheme-handler/https=qubesome-default-handler.desktop; -x-scheme-handler/about=qubesome-default-handler.desktop; -x-scheme-handler/unknown=qubesome-default-handler.desktop; - -[Removed Associations] -x-scheme-handler/slack=slack.desktop; -x-scheme-handler/http=firefox.desktop; -x-scheme-handler/https=firefox.desktop; -x-scheme-handler/snap=snap-handle-link.desktop; -` + runnerBinary = files.ContainerRunnerBinary("docker") ) -func ContainerID(name string) (string, bool) { - args := fmt.Sprintf("ps -a -q -f name=%s", name) - cmd := execabs.Command(files.ContainerRunnerBinary, //nolint:gosec - strings.Split(args, " ")...) - - out, err := cmd.Output() - id := string(bytes.TrimSuffix(out, []byte("\n"))) - - if err != nil || id == "" { - return "", false - } - - return id, true -} - -func exec(id string, ew types.EffectiveWorkload) error { - args := []string{"exec", "--detach", id, ew.Workload.Command} - args = append(args, ew.Workload.Args...) - - slog.Debug(files.ContainerRunnerBinary+" exec", "container-id", id, "cmd", ew.Workload.Command, "args", ew.Workload.Args) - cmd := execabs.Command(files.ContainerRunnerBinary, args...) //nolint - - return cmd.Run() -} - func Run(ew types.EffectiveWorkload) error { if err := ew.Validate(); err != nil { return err @@ -83,12 +31,12 @@ func Run(ew types.EffectiveWorkload) error { wl := ew.Workload if wl.SingleInstance { - if id, ok := ContainerID(ew.Name); ok { - return exec(id, ew) + if id, ok := container.ID(runnerBinary, ew.Name); ok { + return container.Exec(runnerBinary, id, ew) } } - ndevs, err := namedDevices(wl.HostAccess.USBDevices) + ndevs, err := usb.NamedDevices(wl.HostAccess.USBDevices) if err != nil { return fmt.Errorf("failed to get named devices: %w", err) } @@ -118,11 +66,6 @@ func Run(ew types.EffectiveWorkload) error { "-d", "--security-opt=seccomp=unconfined", "--security-opt=no-new-privileges=true", - "--group-add=keep-groups", - } - - if strings.HasSuffix(files.ContainerRunnerBinary, "podman") { - args = append(args, "--userns=keep-id") } if ew.Workload.User != nil { @@ -174,7 +117,7 @@ func Run(ew types.EffectiveWorkload) error { args = append(args, "-v="+xdgRuntimeDir+":/run/user/1000") } else { if wl.HostAccess.Dbus || wl.HostAccess.Bluetooth || wl.HostAccess.VarRunUser { - args = append(args, "-v=/run/user/1000:/run/user/1000:z") + args = append(args, "-v=/run/user/1000:/run/user/1000") } userDir, err := files.IsolatedRunUserPath(ew.Profile.Name) @@ -185,7 +128,7 @@ func Run(ew types.EffectiveWorkload) error { if wl.HostAccess.Dbus || wl.HostAccess.Bluetooth || wl.HostAccess.VarRunUser { args = append(args, hostDbusParams()...) } else { - paths = append(paths, fmt.Sprintf("-v=%s:/run/user/1000:z", userDir)) + paths = append(paths, fmt.Sprintf("-v=%s:/run/user/1000", userDir)) machineIDPath := filepath.Join(files.ProfileDir(ew.Profile.Name), "machine-id") paths = append(paths, fmt.Sprintf("-v=%s:/etc/machine-id:ro", machineIDPath)) @@ -224,7 +167,7 @@ func Run(ew types.EffectiveWorkload) error { srcMimeList := filepath.Join(pdir, "mimeapps.list") dstMimeList := filepath.Join(homedir, ".local", "share", "applications", "mimeapps.list") - err = os.WriteFile(srcMimeList, []byte(mimesList), files.FileMode) + err = os.WriteFile(srcMimeList, []byte(mime.MimesList), files.FileMode) if err != nil { return fmt.Errorf("failed to write mimeapps.list: %w", err) } @@ -233,7 +176,7 @@ func Run(ew types.EffectiveWorkload) error { srcHandler := filepath.Join(pdir, "mime-handler.desktop") dstHandler := filepath.Join(homedir, ".local", "share", "applications", "qubesome-default-handler.desktop") - err = os.WriteFile(srcHandler, []byte(defaultMimeHandler), files.FileMode) + err = os.WriteFile(srcHandler, []byte(mime.DefaultMimeHandler), files.FileMode) if err != nil { return fmt.Errorf("failed to write mime-handler.desktop: %w", err) } @@ -297,15 +240,15 @@ func Run(ew types.EffectiveWorkload) error { } dst := ps[1] - args = append(args, fmt.Sprintf("-v=%s:%s:z", src, dst)) + args = append(args, fmt.Sprintf("-v=%s:%s", src, dst)) } args = append(args, wl.Image) args = append(args, wl.Command) args = append(args, wl.Args...) - slog.Debug(fmt.Sprintf("exec: %s", files.ContainerRunnerBinary), "args", args) - cmd := execabs.Command(files.ContainerRunnerBinary, args...) //nolint + slog.Debug(fmt.Sprintf("exec: %s", runnerBinary), "args", args) + cmd := execabs.Command(runnerBinary, args...) cmd.Stderr = os.Stderr cmd.Stdin = os.Stdin @@ -317,8 +260,8 @@ func Run(ew types.EffectiveWorkload) error { func getHomeDir(image string) (string, error) { args := []string{"run", "--rm", image, "ls", "/home"} - slog.Debug(files.ContainerRunnerBinary + " " + strings.Join(args, " ")) - cmd := execabs.Command(files.ContainerRunnerBinary, args...) //nolint + slog.Debug(runnerBinary + " " + strings.Join(args, " ")) + cmd := execabs.Command(runnerBinary, args...) out, err := cmd.Output() if err != nil { @@ -330,9 +273,9 @@ func getHomeDir(image string) (string, error) { func hostDbusParams() []string { return []string{ - "-v=/run/dbus/system_bus_socket:/run/dbus/system_bus_socket:z", - "-v=/var/lib/dbus:/var/lib/dbus:z", - "-v=/usr/share/dbus-1:/usr/share/dbus-1:z", + "-v=/run/dbus/system_bus_socket:/run/dbus/system_bus_socket", + "-v=/var/lib/dbus:/var/lib/dbus", + "-v=/usr/share/dbus-1:/usr/share/dbus-1", // At the moment we are mapping /run/user/1000 when // the host Dbus is being used. Therefore, there is no // point in mounting descending dirs. @@ -347,7 +290,7 @@ func hostDbusParams() []string { func cameraParams() []string { params := []string{ - // "--group-add=video", + "--group-add=video", } vds, _ := filepath.Glob("/dev/video*") @@ -361,8 +304,8 @@ func cameraParams() []string { func audioParams() []string { return []string{ // TODO: For Bluetooth (Apple AirPods) you may require /run/user/1000 shared via VarRunUser - "-v=/run/user/1000/pipewire-0:/run/user/1000/pipewire-0:z", + "-v=/run/user/1000/pipewire-0:/run/user/1000/pipewire-0", "--device=/dev/snd", - // "--group-add=audio", + "--group-add=audio", } } diff --git a/internal/runners/firecracker/deps.go b/internal/runners/firecracker/deps.go index 0fef097..c816819 100644 --- a/internal/runners/firecracker/deps.go +++ b/internal/runners/firecracker/deps.go @@ -74,7 +74,8 @@ func ensureDependencies(img string) error { func setupTaps(img string) error { slog.Info("setting up taps") - cmd := execabs.Command(files.ContainerRunnerBinary, //nolint + bin := files.ContainerRunnerBinary("") + cmd := execabs.Command(bin, "run", "--rm", "--privileged", "--network", "host", img, diff --git a/internal/runners/firecracker/run.go b/internal/runners/firecracker/run.go index f75653f..3d1861b 100644 --- a/internal/runners/firecracker/run.go +++ b/internal/runners/firecracker/run.go @@ -23,7 +23,8 @@ type configParams struct { func createRootFs(dir, img string) (string, error) { slog.Info("creating root fs") rootfs := filepath.Join(dir, "roofs.ext4") - cmd := execabs.Command(files.ContainerRunnerBinary, //nolint + bin := files.ContainerRunnerBinary("") + cmd := execabs.Command(bin, "run", "--rm", "--privileged", "-v", "/tmp/:/tmp/", img, diff --git a/internal/runners/podman/run.go b/internal/runners/podman/run.go new file mode 100644 index 0000000..4b5eaf1 --- /dev/null +++ b/internal/runners/podman/run.go @@ -0,0 +1,311 @@ +package podman + +import ( + "bytes" + "fmt" + "log/slog" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/qubesome/cli/internal/env" + "github.com/qubesome/cli/internal/files" + "github.com/qubesome/cli/internal/runners/util/container" + "github.com/qubesome/cli/internal/runners/util/mime" + "github.com/qubesome/cli/internal/runners/util/usb" + "github.com/qubesome/cli/internal/types" + "github.com/qubesome/cli/internal/util/dbus" + "github.com/qubesome/cli/internal/util/gpu" + "golang.org/x/sys/execabs" +) + +var ( + runnerBinary = files.ContainerRunnerBinary("podman") +) + +func Run(ew types.EffectiveWorkload) error { + if err := ew.Validate(); err != nil { + return err + } + + wl := ew.Workload + if wl.SingleInstance { + if id, ok := container.ID(runnerBinary, ew.Name); ok { + return container.Exec(runnerBinary, id, ew) + } + } + + ndevs, err := usb.NamedDevices(wl.HostAccess.USBDevices) + if err != nil { + return fmt.Errorf("failed to get named devices: %w", err) + } + + if wl.HostAccess.Gpus != "" { + if !gpu.Supported() { + wl.HostAccess.Gpus = "" + dbus.NotifyOrLog("qubesome error", "GPU support was not detected, disabling it for qubesome") + } + } + + var paths []string + // Mount localtime into container. This file may be a symlink, if so, + // mount the underlying file as well. + file := "/etc/localtime" + if _, err := os.Stat(file); err == nil { + paths = append(paths, fmt.Sprintf("-v=%[1]s:%[1]s:ro", file)) + + if target, err := os.Readlink(file); err == nil { + paths = append(paths, fmt.Sprintf("-v=%[1]s:%[1]s:ro", target)) + } + } + + args := []string{ + "run", + "--rm", + "-d", + "--security-opt=seccomp=unconfined", + "--security-opt=no-new-privileges=true", + "--group-add=keep-groups", + } + + args = append(args, "--userns=keep-id") + + if ew.Workload.User != nil { + args = append(args, fmt.Sprintf("--user=%d", *ew.Workload.User)) + } + + // Single instance workloads share the name of the workload, which + // must be unique. Otherwise, let docker assign a new name. + if wl.SingleInstance { + args = append(args, fmt.Sprintf("--name=%s", ew.Name)) + } + + if wl.HostAccess.Gpus != "" { + args = append(args, "--gpus", wl.HostAccess.Gpus) + args = append(args, "--runtime=nvidia") + } + + for _, cap := range wl.HostAccess.CapsAdd { + args = append(args, "--cap-add="+cap) + } + for _, dev := range wl.HostAccess.Devices { + args = append(args, "--device="+dev) + } + + // TODO: Split + if wl.HostAccess.Microphone || wl.HostAccess.Speakers { + args = append(args, audioParams()...) + } + if wl.HostAccess.Camera { + args = append(args, cameraParams()...) + } + + display := ew.Profile.Display + if strings.EqualFold(os.Getenv("XDG_SESSION_TYPE"), "wayland") { //nolint + fmt.Println("WARN: running qubesome in Wayland (experimental)") + display = 0 + + xdgRuntimeDir := os.Getenv("XDG_RUNTIME_DIR") + if xdgRuntimeDir == "" { + uid := os.Getuid() + if uid < 1000 { + return fmt.Errorf("qubesome does not support running under privileged users") + } + xdgRuntimeDir = "/run/user/" + strconv.Itoa(uid) + } + + // TODO: Investigate ways to avoid sharing /run/user/1000 on Wayland. + args = append(args, "-e XDG_RUNTIME_DIR") + args = append(args, "-v="+xdgRuntimeDir+":/run/user/1000") + } else { + if wl.HostAccess.Dbus || wl.HostAccess.Bluetooth || wl.HostAccess.VarRunUser { + args = append(args, "-v=/run/user/1000:/run/user/1000:z") + } + + userDir, err := files.IsolatedRunUserPath(ew.Profile.Name) + if err != nil { + return fmt.Errorf("failed to get isolated /user path: %w", err) + } + paths = append(paths, fmt.Sprintf("-v=%s:/dev/shm", filepath.Join(userDir, "shm"))) + if wl.HostAccess.Dbus || wl.HostAccess.Bluetooth || wl.HostAccess.VarRunUser { + args = append(args, hostDbusParams()...) + } else { + paths = append(paths, fmt.Sprintf("-v=%s:/run/user/1000:z", userDir)) + + machineIDPath := filepath.Join(files.ProfileDir(ew.Profile.Name), "machine-id") + paths = append(paths, fmt.Sprintf("-v=%s:/etc/machine-id:ro", machineIDPath)) + } + } + + args = append(args, paths...) + args = append(args, "--device=/dev/dri") + + // Display is used for all qubesome applications. + args = append(args, fmt.Sprintf("-e=DISPLAY=:%d", display)) + pp, err := files.ClientCookiePath(ew.Profile.Name) + if err != nil { + return err + } + args = append(args, fmt.Sprintf("-v=%s:/tmp/.Xauthority:ro", pp)) + args = append(args, "-e=XAUTHORITY=/tmp/.Xauthority") + args = append(args, fmt.Sprintf("-v=/tmp/.X11-unix/X%[1]d:/tmp/.X11-unix/X%[1]d", display)) + args = append(args, fmt.Sprintf("-e=QUBESOME_PROFILE=%s", ew.Profile.Name)) + + if ew.Profile.Timezone != "" { + args = append(args, "-e=TZ="+ew.Profile.Timezone) + } + + args = append(args, "--init") + // Link to the profiles IPC. + // args = append(args, fmt.Sprintf("--ipc=container:qubesome-%s", ew.Profile.Name)) + + //nolint + if wl.HostAccess.Mime { + pdir := files.ProfileDir(ew.Profile.Name) + homedir, err := getHomeDir(wl.Image) + if err != nil { + return err + } + + srcMimeList := filepath.Join(pdir, "mimeapps.list") + dstMimeList := filepath.Join(homedir, ".local", "share", "applications", "mimeapps.list") + err = os.WriteFile(srcMimeList, []byte(mime.MimesList), files.FileMode) + if err != nil { + return fmt.Errorf("failed to write mimeapps.list: %w", err) + } + + args = append(args, fmt.Sprintf("-v=%s:%s:ro", srcMimeList, dstMimeList)) + srcHandler := filepath.Join(pdir, "mime-handler.desktop") + dstHandler := filepath.Join(homedir, ".local", "share", "applications", "qubesome-default-handler.desktop") + + err = os.WriteFile(srcHandler, []byte(mime.DefaultMimeHandler), files.FileMode) + if err != nil { + return fmt.Errorf("failed to write mime-handler.desktop: %w", err) + } + args = append(args, fmt.Sprintf("-v=%s:%s:ro", srcHandler, dstHandler)) + + qubesomeBin, err := os.Executable() + if err != nil { + return err + } + + // Mount access to the qubesome binary. + args = append(args, fmt.Sprintf("-v=%s:%s:ro", qubesomeBin, "/usr/local/bin/qubesome")) + + socket, err := files.SocketPath(ew.Profile.Name) + if err != nil { + return err + } + + // Mount qube socket so that it can send commands from container to host. + args = append(args, fmt.Sprintf("-v=%s:/tmp/qube.sock:ro", socket)) + } + + if ew.Profile.DNS != "" { + args = append(args, "--dns", ew.Profile.DNS) + } + + // Set hostname to be the same as the container name + args = append(args, "-h", ew.Name) + + if wl.HostAccess.Network != "" { + args = append(args, fmt.Sprintf("--network=%s", wl.HostAccess.Network)) + } + + if wl.HostAccess.Privileged { + args = append(args, "--privileged") + } + + if len(ndevs) > 0 { + // Some USB devices, such as YubiKeys, requires --device pointing to both + // the hidraw device as well as the respective /dev/usb. The latter by + // itself would enable things such as "ykinfo -a". However, use of SK keys + // fails with operation not permitted unless /dev:/dev is also mapped. + args = append(args, "-v=/dev/:/dev/") + + for _, ndev := range ndevs { + args = append(args, fmt.Sprintf("--device=%s", ndev)) + } + } + + for _, p := range wl.HostAccess.Paths { + ps := strings.SplitN(p, ":", 2) + if len(ps) != 2 { + slog.Warn("failed to mount path", "path", p) + continue + } + + src := env.Expand(ps[0]) + if _, err := os.Stat(src); err != nil { + slog.Warn("failed to mount path", "path", src, "error", err) + continue + } + + dst := ps[1] + args = append(args, fmt.Sprintf("-v=%s:%s:z", src, dst)) + } + + args = append(args, wl.Image) + args = append(args, wl.Command) + args = append(args, wl.Args...) + + slog.Debug(fmt.Sprintf("exec: %s", runnerBinary), "args", args) + cmd := execabs.Command(runnerBinary, args...) + + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + + return cmd.Run() +} + +func getHomeDir(image string) (string, error) { + args := []string{"run", "--rm", image, "ls", "/home"} + + slog.Debug(runnerBinary + " " + strings.Join(args, " ")) + cmd := execabs.Command(runnerBinary, args...) + + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to get home dir: %w", err) + } + + return filepath.Join("/home", string(bytes.TrimSpace(out))), nil +} + +func hostDbusParams() []string { + return []string{ + "-v=/run/dbus/system_bus_socket:/run/dbus/system_bus_socket:z", + "-v=/var/lib/dbus:/var/lib/dbus:z", + "-v=/usr/share/dbus-1:/usr/share/dbus-1:z", + // At the moment we are mapping /run/user/1000 when + // the host Dbus is being used. Therefore, there is no + // point in mounting descending dirs. + // "-v=/run/user/1000/bus:/run/user/1000/bus", + // "-v=/run/user/1000/dbus-1:/run/user/1000/dbus-1", + "-v=/etc/machine-id:/etc/machine-id:ro", + "-e=DBUS_SESSION_BUS_ADDRESS", + "-e=XDG_RUNTIME_DIR", + "-e=XDG_SESSION_ID", + } +} + +func cameraParams() []string { + params := []string{} + + vds, _ := filepath.Glob("/dev/video*") + for _, dev := range vds { + params = append(params, fmt.Sprintf("--device=%s", dev)) + } + + return params +} + +func audioParams() []string { + return []string{ + // TODO: For Bluetooth (Apple AirPods) you may require /run/user/1000 shared via VarRunUser + "-v=/run/user/1000/pipewire-0:/run/user/1000/pipewire-0:z", + "--device=/dev/snd", + } +} diff --git a/internal/runners/util/container/container.go b/internal/runners/util/container/container.go new file mode 100644 index 0000000..f4f547a --- /dev/null +++ b/internal/runners/util/container/container.go @@ -0,0 +1,42 @@ +package container + +import ( + "bytes" + "fmt" + "log/slog" + "strings" + + "github.com/qubesome/cli/internal/types" + "golang.org/x/sys/execabs" +) + +func ID(bin, name string) (string, bool) { + args := fmt.Sprintf("ps -a -q -f name=%s", name) + cmd := execabs.Command(bin, //nolint:gosec + strings.Split(args, " ")...) + + out, err := cmd.Output() + id := string(bytes.TrimSuffix(out, []byte("\n"))) + + if err != nil || id == "" { + return "", false + } + + return id, true +} + +func Exec(bin, id string, ew types.EffectiveWorkload) error { + args := []string{"exec", "--detach", id, ew.Workload.Command} + args = append(args, ew.Workload.Args...) + + slog.Debug(bin+" exec", "container-id", id, "cmd", ew.Workload.Command, "args", ew.Workload.Args) + cmd := execabs.Command(bin, args...) + + return cmd.Run() +} + +func Running(bin, name string) bool { + _, running := ID(bin, name) + + return running +} diff --git a/internal/runners/util/mime/mime.go b/internal/runners/util/mime/mime.go new file mode 100644 index 0000000..4bff345 --- /dev/null +++ b/internal/runners/util/mime/mime.go @@ -0,0 +1,35 @@ +package mime + +const ( + DefaultMimeHandler = `[Desktop Entry] +Version=1.0 +Type=Application +Name=qubesome +Exec=/usr/local/bin/qubesome xdg-open %u +StartupNotify=false +` + MimesList = `[Default Applications] +x-scheme-handler/slack=qubesome-default-handler.desktop; + +application/x-yaml=qubesome-default-handler.desktop; +text/english=qubesome-default-handler.desktop; +text/html=qubesome-default-handler.desktop; +text/plain=qubesome-default-handler.desktop; +text/x-c=qubesome-default-handler.desktop; +text/x-c++=qubesome-default-handler.desktop; +text/x-makefile=qubesome-default-handler.desktop; +text/xml=qubesome-default-handler.desktop; +x-www-browser=qubesome-default-handler.desktop; + +x-scheme-handler/http=qubesome-default-handler.desktop; +x-scheme-handler/https=qubesome-default-handler.desktop; +x-scheme-handler/about=qubesome-default-handler.desktop; +x-scheme-handler/unknown=qubesome-default-handler.desktop; + +[Removed Associations] +x-scheme-handler/slack=slack.desktop; +x-scheme-handler/http=firefox.desktop; +x-scheme-handler/https=firefox.desktop; +x-scheme-handler/snap=snap-handle-link.desktop; +` +) diff --git a/internal/runners/docker/devices.go b/internal/runners/util/usb/devices.go similarity index 96% rename from internal/runners/docker/devices.go rename to internal/runners/util/usb/devices.go index e41aa46..2107bbe 100644 --- a/internal/runners/docker/devices.go +++ b/internal/runners/util/usb/devices.go @@ -1,4 +1,4 @@ -package docker +package usb import ( "bufio" @@ -11,7 +11,7 @@ import ( "strings" ) -func namedDevices(names []string) ([]string, error) { +func NamedDevices(names []string) ([]string, error) { devs := []string{} products, err := filepath.Glob("/sys/bus/usb/devices/*/product") diff --git a/internal/types/config.go b/internal/types/config.go index 6e1f7fa..cf303dd 100644 --- a/internal/types/config.go +++ b/internal/types/config.go @@ -17,7 +17,7 @@ var ( nameRegex = regexp.MustCompile(`^[a-zA-Z0-9\-]+$`) imageRegex = regexp.MustCompile(`^(?:(?:[a-z0-9]+(?:[._-][a-z0-9]+)*)+\/)?(?:[a-z0-9]+(?:[._-][a-z0-9]+)*)+(?:[:/][a-z0-9]+(?:[._-][a-z0-9]+)*)+$`) ipRegex = regexp.MustCompile(`^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$`) - runnerRegex = regexp.MustCompile(`^(docker|firecracker)$`) + runnerRegex = regexp.MustCompile(`^(docker|podman|firecracker)$`) externalPathRegex = regexp.MustCompile(`^[a-zA-Z0-9\-]+:/[^:]+:/[^:]+$`) pathRegex = regexp.MustCompile(`^(\${[a-zA-Z0-9\-]+}){0,1}/[^:]+:/[^:]+(:ro){0,1}$`) )