mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
rover self updating!
This commit is contained in:
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Executable
+88
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
Reference in New Issue
Block a user