Skip to content

Commit

Permalink
feat: split out agent executable
Browse files Browse the repository at this point in the history
This allows for a smaller agent executable
that uses less (locked) memory.

Refactor the package `pago` into a number of packages
to enable separating the agent.

Stop locking memory in the main pago executable
when contacting the agent.
It potentially confuses the user's security expectations
because operations without an agent don't do it.

v0.10.0
  • Loading branch information
dbohdan committed Feb 20, 2025
1 parent 5d046f4 commit 3b6b92b
Show file tree
Hide file tree
Showing 15 changed files with 589 additions and 398 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/attic/
/cmd/pago/pago
/cmd/pago-agent/pago-agent
/dist/
/help
/pago
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ Once Go is installed on your system, run the following command:

```
go install dbohdan.com/pago/cmd/pago@latest
go install dbohdan.com/pago/cmd/pago-agent@latest
```

Shell completion files for Bash and fish are available in [`completions/`](completions/).
Expand Down Expand Up @@ -185,6 +186,11 @@ pago show foo/bar
# Start manually.
pago agent start

# By default, the agent locks its memory to prevent secrets from being written to swap.
# You may need to run `ulimit -l 100000` to let it lock enough memory.
# Alternatively, you can disable this feature with the flag `--no-mlock`.
pago agent start --no-mlock

# Run without an agent.
pago -s '' show foo/bar

Expand All @@ -194,6 +200,8 @@ pago agent stop

### Environment variables

- `PAGO_AGENT`:
The agent executable path
- `PAGO_CLIP`:
The command to use to copy the password to the clipboard.
The default differs by platform.
Expand All @@ -208,6 +216,9 @@ pago agent stop
Whether to use Git
- `PAGO_LENGTH`:
The default length of random passwords
- `PAGO_MLOCK`:
Whether the agent should lock its memory using [mlockall(2)](https://pubs.opengroup.org/onlinepubs/9799919799/functions/mlockall.html) to prevent secrets from being written to swap.
`0` to disable.
- `PAGO_PATTERN`:
The default character pattern (regular expression) for random passwords
- `PAGO_SOCK`:
Expand Down
25 changes: 19 additions & 6 deletions Taskfile.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
version: '3'

vars:
cmd_dir: cmd/pago
ext: '{{if eq OS "windows"}}.exe{{end}}'
pago_agent_dir: cmd/pago-agent
pago_dir: cmd/pago

env:
CGO_ENABLED: 0
Expand All @@ -21,34 +22,46 @@ tasks:
desc: 'Build all components'
deps:
- build_pago
- build_pago_agent

build_pago:
dir: '{{.cmd_dir}}'
dir: '{{.pago_dir}}'
desc: 'Build the pago binary'
cmds:
- cmd: go build -trimpath
sources:
- '*.go'
- '../../*.go'
- '../**/*.go'
generates:
- pago

build_pago_agent:
dir: '{{.pago_agent_dir}}'
desc: 'Build the pago agent binary'
cmds:
- cmd: go build -trimpath
sources:
- '*.go'
- '../**/*.go'
generates:
- pago-agent

clean:
dir: '{{.cmd_dir}}'
desc: 'Clean up binaries'
cmds:
- rm -f pago pago.exe
- rm -f {{.pago_dir}}/pago{{.ext}} {{.pago_agent_dir}}/pago-agent{{.ext}}

release:
desc: 'Prepare a release'
deps:
- build_pago
cmds:
- VERSION=$({{.cmd_dir}}/pago{{.ext}} version) go run script/release.go
- VERSION=$({{.pago_dir}}/pago{{.ext}} version) go run script/release.go

test:
desc: 'Run tests'
deps:
- build_pago
- build_pago_agent
cmds:
- go test ./...
140 changes: 29 additions & 111 deletions cmd/pago/agent.go → agent/agent.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
//go:build !windows

// pago - a command-line password manager.
//
// License: MIT.
// See the file LICENSE.

package main
package agent

import (
"bytes"
Expand All @@ -19,136 +17,56 @@ import (
"syscall"

"dbohdan.com/pago"
"dbohdan.com/pago/crypto"

"filippo.io/age"
"github.com/tidwall/redcon"
"github.com/valkey-io/valkey-go"
)

var defaultSocket = filepath.Join(defaultCacheDir, agentSocketPath)

func (cmd *RestartCmd) Run(config *Config) error {
if config.Verbose {
printRepr(cmd)
}

if err := pago.LockMemory(); err != nil {
return err
}

_, _ = messageAgent(config.Socket, "SHUTDOWN")

identitiesText, err := decryptIdentities(config.Identities)
if err != nil {
return err
}

return startAgentProcess(config.Socket, identitiesText)
}

func (cmd *RunCmd) Run(config *Config) error {
if config.Verbose {
printRepr(cmd)
}

if err := pago.LockMemory(); err != nil {
return err
}

return runAgent(config.Socket)
}

func (cmd *StartCmd) Run(config *Config) error {
if config.Verbose {
printRepr(cmd)
}

if err := pago.LockMemory(); err != nil {
return err
}

if err := pingAgent(config.Socket); err == nil {
return fmt.Errorf("found agent responding on socket")
}

identitiesText, err := decryptIdentities(config.Identities)
if err != nil {
return err
}

return startAgentProcess(config.Socket, identitiesText)
}

func (cmd *StatusCmd) Run(config *Config) error {
if config.Verbose {
printRepr(cmd)
}

err := pingAgent(config.Socket)
if err == nil {
fmt.Println("Ping successful")
os.Exit(0)
} else {
fmt.Println("Failed to ping agent")
os.Exit(1)
}

return nil
}

func (cmd *StopCmd) Run(config *Config) error {
if config.Verbose {
printRepr(cmd)
}

_, err := messageAgent(config.Socket, "SHUTDOWN")
return err
}

func startAgentProcess(agentSocket, identitiesText string) error {
// The agent is the same executable.
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to get executable path: %v", err)
func StartProcess(executable string, mlock bool, socket, identitiesText string) error {
mlockFlag := "--mlock"
if !mlock {
mlockFlag = "--no-mlock"
}

cmd := exec.Command(exe, "agent", "run", "--socket", agentSocket)
cmd := exec.Command(executable, "run", mlockFlag, "--socket", socket)

// Start the process in the background.
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start agent: %v", err)
}

_ = os.Remove(agentSocket)
_ = os.Remove(socket)
// Don't wait for the process to finish.
go func() {
_ = cmd.Wait()
}()

if err := waitUntilAvailable(agentSocket, waitForSocket); err != nil {
if err := pago.WaitUntilAvailable(socket, pago.WaitForSocket); err != nil {
return fmt.Errorf("timed out waiting for agent socket")
}

_, err = messageAgent(agentSocket, "IDENTITIES", identitiesText)
_, err := Message(socket, "IDENTITIES", identitiesText)
return err
}

func runAgent(agentSocket string) error {
if err := pingAgent(agentSocket); err == nil {
func Run(socket string) error {
if err := Ping(socket); err == nil {
return fmt.Errorf("found agent responding on socket")
}

socketDir := filepath.Dir(agentSocket)
if err := os.MkdirAll(socketDir, dirPerms); err != nil {
socketDir := filepath.Dir(socket)
if err := os.MkdirAll(socketDir, pago.DirPerms); err != nil {
return fmt.Errorf("failed to create socket directory: %v", err)
}

os.Remove(agentSocket)
os.Remove(socket)

identities := []age.Identity{}
srv := redcon.NewServerNetwork(
"unix",
agentSocket,
socket,
func(conn redcon.Conn, cmd redcon.Command) {
switch strings.ToUpper(string(cmd.Args[0])) {

Expand All @@ -162,7 +80,7 @@ func runAgent(agentSocket string) error {

// Decrypt the data.
reader := bytes.NewReader(encryptedData)
decryptedReader, err := wrapDecrypt(reader, identities...)
decryptedReader, err := crypto.WrapDecrypt(reader, identities...)
if err != nil {
conn.WriteError("ERR failed to decrypt: " + err.Error())
return
Expand Down Expand Up @@ -218,22 +136,22 @@ func runAgent(agentSocket string) error {
return
}

if err := os.Chmod(agentSocket, filePerms); err != nil {
exitWithError("failed to set permissions on agent socket: %v", err)
if err := os.Chmod(socket, pago.FilePerms); err != nil {
pago.ExitWithError("failed to set permissions on agent socket: %v", err)
}
}()

return srv.ListenServeAndSignal(errc)
}

func messageAgent(agentSocket string, args ...string) (string, error) {
func Message(socket string, args ...string) (string, error) {
// Check socket security.
if err := checkSocketSecurity(agentSocket); err != nil {
if err := checkSocketSecurity(socket); err != nil {
return "", fmt.Errorf("socket security check failed: %v", err)
}

// Connect to the server.
opts, err := valkey.ParseURL("unix://" + agentSocket)
opts, err := valkey.ParseURL("unix://" + socket)
if err != nil {
return "", fmt.Errorf("failed to parse socket URL: %v", err)
}
Expand Down Expand Up @@ -269,23 +187,23 @@ func messageAgent(agentSocket string, args ...string) (string, error) {
return string(result), nil
}

func pingAgent(agentSocket string) error {
_, err := messageAgent(agentSocket)
func Ping(socket string) error {
_, err := Message(socket)
return err
}

func decryptWithAgent(agentSocket string, data []byte) (string, error) {
return messageAgent(agentSocket, "DECRYPT", valkey.BinaryString(data))
func Decrypt(socket string, data []byte) (string, error) {
return Message(socket, "DECRYPT", valkey.BinaryString(data))
}

func checkSocketSecurity(agentSocket string) error {
info, err := os.Stat(agentSocket)
func checkSocketSecurity(socket string) error {
info, err := os.Stat(socket)
if err != nil {
return fmt.Errorf("failed to stat socket: %v", err)
}

// Check socket permissions.
if info.Mode().Perm() != filePerms {
if info.Mode().Perm() != pago.FilePerms {
return fmt.Errorf("incorrect socket permissions: %v", info.Mode().Perm())
}

Expand Down
Loading

0 comments on commit 3b6b92b

Please sign in to comment.