Skip to content

Commit

Permalink
Better permissions request (#16)
Browse files Browse the repository at this point in the history
* add more app metadata fields
* add pkexec policy template
* add command logger
* embed and write pkexec policy config when run as root
* add option to delay menu request for permission and pass desktop env as cmdline flag and add out/err logging
* add missing docs
  • Loading branch information
bradrf authored Jun 28, 2022
1 parent bea49ea commit c5448b6
Show file tree
Hide file tree
Showing 10 changed files with 258 additions and 30 deletions.
3 changes: 2 additions & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
"extensions": [
"golang.Go",
"redhat.vscode-yaml",
"esbenp.prettier-vscode"
"esbenp.prettier-vscode",
"redhat.vscode-xml"
],

// Use 'forwardPorts' to make a list of ports inside the container available locally.
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,5 @@ jobs:
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
GITHUB_ACTOR: ${{ github.actor }}
GITHUB_JOB_NAME: "${{ steps.release.outputs.id }}: ${{ steps.release.outputs.url }}"
GITHUB_JOB_NAME: "${{ steps.release.outputs.url }}"
GITHUB_JOB_STATUS: ${{ job.status }}
3 changes: 3 additions & 0 deletions buildinfo/app.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
name: huekeys
reverse_dns: com.bitpony.huekeys
url: https://github.com/BitPonyLLC/huekeys
vendor: BitPony, LLC
description: Control the keyboard backlight on System76 laptops
full_description: >
Huekeys is a fun application that makes it easy to adjust your System76
Expand Down
15 changes: 14 additions & 1 deletion buildinfo/buildinfo.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package buildinfo
import (
_ "embed"
"fmt"
"os"
"time"

"github.com/rs/zerolog/log"
Expand All @@ -13,7 +14,12 @@ import (
type AppInfo struct {
buildInfo

ExePath string `yaml:"-"`

Name string `yaml:"name"`
URL string `yaml:"url"`
ReverseDNS string `yaml:"reverse_dns"`
Vendor string `yaml:"vendor"`
Description string `yaml:"description"`
FullDescription string `yaml:"full_description"`
}
Expand All @@ -36,7 +42,14 @@ var app []byte
var build []byte

func init() {
err := yaml.Unmarshal(app, &App)
var err error

App.ExePath, err = os.Executable()
if err != nil {
log.Fatal().Err(err).Msg("unable to determine executable pathname")
}

err = yaml.Unmarshal(app, &App)
if err != nil {
log.Fatal().Err(err).Msg("unable to parse embedded app info")
}
Expand Down
51 changes: 40 additions & 11 deletions cmd/menu.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (

var patternName string
var menuPidPath *pidpath.PidPath
var delay = time.Duration(0)
var restarting = false

func init() {
Expand All @@ -33,6 +34,8 @@ func init() {
menuCmd.Flags().String("pidpath", defaultPidPath, "pathname of the menu pidfile")
viper.BindPFlag("menu.pidpath", menuCmd.Flags().Lookup("pidpath"))

menuCmd.Flags().DurationVar(&delay, "delay", delay, "delay before asking for sudo permission")

rootCmd.AddCommand(menuCmd)
}

Expand Down Expand Up @@ -114,11 +117,14 @@ func ensureWaitRunning(cmd *cobra.Command) error {
return nil
}

exe, err := os.Executable()
if err != nil {
return fmt.Errorf("unable to determine executable pathname: %w", err)
if delay > time.Second {
log.Debug().Dur("delay", delay).Msg("waiting")
}

// this is useful when launching at start of X session to let the
// windowing env get set up
time.Sleep(delay)

dpEnv, err := patterns.DesktopPatternEnv()
if err != nil {
if util.IsTTY(os.Stderr) {
Expand All @@ -141,27 +147,50 @@ func ensureWaitRunning(cmd *cobra.Command) error {
execArgs = []string{"--user", "root"}
}

execArgs = append(execArgs, buildinfo.App.ExePath)

config := viper.GetViper().ConfigFileUsed()
if config != "" {
config = " --config " + config
execArgs = append(execArgs, "--config", config)
}

// use sh exec to let parent processes exit
hkCmd := fmt.Sprint("export ", dpEnv, "; exec ", exe, config, " run wait &")
execArgs = append(execArgs, "sh", "-c", hkCmd)
execArgs = append(execArgs, "run", "wait", "--env", dpEnv)

execStr := fmt.Sprint(execName, " ", strings.Join(execArgs, " "))
log.Debug().Str("cmd", execStr).Msg("")

err = exec.Command(execName, execArgs...).Run()
subCmd := exec.Command(execName, execArgs...)

// send out/err to logs...
subLogger := log.With().Str("cmd", execName).Logger()
subCmd.Stdout = &util.CommandLogger{Log: func(msg string) { subLogger.Info().Msg(msg) }}
subCmd.Stderr = &util.CommandLogger{Log: func(msg string) { subLogger.Error().Msg(msg) }}

err = subCmd.Start()
if err != nil {
return fmt.Errorf("unable to run %s: %w", execStr, err)
}

// wait a second for socket to be ready...
var subCmdErr error

go func() {
stat, err := subCmd.Process.Wait()
if err == nil {
subCmdErr = fmt.Errorf("wait process exited: %s", stat)
} else {
subCmdErr = fmt.Errorf("unable to stat the background wait process: %w", err)
}
}()

// wait for user to grant permission to run...
const delay = 50 * time.Millisecond
sockPath := waitSockPath()
for i := 0; i < 10; i += 1 {
time.Sleep(50 * time.Millisecond)
for timeout := time.Minute; timeout > 0; timeout -= delay {
if subCmdErr != nil {
return subCmdErr
}

time.Sleep(delay)
_, err := os.Stat(sockPath)
if err == nil {
return nil
Expand Down
10 changes: 10 additions & 0 deletions cmd/pattern.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ func init() {
addPatternCmd("monitor the desktop picture and change the keyboard color to match", patterns.Get("desktop"))

//----------------------------------------
desktopEnv := ""
waitCmd := addPatternCmd("wait for remote commands", patterns.Get("wait"))
// wait needs to manage the pidpath and start the IPC server...
waitCmd.PreRunE = func(cmd *cobra.Command, args []string) error {
Expand All @@ -43,6 +44,12 @@ func init() {
if err := waitPidPath.CheckAndSet(); err != nil {
return fail(11, err)
}
if desktopEnv != "" {
desktopPattern := patterns.Get("desktop").(*patterns.DesktopPattern)
if err := desktopPattern.SetEnv(desktopEnv); err != nil {
return err
}
}
return ipcServer.Start(cmd.Context(), &log.Logger, waitSockPath(), rootCmd)
}
waitCmd.PostRun = func(cmd *cobra.Command, args []string) {
Expand All @@ -59,6 +66,9 @@ func init() {
waitCmd.Flags().String("sockpath", defaultSockPath, "pathname of the wait sockfile")
viper.BindPFlag("wait.sockpath", waitCmd.Flags().Lookup("sockpath"))

waitCmd.Flags().StringVar(&desktopEnv, "env", desktopEnv, "environment to set for desktop pattern")
waitCmd.Flags().MarkHidden("env") // only used by menu

//----------------------------------------
watchPattern := patterns.Get("watch").(*patterns.WatchPattern)
watchCmd := addPatternCmd("watch and report color, brightness, and pattern changes", watchPattern)
Expand Down
20 changes: 20 additions & 0 deletions cmd/pkexec_policy.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN" "https://specifications.freedesktop.org/PolicyKit/1.0/policyconfig.dtd">
<policyconfig>

<vendor>{{ .Vendor }}</vendor>
<vendor_url>{{ .URL }}</vendor_url>

<action id="{{ .ReverseDNS }}">
<description>Access System76 keyboard color and brightness devices.</description>
<message>{{ .Name }} needs permission to change your keyboard brightness and colors</message>
<icon_name>gnome-settings-theme</icon_name>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>no</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">{{ .ExePath }}</annotate>
</action>

</policyconfig>
56 changes: 55 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"context"
_ "embed"
"errors"
"fmt"
"io"
Expand All @@ -10,6 +11,7 @@ import (
"os/signal"
"path/filepath"
"syscall"
"text/template"

"github.com/BitPonyLLC/huekeys/buildinfo"
"github.com/BitPonyLLC/huekeys/pkg/ipc"
Expand Down Expand Up @@ -78,6 +80,10 @@ func Execute() int {

const logDstLabel = "log-dst"
const minimalTimeFormat = "15:04:05.000"
const policyConfigPath = "/usr/share/polkit-1/actions"

//go:embed pkexec_policy.xml
var policyConfigTemplate string

var failureCode = 1
var initialized = false
Expand Down Expand Up @@ -143,7 +149,7 @@ func atStart(cmd *cobra.Command, _ []string) error {
}

log.Debug().Str("file", viper.ConfigFileUsed()).Msg("config")
return nil
return checkPolicyConfig()
}

func atExit() {
Expand Down Expand Up @@ -223,6 +229,54 @@ func setupLogging(cmd *cobra.Command, logDst string) error {
return nil
}

func checkPolicyConfig() error {
if os.Getuid() != 0 {
return nil
}

policyPath := filepath.Join(policyConfigPath, buildinfo.App.ReverseDNS+".policy")
cfgF, err := os.OpenFile(policyPath, os.O_RDWR|os.O_CREATE, 0644)
if err != nil {
return fmt.Errorf("unable to open %s: %w", policyPath, err)
}
defer cfgF.Close()

policyStat, err := cfgF.Stat()
if err != nil {
return fmt.Errorf("unable to stat %s: %w", policyPath, err)
}

if policyStat.Size() > 0 {
exeStat, err := os.Stat(buildinfo.App.ExePath)
if err != nil {
return fmt.Errorf("unable to stat %s: %w", buildinfo.App.ExePath, err)
}

if exeStat.ModTime().Before(policyStat.ModTime()) {
log.Debug().Str("policy", policyPath).Msg("unchanged")
return nil
}
}

err = cfgF.Truncate(0)
if err != nil {
return fmt.Errorf("unable to truncate %s: %w", policyPath, err)
}

tmpl, err := template.New("policy-config").Parse(policyConfigTemplate)
if err != nil {
return fmt.Errorf("unable to parse policy template: %w", err)
}

err = tmpl.Execute(cfgF, buildinfo.App)
if err != nil {
return fmt.Errorf("unable to execute policy template: %w", err)
}

log.Debug().Str("policy", policyPath).Msg("updated")
return nil
}

func fail(code int, formatOrErr interface{}, args ...interface{}) error {
failureCode = code
if len(args) == 0 {
Expand Down
44 changes: 29 additions & 15 deletions pkg/patterns/desktop.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,34 @@ func DesktopPatternEnv() (string, error) {
return desktopPatternKey + "=" + sVal, err
}

// SetEnv is invoked when the DesktopPattern needs additional environment values
// to pass along to the gsettings process (e.g. to ensure it monitors the right
// user desktop).
func (p *DesktopPattern) SetEnv(env string) error {
p.env = &preservedEnv{}

if env == "" {
return nil
}

if strings.HasPrefix(env, desktopPatternKey) {
env = env[len(desktopPatternKey)+1:]
}

jVal, err := base64.RawStdEncoding.DecodeString(env)
if err != nil {
return fmt.Errorf("unable to decode %s (%s): %w", desktopPatternKey, env, err)
}

err = json.Unmarshal(jVal, p.env)
if err != nil {
return fmt.Errorf("unable to unmarshal %s (%s): %w", desktopPatternKey, env, err)
}

log.Debug().Interface("env", p.env).Msg("using")
return nil
}

//--------------------------------------------------------------------------------
// private

Expand All @@ -87,21 +115,7 @@ func init() {

func (p *DesktopPattern) run() error {
if p.env == nil {
p.env = &preservedEnv{}
dp := os.Getenv(desktopPatternKey)
if dp != "" {
jVal, err := base64.RawStdEncoding.DecodeString(dp)
if err != nil {
return fmt.Errorf("unable to decode %s (%s): %w", desktopPatternKey, dp, err)
}

err = json.Unmarshal(jVal, p.env)
if err != nil {
return fmt.Errorf("unable to unmarshal %s (%s): %w", desktopPatternKey, dp, err)
}

log.Debug().Interface("env", p.env).Msg("using")
}
p.SetEnv(os.Getenv(desktopPatternKey))
}

colorScheme, err := p.getDesktopSetting("interface", "color-scheme")
Expand Down
Loading

0 comments on commit c5448b6

Please sign in to comment.