Skip to content

Commit

Permalink
refactor: split out packages and 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.

Stop locking memory in the main pago executable
when contacting the agent.
It confuses 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 8137ec0
Show file tree
Hide file tree
Showing 15 changed files with 583 additions and 400 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
7 changes: 5 additions & 2 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 @@ -183,17 +184,19 @@ The agent keeps your identities in memory to avoid repeated password prompts.
pago show foo/bar

# Start manually.
pago agent start
pago-agent start

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

# Shut down.
pago agent stop
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 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 8137ec0

Please sign in to comment.