rover self updating!

This commit is contained in:
legop3
2026-06-05 15:09:10 -04:00
parent 870de4f135
commit c097d94ba8
14 changed files with 359 additions and 132 deletions
BIN
View File
Binary file not shown.
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
#
# Fixed-purpose self-update helper for Raspberry Pi rover hosts.
#
# roverd itself runs as the unprivileged "roverd" service user, but updating the
# installed agent requires root because the installer writes to /usr/local/bin,
# /etc, /opt, /var/lib/roverd, and systemd unit locations. This helper is the
# narrow privilege boundary: sudoers allows roverd to run this exact file with no
# arguments, and this file decides the complete update sequence internally.
set -euo pipefail
ENV_FILE="/etc/roverd-update.env"
LOCK_FILE="/var/lock/roverd-self-update.lock"
LOG_FILE="/var/log/roverd-self-update.log"
log() {
# Log to stdout so systemd/journal captures the message when launched by
# roverd, and also append to a stable file so an admin can inspect the last
# update attempt after the roverd service restarts.
local message
message="[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*"
echo "$message"
echo "$message" >> "$LOG_FILE"
}
if [[ "${EUID}" -ne 0 ]]; then
echo "roverd-self-update must run as root" >&2
exit 1
fi
if [[ ! -f "$ENV_FILE" ]]; then
echo "Missing $ENV_FILE; run pi/install_roverd.sh once to register the repository path" >&2
exit 1
fi
# shellcheck disable=SC1090
source "$ENV_FILE"
if [[ -z "${ROVERD_REPO_DIR:-}" ]]; then
echo "ROVERD_REPO_DIR is not set in $ENV_FILE" >&2
exit 1
fi
if [[ ! -d "$ROVERD_REPO_DIR/.git" ]]; then
echo "ROVERD_REPO_DIR does not point at a git checkout: $ROVERD_REPO_DIR" >&2
exit 1
fi
if [[ ! -x "$ROVERD_REPO_DIR/pi/install_roverd.sh" ]]; then
echo "Installer is missing or not executable: $ROVERD_REPO_DIR/pi/install_roverd.sh" >&2
exit 1
fi
mkdir -p "$(dirname "$LOCK_FILE")" "$(dirname "$LOG_FILE")"
touch "$LOG_FILE"
chmod 0644 "$LOG_FILE"
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
log "Another roverd self-update is already running; refusing to start a second one"
exit 1
fi
cd "$ROVERD_REPO_DIR"
log "Starting roverd self-update in $ROVERD_REPO_DIR"
repo_uid="$(stat -c '%u' "$ROVERD_REPO_DIR")"
# Fetch through the normal git remote instead of downloading just one artifact.
# That keeps the binary, installer, scripts, systemd units, and any other rover
# files from this repository on the same commit.
if [[ "$repo_uid" -eq 0 ]]; then
git pull --ff-only
else
# The helper runs as root only for installation privileges. The git checkout
# usually belongs to the human deploy user, so running the pull as that owner
# avoids Git's dubious-ownership protection and preserves whatever SSH/HTTPS
# credentials that user normally uses for this repository.
sudo -H -u "#${repo_uid}" git -C "$ROVERD_REPO_DIR" pull --ff-only
fi
log "Repository fast-forward pull complete"
# The installer already owns the full desired rover host state. Reusing it here
# prevents this update helper from becoming a second, partial installer that can
# drift away from the normal manual install path.
"$ROVERD_REPO_DIR/pi/install_roverd.sh"
log "Installer completed successfully"
+41
View File
@@ -6,6 +6,7 @@ set -euo pipefail
BINARY_SRC="dist/roverd"
CONFIG_SRC="pi/roverd/roverd.sample.yaml"
REPO_ROOT="$(pwd -P)"
usage() {
cat <<'USAGE'
@@ -22,6 +23,7 @@ The script must run from the repository root and as root (sudo). It will:
* install /usr/local/bin/roverd and /etc/roverd.yaml
* install /usr/local/bin/video/audio helpers and systemd units
* install fixed-location Google Chrome TTS assets for roverd
* install the fixed-command self-update helper used by admin-triggered updates
* enable roverd.service and media publisher/listener services
USAGE
}
@@ -131,6 +133,45 @@ ensure_user roverd "dialout,gpio,video,render,audio"
install -o roverd -g roverd -m 0755 "$BINARY_SRC" /usr/local/bin/roverd
log "Installed roverd binary"
install_self_update_support() {
local sudoers_file="/etc/sudoers.d/roverd-self-update"
local update_env="/etc/roverd-update.env"
local quoted_repo_root
# The self-update helper must know which checkout should receive the git
# pull. Recording the repository root during the normal installer run keeps
# the runtime websocket command simple and prevents the rover from accepting
# a caller-controlled path.
printf -v quoted_repo_root '%q' "$REPO_ROOT"
install -D -o root -g root -m 0644 /dev/null "$update_env"
cat > "$update_env" <<ENV
# Managed by pi/install_roverd.sh.
# This path is intentionally captured from the installer working directory so
# admin-triggered rover updates always operate on the same full repository that
# was used for the manual install.
ROVERD_REPO_DIR=$quoted_repo_root
ENV
log "Registered roverd update repository at $REPO_ROOT"
# The helper is root-owned and argument-free. sudoers grants the roverd
# service user exactly this command and nothing broader, which is important
# because update requests arrive over the rover websocket.
install -D -o root -g root -m 0755 pi/bin/roverd-self-update.sh /usr/local/sbin/roverd-self-update
cat > "$sudoers_file" <<'SUDOERS'
# Managed by pi/install_roverd.sh.
# Allow only the roverd service account to run the fixed self-update helper.
roverd ALL=(root) NOPASSWD: /usr/local/sbin/roverd-self-update
SUDOERS
chown root:root "$sudoers_file"
chmod 0440 "$sudoers_file"
if command -v visudo >/dev/null 2>&1; then
visudo -cf "$sudoers_file" >/dev/null
fi
log "Installed roverd self-update helper and sudoers rule"
}
install_self_update_support
CONFIG_DEST="/etc/roverd.yaml"
CONFIG_EXISTS=0
if [[ -f "$CONFIG_DEST" ]]; then
+6
View File
@@ -45,6 +45,10 @@ type inboundMessage struct {
NightVision *nightVisionPayload `json:"nightVision,omitempty"`
Song *songPayload `json:"song,omitempty"`
Reboot *rebootPayload `json:"reboot,omitempty"`
// Update is intentionally just a marker payload. The server can request the
// fixed self-update workflow, but it cannot pass paths, commands, branches,
// or installer flags through the websocket into the privileged helper.
Update *updatePayload `json:"update,omitempty"`
}
type driveDirectPayload struct {
@@ -112,6 +116,8 @@ type rebootPayload struct {
DelayMs int `json:"delayMs,omitempty"`
}
type updatePayload struct{}
type ackMessage struct {
Type string `json:"type"`
ID string `json:"id"`
+54 -4
View File
@@ -35,6 +35,7 @@ type WSClient struct {
rebootT *time.Timer
seekIssued bool
rebootIssued bool
updateIssued bool
audioLevels AudioLevels
audioMu sync.RWMutex
}
@@ -244,20 +245,32 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
return c.adapter.PlaySong(slot, msg.Song.Notes)
case msg.Reboot != nil || msg.Type == "reboot":
return c.handleRebootCommand(msg.Reboot)
case msg.Update != nil || msg.Type == "update":
return c.handleUpdateCommand()
default:
return fmt.Errorf("unsupported command type: %s", msg.Type)
}
}
func (c *WSClient) handleRebootCommand(payload *rebootPayload) error {
func (c *WSClient) stopMotionForSystemCommand(reason string) error {
// System-level commands can restart the process or the whole Pi. Stopping
// both wheel and auxiliary motors first leaves the Roomba in a predictable
// state before roverd hands control to systemd or the update helper.
if err := c.adapter.DriveDirect(0, 0); err != nil {
return fmt.Errorf("stop drive before reboot: %w", err)
return fmt.Errorf("stop drive before %s: %w", reason, err)
}
if err := c.adapter.MotorPWM(0, 0, 0); err != nil {
return fmt.Errorf("stop aux motors before reboot: %w", err)
return fmt.Errorf("stop aux motors before %s: %w", reason, err)
}
if err := c.adapter.StartOI(); err != nil {
return fmt.Errorf("enter passive mode before reboot: %w", err)
return fmt.Errorf("enter passive mode before %s: %w", reason, err)
}
return nil
}
func (c *WSClient) handleRebootCommand(payload *rebootPayload) error {
if err := c.stopMotionForSystemCommand("reboot"); err != nil {
return err
}
delay := 300 * time.Millisecond
@@ -290,6 +303,42 @@ func (c *WSClient) handleRebootCommand(payload *rebootPayload) error {
return nil
}
func (c *WSClient) handleUpdateCommand() error {
if err := c.stopMotionForSystemCommand("self-update"); err != nil {
return err
}
c.connMu.Lock()
if c.updateIssued {
c.connMu.Unlock()
return fmt.Errorf("update already pending")
}
c.updateIssued = true
c.connMu.Unlock()
c.emitEvent("system.updateStarting", map[string]any{
"source": "remoteCommand",
})
// The helper is launched asynchronously because a successful update may
// restart roverd before this websocket command could stream progress back to
// the server. sudo is intentionally limited by /etc/sudoers.d/roverd-self-update
// to one root-owned helper with no caller-controlled arguments.
cmd := exec.Command("sudo", "-n", "/usr/local/sbin/roverd-self-update")
if err := cmd.Start(); err != nil {
c.connMu.Lock()
c.updateIssued = false
c.connMu.Unlock()
return fmt.Errorf("start self-update helper: %w", err)
}
if err := cmd.Process.Release(); err != nil {
c.log.Printf("release self-update helper process handle failed: %v", err)
}
c.log.Printf("started roverd self-update helper with pid %d", cmd.Process.Pid)
return nil
}
func (c *WSClient) applyAutoSideBrush(left, right int) {
if c.cfg == nil || !c.cfg.AutoSideBrush.Enabled {
if c.autoSideOn {
@@ -576,6 +625,7 @@ func (c *WSClient) markConnected() {
c.connected = true
c.seekIssued = false
c.rebootIssued = false
c.updateIssued = false
if c.disconnectT != nil {
c.disconnectT.Stop()
c.disconnectT = nil
@@ -21,6 +21,7 @@ Rules:
- Only talk in first person, do not narrate yourself.
- It is not possible for you to control the rovers.
- All rovers are female. Neato is nonbinary.
- Do not play a neato sound unless you are instructed to.
Chat style:
- Always use English.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -11,7 +11,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-DxjZG9qS.js"></script>
<script type="module" crossorigin src="/assets/index-BTF4sS2O.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D6X9qUts.css">
</head>
<body>
+7 -2
View File
@@ -82,15 +82,20 @@ io.on('connection', (socket) => {
}
const payload = data ? { ...data } : {};
const isRebootCommand = type === 'reboot';
const isUpdateCommand = type === 'update';
const isSongCommand = type === 'song' || (type === 'raw' && isSongRawPayload(payload));
const isAdminSocket = isAdmin(socket);
if (!isAdminSocket && isDeterred(socket)) {
throw new Error('Not authorized');
}
if (isRebootCommand && !isAdminSocket) {
// Rover updates run a privileged, root-owned helper on the Pi. Keep this
// in the same explicit admin-only branch as reboot instead of relying on
// drive ownership checks, because having a turn should not grant system
// maintenance privileges.
if ((isRebootCommand || isUpdateCommand) && !isAdminSocket) {
throw new Error('Not authorized');
}
if (!isSongCommand && !isRebootCommand && !roverManager.canDrive(roverId, socket)) {
if (!isSongCommand && !isRebootCommand && !isUpdateCommand && !roverManager.canDrive(roverId, socket)) {
throw new Error('Not your turn or no control');
}
const driveDirect = payload?.driveDirect;
@@ -26,6 +26,7 @@ export default function AdminPanelContent() {
setGlobalObjective,
setAdminReason,
rebootRover,
updateRover,
rebootServer,
setAudioLevels,
setPrivateSafety,
@@ -38,6 +39,7 @@ export default function AdminPanelContent() {
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
const [lockStates, setLockStates] = useState({});
const [rebootStates, setRebootStates] = useState({});
const [updateStates, setUpdateStates] = useState({});
const [serverRebooting, setServerRebooting] = useState(false);
const [clearingLlmHistory, setClearingLlmHistory] = useState(false);
const [clearingOverseerHistory, setClearingOverseerHistory] = useState(false);
@@ -104,6 +106,27 @@ export default function AdminPanelContent() {
}
};
const handleUpdate = async (rover) => {
if (!rover?.id) return;
const ok = window.confirm(
`Update rover "${rover.name || rover.id}" now? The rover will git pull the repo, run the roverd installer, and may disconnect while services restart.`,
);
if (!ok) return;
setUpdateStates((prev) => ({ ...prev, [rover.id]: true }));
try {
// The rover acknowledges once the privileged self-update helper has been
// launched, not when the full install finishes. That is deliberate: a
// successful installer run restarts roverd, so waiting for completion over
// the same websocket would make the button look failed even when the
// update is doing exactly what it should.
await updateRover(rover.id);
} catch (err) {
alert(err.message);
} finally {
setUpdateStates((prev) => ({ ...prev, [rover.id]: false }));
}
};
const handleServerReboot = async () => {
const ok = window.confirm('Reboot the server host now? This will disconnect all users.');
if (!ok) return;
@@ -435,6 +458,14 @@ export default function AdminPanelContent() {
>
{rebootStates[rover.id] ? 'Rebooting...' : 'Reboot'}
</button>
<button
type="button"
onClick={() => handleUpdate(rover)}
disabled={Boolean(updateStates[rover.id])}
className="button-danger disabled:cursor-not-allowed disabled:opacity-60"
>
{updateStates[rover.id] ? 'Updating...' : 'Update'}
</button>
{rover?.private?.enabled ? (
<div className="w-full rounded border border-slate-700/70 p-0.5 space-y-0.5 text-[0.7rem]">
<div className="text-slate-300">Private safety</div>
+5
View File
@@ -195,6 +195,11 @@ export function SessionProvider({ children }) {
setAdminReason: (text) => emitWithAck('adminReason:set', { text }),
rebootRover: (roverId) =>
emitWithAck('command', { roverId, type: 'reboot', data: { reboot: {} } }),
// Rover updates are intentionally parameter-free from the browser. The Pi
// side owns the git pull + installer sequence so the admin UI can request
// maintenance without becoming a remote shell.
updateRover: (roverId) =>
emitWithAck('command', { roverId, type: 'update', data: { update: {} } }),
rebootServer: () => emitWithAck('server:reboot'),
playUploadedAudio: ({ roverId, name, mime, dataBase64 }) =>
emitWithAck('audio:uploadPlay', { roverId, name, mime, dataBase64 }),