action

package
v0.54.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 6, 2026 License: Apache-2.0 Imports: 18 Imported by: 0

Documentation

Overview

Package action carries out a fix on this machine.

This is the most dangerous code in the agent, so it is the narrowest. One rule decides its whole shape:

The service never sends a command. It names an action from this catalog and
fills in that action's parameters. Anything else is refused.

So "ghostpsy is not a root agent that does whatever it is told" is true because of how this package is built, not because we promise it. Three walls stand between the cloud and a customer's server:

  1. The action type must be in the catalog here.
  2. Every parameter must match the exact shape the action declared for it.
  3. Every command must already be declared in internal/privexec, which is also what generates the sudo grant on the host.

A fourth wall belongs to the machine owner and beats all of the above: if actions are switched off locally, the agent obeys the machine, not the cloud.

Index

Constants

View Source
const (
	ModeDryRun = "dry_run"
	ModeRun    = "run"
)

Mode is what the service is asking for. The words match internal/solve.

View Source
const ProtectedFileName = "/etc/ghostpsy/protected-services"

ProtectedFileName lists services ghostpsy must never touch, one per line.

A finer tool than the switch above: somebody who is happy for ghostpsy to restart nginx may still want it nowhere near their database.

View Source
const SwitchFileName = "solve.disabled"

SwitchFileName is what a sysadmin creates to stop the agent changing anything.

Variables

This section is empty.

Functions

func FreeBytes

func FreeBytes(path string) (int64, error)

FreeBytes reports the space left on the filesystem holding path.

Bavail, not Bfree: Bfree counts blocks the kernel keeps for root, and writing into those is how a "successful" backup leaves a disk with nothing left for anybody else.

func PossibleOn

func PossibleOn(deps Deps) []string

PossibleOn lists the actions this machine could actually carry out.

It exists because the plan is built somewhere else. The cloud reads the scan report, which says what is wrong with a server but not what that server can do about it — so harden_ssh_config was offered on an Ubuntu 14.04 host with no systemd to reload sshd and no sshd_config.d to drop a file into. The person ticked it, approved it, and was told only then that it could never work.

The alternative was teaching the cloud the same rules in Python. That is the same knowledge in two languages, and the two would drift — which is how the morning's apt failure and this one are the same bug twice. So the agent, which already decides this every time it runs, says it once with the scan.

It is not a security boundary and does not try to be. The agent still refuses anything it cannot do, whatever the cloud sends; this only stops the screen offering work that was never going to happen.

func Protected

func Protected() ([]string, error)

Protected reads the services this machine's owner has put out of bounds.

A missing file means nothing is protected, which is the normal case. An unreadable one is an error and stays an error: guessing "probably nothing" about a list whose whole purpose is to stop us touching something is not a guess we are entitled to make.

func SwitchedOff

func SwitchedOff() (bool, string)

SwitchedOff reports whether this machine's owner has turned actions off, and says so in words a person can read.

Anything the file contains is passed back as the reason, so a sysadmin can leave a note for whoever reads the dashboard: "frozen until the audit is over".

Types

type Action

type Action struct {
	Type string

	// Summary is what the approval screen says this will do, in plain words. It
	// may contain {name} to name a parameter.
	Summary string

	Params        []Param
	Reversibility Reversibility

	// UndoWhy explains the reversibility in plain words: "the file is copied
	// before editing", "deleted logs cannot be brought back", "the images can be
	// pulled again from your registry".
	UndoWhy string

	// Settle is how long to wait after the change before checking it worked. A
	// service restarted a moment ago is not yet proof of anything.
	Settle time.Duration

	Variants []Variant
}

Action is one fix, declared as data.

func All

func All() []Action

All returns every declared action, in a stable order.

It is what `ghostpsy actions` prints, so a sysadmin can read the whole list of what this agent is able to do before deciding to trust it.

func Lookup

func Lookup(actionType string) (Action, bool)

Lookup returns the declared action, or false if the catalog does not have it.

type ActionReport

type ActionReport struct {
	Type          string        `json:"type"`
	Summary       string        `json:"summary"`
	Reversibility Reversibility `json:"reversibility"`
	UndoWhy       string        `json:"undo_why"`

	OK bool `json:"ok"`

	// Refused says, in plain words, why this action did not happen. Empty when
	// it did.
	Refused string `json:"refused,omitempty"`

	// Commands is never nil on the wire.
	//
	// A nil slice marshals to JSON null, and the screen flattens every action's
	// commands into one list to draw the terminal. flatMap over a null keeps the
	// null, so the list held one and the page crashed reading its `why`. Somebody
	// who clicked Solve got a blank screen and a stack trace.
	//
	// A refused action is a normal outcome. It must not be able to break the page
	// that exists to explain it. See emptyCommands.
	Commands []CommandRun `json:"commands"`

	// WouldRun is every command that would change this machine, written out as it
	// would be typed. Set by a dry run only; a real run reports what it did.
	//
	// It exists because a dry run runs its own steps — `cat` the file that would be
	// installed, ask the service what it believes — and those were the only commands
	// the report carried. The `install` that does the work appeared nowhere, so the
	// screen showed `cat …apt.update_package_lists=1.conf` and somebody approving
	// reasonably read that as the change. Reported from the UI.
	WouldRun []string `json:"would_run,omitempty"`

	// FreedBytes is what the dry run said would be freed, where the action frees
	// space. Zero for everything else.
	FreedBytes int64 `json:"freed_bytes,omitempty"`

	// DoItYourself is set when this action was refused because it is too dangerous
	// for us to carry out, and carries the way to do it by hand. Absent for every
	// other kind of refusal: explaining how to weaken a server is not help.
	DoItYourself *DoItYourself `json:"do_it_yourself,omitempty"`
}

ActionReport is what happened for one action.

type BackupKind

type BackupKind string

BackupKind says what a backup of an action would even be.

const (
	// BackupNone means there is nothing to copy: the action records what it
	// needs to reverse itself, or it cannot be reversed at all.
	BackupNone BackupKind = "none"

	// BackupCopyFiles copies small files aside before they are edited. This is
	// the cheap, reliable case — a config file is a few kilobytes.
	BackupCopyFiles BackupKind = "copy_files"

	// BackupArchiveFreed would need as many bytes as the action frees, which is
	// why it usually cannot happen. Writing it would cause the exact problem the
	// action exists to solve, so the free space is measured at the moment of
	// running and the backup is refused out loud when it does not fit.
	BackupArchiveFreed BackupKind = "archive_freed"
)

type BackupPlan

type BackupPlan struct {
	Kind BackupKind

	// Target is the filesystem the archive would be written to, for
	// BackupArchiveFreed. It is the disk being freed, which is the whole problem.
	Target string

	// Freed matches the size a dry run says would be freed, so the space needed
	// can be measured rather than guessed. Required for BackupArchiveFreed.
	Freed *regexp.Regexp
}

BackupPlan is how an action would be backed up, if it can be.

type BackupReport

type BackupReport struct {
	Asked bool  `json:"asked"`
	Taken bool  `json:"taken"`
	Needs int64 `json:"needs_bytes,omitempty"`
	Free  int64 `json:"free_bytes,omitempty"`

	// Why says in plain words what happened and, when a backup was skipped, the
	// numbers that decided it. Never skipped quietly.
	Why string `json:"why"`
}

BackupReport is the honest story of the backup.

type CheckID

type CheckID string

CheckID names a judgement the runner makes itself, rather than a command.

const (
	// CheckPlanKeepsMeReachable reads the dry run's own output and refuses a
	// plan whose rules would not allow a port somebody is connected on right
	// now. It runs before anything changes, which is the only place a lock-out
	// can actually be prevented rather than repaired.
	CheckPlanKeepsMeReachable CheckID = "plan_keeps_me_reachable"

	// CheckKeepsMeReachable runs after the change and fails if the machine
	// stopped answering, which is what triggers the undo. Together with the one
	// above it is the entire risk of the firewall and ssh actions: switching on
	// a firewall on a remote server is how people lose access to their own
	// machine for good.
	CheckKeepsMeReachable CheckID = "keeps_me_reachable"

	// CheckUnitNotProtected refuses to touch a service the machine's owner
	// listed as hands off. It runs first, so a protected service is never
	// touched at all — not even for a moment.
	CheckUnitNotProtected CheckID = "unit_not_protected"

	// CheckSomebodyCanStillLogIn refuses a hardening change that would leave
	// nobody able to log in.
	//
	// The other reachability checks ask whether the port still answers. That is
	// not the same question, and the difference locked me out of a real machine:
	// `PermitRootLogin no` on a cloud image left sshd running and port 22
	// accepting every connection, then refusing every login. This one counts
	// accounts instead of ports.
	CheckSomebodyCanStillLogIn CheckID = "somebody_can_still_log_in"

	// CheckServiceIsOneWeConfigure refuses to restart a service ghostpsy does not
	// configure, and hands over the commands instead.
	//
	// The rule is not only about the sudo grant. We cannot see what nginx is
	// serving or what a restart of it interrupts, so "systemd says it failed, so
	// restart it" is a guess dressed up as a fix. Where we wrote the configuration
	// we know what changed and why; where we did not, the honest answer is the
	// command and a look at the status first.
	CheckServiceIsOneWeConfigure CheckID = "service_is_one_we_configure"

	// CheckDropInWillTakeEffect refuses a change that would be written and then
	// ignored, and hands over the commands to make it by hand instead.
	//
	// sshd keeps the first value it reads for a keyword. A server whose main
	// configuration sets this directive above its Include line would read our file
	// and take no notice of it — so we would write it, report success, and change
	// nothing. That is the worst outcome available to a tool whose whole claim is
	// that you can see what it did.
	CheckDropInWillTakeEffect CheckID = "drop_in_will_take_effect"

	// CheckSettingTookEffect reads what the service reported and refuses to call the
	// fix done unless the value is really there.
	//
	// `sshd -T` exiting zero only means sshd answered. Treating that as success is how
	// a fix reports done and changed nothing.
	CheckSettingTookEffect CheckID = "setting_took_effect"
)

type CommandRun

type CommandRun struct {
	Why      string `json:"why"`
	Display  string `json:"display"`
	Stdout   string `json:"stdout"`
	Stderr   string `json:"stderr"`
	ExitCode int    `json:"exit_code"`
	Millis   int64  `json:"duration_ms"`

	// Skipped is set when this step had nothing to do on this machine, and says
	// why. It is not a failure and not a success: the command never ran, because
	// the software it drives is not here.
	//
	// It is reported rather than quietly dropped. A person approving a plan has to
	// be able to see that a step they read was not part of the work.
	Skipped string `json:"skipped,omitempty"`

	// Advice is set by a step that refused a change as too dangerous, and carries
	// the way to make it by hand. The runner lifts it onto the action so the app
	// does not have to hunt for it among the commands.
	Advice *DoItYourself `json:"-"`
	// contains filtered or unexported fields
}

CommandRun is one command, exactly as it ran.

type Deps

type Deps struct {
	// Exec runs a declared privileged command. Nothing else here can run
	// anything.
	Exec func(context.Context, privexec.ID, privexec.Values) (privexec.Result, error)

	// Installed reports whether a binary exists on this host, which is how a
	// variant is chosen.
	Installed func(binary string) bool

	// Applies reports whether a declared command has anything to do on this host,
	// and why not when it does not. Injected like Exec, so a test can decide the
	// answer instead of depending on what happens to be installed where the test
	// runs.
	//
	// Nil means everything applies. That keeps every existing caller and test
	// working, and the one place that matters — the real agent — sets it.
	Applies func(privexec.ID) (bool, string)

	// FreeBytes reports the space left on the filesystem holding path.
	FreeBytes func(path string) (int64, error)

	// SwitchedOff asks the machine whether its owner has turned actions off, and
	// why. It beats an approved job from the cloud.
	SwitchedOff func() (bool, string)

	// InboundPorts lists the ports of connections somebody is currently using to
	// reach this machine. That is how a firewall change can be stopped before it
	// locks its own operator out.
	InboundPorts func() ([]int, error)

	// Listening reports whether anything is accepting connections on a port.
	Listening func(port int) bool

	// Protected lists the services this machine's owner has said ghostpsy must
	// not touch. Their list beats an approved job.
	Protected func() ([]string, error)

	// SSHAccess counts how many accounts could still log in over SSH. It is what
	// stops a hardening change closing the last door.
	SSHAccess func(context.Context) (confedit.Access, error)

	// Accounts names the people with an account on this machine, so their names can
	// be covered in everything a command printed before any of it is sent.
	Accounts func() ([]string, error)

	Sleep func(context.Context, time.Duration) error
}

Deps is everything the runner touches outside itself.

Gathered in one place on purpose: every rule about when and how we change a customer's server can then be tested without changing one.

func HostDeps

func HostDeps() Deps

HostDeps is the runner wired to this machine.

Everything the runner touches outside itself lives behind these functions, so the rules about when we change a customer's server are all testable without changing one. This is the only place those functions are real.

type DoItYourself

type DoItYourself struct {
	Risk       string `json:"risk"`
	CheckFirst string `json:"check_first"`
	Script     string `json:"script"`
}

DoItYourself is a change ghostpsy refused to make, with the way to make it.

It exists because refusing is not the same as helping. Somebody who asked for a change still wants it, and if all we say is no, they go and do it from memory without the one check that would have saved them. So the refusal carries the risk, the thing to confirm first, and the commands — ready to paste.

type Job

type Job struct {
	Mode    string    `json:"mode"`
	Actions []Request `json:"actions"`

	// Backup is true when the person asked for one. It is a request, not an
	// instruction: whether one is possible is measured on the machine.
	Backup bool `json:"backup"`
}

Job is what the service handed over.

type LedgerEntry

type LedgerEntry struct {
	Type string `json:"type"`

	// CanPutBack is whether an undo is possible at all.
	CanPutBack bool `json:"can_put_back"`

	// PutBack is whether it actually was put back. Only ever true after a real
	// undo ran.
	PutBack bool `json:"put_back"`

	Why string `json:"why"`
}

LedgerEntry is one line of "what can and cannot be put back".

type Param

type Param struct {
	Name string

	// Why says in plain words what may go here. It reaches the approval screen.
	Why string

	// Allow is the only shape accepted, anchored so it matches whole values.
	// Checked here, and checked again by privexec when the command is built.
	Allow *regexp.Regexp
}

Param is one value the service fills in.

type PhaseReport

type PhaseReport struct {
	OK       bool         `json:"ok"`
	Commands []CommandRun `json:"commands"`
}

PhaseReport is the check that the fix worked.

type Report

type Report struct {
	Mode string `json:"mode"`

	OK bool `json:"ok"`

	// Refused says why nothing ran at all — the local switch, an empty job.
	Refused string `json:"refused,omitempty"`

	// PreviewID identifies this exact preview, on a dry run only. An approval
	// names it, so an approval given for an older preview is detectably old.
	PreviewID string `json:"preview_id,omitempty"`

	Actions []ActionReport `json:"actions"`
	Backup  BackupReport   `json:"backup"`
	Verify  *PhaseReport   `json:"verify,omitempty"`
	Undo    *UndoReport    `json:"undo,omitempty"`
}

Report is everything the service is told.

func Run

func Run(ctx context.Context, deps Deps, job Job) Report

Run carries out a job and reports what happened.

It never returns an error. Every failure belongs in the report: the person who approved this is waiting to be told what happened on their server, and an error swallowed on the way home is the failure this whole product exists to remove.

type Request

type Request struct {
	Type   string            `json:"type"`
	Params map[string]string `json:"params,omitempty"`
}

Request is one action the service asked for.

type Reversibility

type Reversibility string

Reversibility says how well a change can be put back.

It is declared per action and shown before anything runs, because the earlier rule — "no action without a backup and a rollback" — breaks on the first fix we ship: backing up the logs on a full disk needs the very space the fix exists to free. Honesty about undo is the workable rule; a promise we cannot keep is not.

const (
	// ReverseFull means putting it back is cheap and reliable.
	ReverseFull Reversibility = "full"
	// ReversePartial means it can be recovered, but it costs something or
	// depends on something — a registry being up, a mirror still carrying a
	// version. The action must name what it depends on in UndoWhy.
	ReversePartial Reversibility = "partial"
	// ReverseNone means truly gone. The dry run is then the only safety net,
	// and the screen has to say exactly that.
	ReverseNone Reversibility = "none"
)

type Step

type Step struct {
	// Why says what this step is for, in plain words. It is shown next to the
	// command in the output the customer reads.
	Why string

	Command privexec.ID
	Check   CheckID

	// Args gives the command's parameters their values. A value is either a
	// literal or {name}, naming one of the action's own parameters.
	Args map[string]string
}

Step is one thing a phase does.

Exactly one of Command or Check is set. A step cannot name a binary of its own: a privileged command has to already be declared in privexec, which is also what writes the sudo grant, so the two cannot drift apart.

type UndoReport

type UndoReport struct {
	Ran      bool          `json:"ran"`
	Ledger   []LedgerEntry `json:"ledger"`
	Commands []CommandRun  `json:"commands,omitempty"`
}

UndoReport is the ledger, and whether an undo ran.

type Variant

type Variant struct {
	// Needs is the binary that must be installed for this variant to apply.
	// Empty means it applies anywhere.
	Needs string

	// DryRun changes nothing. It is required — see checkAction.
	DryRun []Step
	Run    []Step
	Verify []Step

	// Undo must be absent when the action says it cannot be undone. A rollback
	// step on an irreversible action is a button that lies.
	Undo []Step

	// Backup is how this variant would be backed up. It belongs here rather than
	// on the action because two ways of doing the same fix can differ: switching
	// on automatic updates edits a file on Debian and flips a timer on the RHEL
	// family, and only one of those has anything to copy.
	Backup BackupPlan
}

Variant is one way of carrying out an action on one kind of machine.

Turning on a firewall is ufw on Debian and firewalld on the RHEL family. The agent picks, not the cloud: the machine knows what it has installed, and keeping the choice here keeps the vocabulary the service speaks small.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL