A Telegram bot that wakes, sleeps, shuts down and checks the machines on your LAN, and keeps an eye on your homelab.
Illustration: a mock chat with fake data.
You have a small server that is always on (a Raspberry Pi, a NAS, a mini PC), and PCs that aren't. This bot runs on the server and lets you wake, sleep, reboot and shut down those PCs from Telegram, safely: every power action asks first, and the bot reports what actually happened ("Booted in 45s", "Shut down in 8s"), not just that it sent a command. It also watches the server itself.
- Features
- Quickstart with Docker
- Install without Docker
- Preparing the machines
- Configuration
- Commands
- How each feature works
- Security model
- Development
- License and Contact
- Wake-on-LAN with a real follow-up: "Booted in 45s" or "Woke from sleep in 4s".
- Sleep, reboot and shut down over SSH, each after a confirmation, then checked: "Shut down in 8s".
- Timers: shut down at 22:30, wake in 8 hours; with a warning before a shutdown.
- Several machines: Windows, and Linux and macOS (experimental), picked by name or with a button.
- Homelab health: disks, failed services, Docker containers, updates, pending reboots and backups, with alerts when something goes wrong and when it clears.
- Internet outages: noticed, timed and reported when the connection is back.
- A daily summary and a weekly report, sent at times you choose.
- Logs in the chat: recent lines, or everything as a file.
You need a Linux host with rootful Docker Engine on the same network as your PCs. Docker Desktop and rootless Docker can't send Wake-on-LAN packets to the LAN.
-
Create a bot with @BotFather and copy its token. Find your numeric Telegram user ID (bots like @userinfobot show it).
-
Get the code and fill in the configuration:
git clone https://github.com/candygoogy/candygoogy-bot.git cd candygoogy-bot cp .env.example .env nano .env # BOT_TOKEN, AUTHORIZED_USER_IDS, and one machine (or MACHINES_FILE)
-
Make the bot's SSH key. The container runs as UID/GID 10001, so the folder and the key must be readable by group 10001, and owned by root so the bot can't change them:
sudo install -d -o root -g 10001 -m 750 ssh sudo ssh-keygen -t ed25519 -N "" -C candygoogy-bot -f ssh/id_ed25519 sudo chown root:10001 ssh/id_ed25519 && sudo chmod 640 ssh/id_ed25519
Then set up each PC to accept that key: see Preparing the machines.
-
Build, check the configuration, and start:
export CANDYGOOGY_COMMIT="$(git rev-parse HEAD)" # shown by /about docker compose build docker compose run --rm --no-deps bot python bot.py --check-config docker compose up -d docker compose logs -f bot
-
In Telegram, open a private chat with your bot and send
/start.
--check-config prints OK: the configuration is valid., or lists the
problems by variable name (never their values). The bot uses ping without any
privileges, which needs the host's sysctl net.ipv4.ping_group_range to include
group 10001. The default on most systemd hosts (0 2147483647) does; if yours
prints 1 0, fix it once:
echo 'net.ipv4.ping_group_range = 0 2147483647' | sudo tee /etc/sysctl.d/60-ping.conf
sudo sysctl --systemOptional: Docker containers in /homelab
Docker's own socket is root on the host, so the bot never gets it. Instead, a small proxy (wollomatic/socket-proxy) allows exactly one request, the container list:
echo "DOCKER_GID=$(getent group docker | cut -d: -f3)" >> .env
docker compose -f docker-compose.yml -f docker-compose.docker-proxy.yml up -dOptional: accurate disks and "reboot required" from the host
Inside a container, / is Docker's disk and the host's /run/reboot-required
isn't visible. Uncomment the /host mount and HOMELAB_HOST_ROOT: /host in
docker-compose.yml to read them from the host (read-only). The bot can then read
whatever on the host is world-readable, so never combine it with access to
Docker's socket.
On a Debian-like host with systemd, the bot runs as a hardened service. The
layout is code in /opt/candygoogy-bot, configuration in
/etc/candygoogy/bot.env, SSH keys in /etc/candygoogy/ssh/ and state in
/var/lib/candygoogy-bot. The one-time setup commands are at the top of
deploy/candygoogy-bot.service; in short:
sudo useradd --system --user-group --home-dir /var/lib/candygoogy-bot \
--no-create-home --shell /usr/sbin/nologin candygoogy
sudo install -d -o candygoogy -g candygoogy -m 755 /opt/candygoogy-bot
sudo -u candygoogy git clone https://github.com/candygoogy/candygoogy-bot.git /opt/candygoogy-bot
sudo -u candygoogy python3 -m venv /opt/candygoogy-bot/venv
sudo -u candygoogy /opt/candygoogy-bot/venv/bin/pip install --no-cache-dir --require-hashes \
-r /opt/candygoogy-bot/requirements.txt
sudo install -d -o root -g candygoogy -m 750 /etc/candygoogy /etc/candygoogy/ssh
sudo install -o root -g candygoogy -m 640 /opt/candygoogy-bot/.env.example /etc/candygoogy/bot.env
sudoedit /etc/candygoogy/bot.env
sudo cp /opt/candygoogy-bot/deploy/candygoogy-bot.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now candygoogy-botThe host needs ping (iputils-ping), ip (iproute2) and ssh on the PATH. The
unit has no capabilities, so ping needs the same ping_group_range setting as
in the Docker quickstart. A configuration error stops the service with exit
code 2 instead of restarting it over and over; journalctl -u candygoogy-bot
says which variables are wrong.
Updates: /opt/candygoogy-bot/deploy/deploy.sh (as an admin). It stops at the
first failure, without restarting:
git pull --ff-only, as the checkout's owner;pip install --no-cache-dir --require-hashes -r requirements.txt;deploy/check-env.sh: the required variables (BOT_TOKEN,AUTHORIZED_USER_IDS) exist and aren't empty;bot.py --check-configwithbot.envand the unit's defaults, as the service will see them;- compares the installed unit with the repo's and prints the commands to update it (it never installs it itself);
- restarts the service and checks that it runs.
The "Reboot homelab" button needs one polkit rule, which allows only that:
sudo install -m 644 /opt/candygoogy-bot/deploy/50-candygoogy-reboot.rules /etc/polkit-1/rules.d/.
Each machine needs an SSH server that accepts the bot's key (restricted to the bot's host), a rule that lets that user power the machine off, and Wake-on-LAN:
The power rules to copy are in deploy/targets/. For
Wake-on-LAN, turn it on in the BIOS/UEFI and on the network card, use a wired
connection, and give each machine a fixed IP (a DHCP reservation).
Everything is set with environment variables: in .env for Docker,
/etc/candygoogy/bot.env for systemd. .env.example lists them
all with examples. Only two are required; for the machines, set either the
WORKSTATION_* variables (one Windows PC) or MACHINES_FILE (any number of
Windows, Linux and macOS machines; see
machines.example.toml).
Every value is checked at startup. A missing or invalid one stops the bot with
exit code 2 and a list of the variable names, never their values.
python bot.py --check-config runs the same check without starting the bot.
Every variable
| Variable | Default | Meaning |
|---|---|---|
BOT_TOKEN |
required | The token from @BotFather. |
AUTHORIZED_USER_IDS |
required | Telegram user IDs allowed to use the bot, comma-separated. Alerts go to each. |
WORKSTATION_MAC |
One Windows PC: its MAC address, for Wake-on-LAN. | |
WORKSTATION_IP |
Its fixed IP address. | |
SSH_USER |
The user the bot logs in as over SSH. | |
SSH_KEY_PATH |
~/.ssh/id_ed25519 |
The bot's private key, without a passphrase. |
MACHINES_FILE |
A TOML file of machines, instead of WORKSTATION_*. |
|
SSH_KNOWN_HOSTS |
ssh's own | Where ssh records each machine's host key. |
TZ |
the host's | Time zone for clock times, e.g. America/New_York. |
DIGEST_TIME |
08:00 |
When the daily summary is sent, or off. |
WEEKLY_REPORT |
sun 20:00 |
When the weekly report is sent, or off. |
HOMELAB_DISKS |
/ |
Disks /homelab watches; paths other than / must stay mounted. |
BACKUP_DIR |
off | Where your backup job writes its snapshot folders. |
BACKUP_MAX_AGE_HOURS |
26 |
Hours without a completed backup before a warning. |
DOCKER_HOST |
unix:///var/run/docker.sock |
Docker's API for the container list (unix:// or tcp://). |
INTERNET_CHECK_TARGETS |
1.1.1.1:443,9.9.9.9:443 |
Where the internet check connects (1 to 5). |
CANDYGOOGY_IN_CONTAINER |
0 |
1 in a container (the image sets it): host-only checks are skipped. |
HOMELAB_HOST_ROOT |
Where the host's / is mounted in a container, e.g. /host. |
|
CANDYGOOGY_COMMIT |
The commit an image was built from, shown by /about. |
| Command | What it does |
|---|---|
/start, /help |
The command list, and the keyboard under the message box. |
/wake |
Wake-on-LAN (3 packets, 1s apart) unless it already answers, then reports how it came up. |
/status |
Awake, probably asleep, or off; while awake also uptime, CPU, RAM, disk and who is signed in. |
/sleep |
Sleep, after a confirmation. |
/reboot |
Restart, after a confirmation. |
/shutdown |
Power off, after a confirmation. |
/schedule_shutdown [when] |
Power off later: 45m, 2h, 1h30m, 22:30. Asks when if no time is given. |
/schedule_wake [when] |
Power on later: 8h, 06:45. Asks when if no time is given. |
/timers |
Pending timers, with Cancel (and +30m for a shutdown). |
/homelab |
The bot's host: uptime, load, RAM, disks, services, containers, updates, backups. |
/net |
Internet outages in the last 7 days. |
/summary |
The daily summary, now. |
/week |
The weekly report, now. |
/logs [audit|errors] |
Recent log lines; π File sends everything kept. |
/about |
The running commit, uptime, versions, where state is saved, the time zone. |
A keyboard under the message box has buttons for the common commands (Wake, Status, Homelab, Sleep, Reboot, Shut down, Timers, Logs, About, Internet, Summary, Week). A tap runs the same command, so power actions still ask first.
Several machines: the power and timer commands take a name, e.g.
/wake desk or /schedule_shutdown nas 22:30. Without one, the bot asks with
a button per machine. /status then shows one line per machine, with a button
for each machine's details. With one machine, nothing changes: no names, no picker.
Power actions and follow-ups
/sleep, /reboot and /shutdown first check that the machine answers over
SSH, then ask with two buttons (e.g. π Shut down and Cancel). A prompt expires
after 60 seconds and works only once. /shutdown and /reboot also warn if
someone is signed in to the machine, since unsaved work would be lost.
After /wake, /sleep, /reboot and /shutdown the bot keeps checking and
updates its reply with what happened, e.g. Booted in 45s, Woke from sleep in 4s or Shut down in 8s, or a warning if nothing happened within a few
minutes. Booted vs. woke comes from the machine's last boot time, which sleep
doesn't reset. One power action runs per machine at a time; a second one is
refused until the first is done.
Timers
[when] is a duration (45m, 2h, 1h30m, 1d; a bare number means
minutes) or a clock time (22:30, the next one, in the bot's time zone), from 1
minute to 7 days ahead. Without a time, the bot asks "when?" with preset buttons
(15m/30m/1h/2h for a shutdown, 1h/4h/8h/12h for a wake), or you type the time as
your next message within 2 minutes. Setting a timer always asks for
confirmation. Each machine has one timer per action; a new one replaces the old.
Five minutes before a scheduled shutdown the bot sends a warning with Cancel and +30m buttons. When a timer fires, it runs the same checks as the command and reports the result. Timers are saved in the state folder, so a restart keeps them; one that fell due more than 2 minutes before the bot came back is reported as missed, not run late.
Homelab health and alerts
/homelab reads /proc, the disks in HOMELAB_DISKS, failed systemd units,
Docker's container list (GET /containers/json on its API), apt list --upgradable, /run/reboot-required and the backups folder; it changes
nothing. It warns when a disk has under 10% free, when a disk other than /
isn't mounted, when a service has failed, when a container is stopped,
restarting or unhealthy, when a reboot is required, and when no backup has
completed for BACKUP_MAX_AGE_HOURS. Waiting updates are listed but aren't a
problem on their own.
Every 10 minutes the bot checks the same things and sends one message when a
problem appears and one ("back to normal") when it clears. Something that can't
be checked keeps its alerts as they are. In a container, services, updates and
reboots belong to the host and aren't checked (reboots are, with
HOMELAB_HOST_ROOT).
While a reboot is required, /homelab and the alert have a π Reboot homelab
button (systemd installs only). It asks first, then runs systemctl reboot,
which the polkit rule allows.
Backups: your backup job writes each snapshot to a folder in BACKUP_DIR named
after when it started, YYYYMMDD-HHMMSS, and creates a .complete file in it
once every step succeeded. A newer folder without it is shown as failed or still
running.
Internet outages
Every minute the bot opens and closes a TCP connection to the first of
INTERNET_CHECK_TARGETS (then the next, if that fails). Two failed checks in a
row make an outage. Nothing can be sent while it's down, so when the connection
is back the bot says how long it was down. /net lists the last 7 days.
Daily summary and weekly report
At DIGEST_TIME the bot sends the homelab's problems, the last backup, waiting
updates, recent internet outages, which machines are on, and timers due in the
next 24 hours. At WEEKLY_REPORT it sends the last 7 days: the share of time
online and the longest outage, backups, the homelab's uptime and every problem
it alerted about, and how long each machine was on per day (from a ping every 5
minutes). Both retry for an hour if Telegram can't be reached. /summary and
/week show them on demand.
Logs, restarts and the keyboard
/logs shows the latest log lines (all, audit or errors); π File sends the
last 1,000 records as a file. The bot token is masked in every log line.
Commands sent while the bot was offline are not run when it comes back, since Telegram would deliver them all at once. Instead, the bot sends one "Back online" message listing what it ignored and any missed timers.
Telegram keeps showing the keyboard of the latest message that carried one, so
/start, alerts, summaries and "Back online" all carry it. /start brings it
back if it's ever gone.
The bot can turn machines on and off, so it is built to do only that, only for you.
- Who can use it: only the users in
AUTHORIZED_USER_IDS, and only in a private chat with the bot. Everyone else, and every group or channel, is silently ignored, and logged at most once a minute per user so strangers can't flood the logs. - Power needs a confirmation: sleep, reboot, shutdown and timers ask first. A confirmation expires after 60 seconds and works only once, and the data behind the buttons is checked again when they're pressed.
- No chat text reaches a command line: what the bot runs on each machine is a fixed command per operating system. Machine names from chat are only looked up among the configured ones. A test checks every place the bot starts a program.
- Configuration is checked at startup, and problems name the variable, never its value. The bot token is masked in every log line and log file.
- Least privilege: the systemd unit and the container run without root and
without any capabilities, on a read-only filesystem. On the machines, the
polkit rule (Linux) and the sudoers rule (macOS) allow exactly the power
commands, and the bot's key is restricted to the bot's host with
from=and no forwarding or terminal. - Docker's socket is root on the host, so the bot never gets it. For the
container list, use the filtering proxy (Docker) or point
DOCKER_HOSTat one (systemd). Adding the bot's user to Docker's group would give it root. - What it stores: small JSON files in the state folder
(
/var/lib/candygoogy-bot, or the Docker volume): timers, the last power action per machine, open alerts and the week's problem history, internet outages, on-time checks, the keyboard last sent, and ssh'sknown_hosts. No messages, no tokens. Recent log lines are kept in memory only.
Found a problem? See SECURITY.md.
python -m venv venv
# "python -m pip": the dev lock pins pip itself (pip-audit needs it), and on
# Windows pip.exe refuses to replace itself.
venv/bin/python -m pip install --require-hashes -r requirements-dev.txt # Windows: venv\Scripts\python
pytest
ruff check . && ruff format --check .
basedpyright
pip-audit --require-hashes --disable-pip -r requirements.txt -r requirements-dev.txtTo run the bot itself from the checkout (e.g. to try a change), put a .env
next to bot.py (copy .env.example and fill it in), then:
venv/bin/python bot.py --check-config # OK, or the problems by name
venv/bin/python bot.pyTests are in tests/, one file per area. Fakes shared between files are in
helpers.py, fixtures in conftest.py, and the fake configuration in
__init__.py. conftest.py fails any test that would start a real process or
open a real connection.
Code layout
bot.py is only the entry point: it loads .env, checks the configuration and
starts the bot. The code is in the candygoogy/ package; each module imports
only from the ones above it.
| Module | What's in it |
|---|---|
settings.py |
Reading and checking every setting: load_settings(), ConfigError |
config.py |
The checked settings as constants, audit(), shared types |
keyboard.py |
The keyboard under the message box |
auth.py |
@authorized: allowed users, private chats only |
messages.py |
Telegram HTML helpers: card, duration, replies, notifications |
oscommands.py |
What runs on Windows, Linux and macOS, and reading the answers |
system.py |
run() and ssh(): the only places a program starts |
wol.py |
The Wake-on-LAN magic packet |
machines.py |
The configured machines, names in chat and the picker |
state.py |
Saving and loading the state files, atomically |
tasks.py |
repeat(): the loop behind the periodic checks |
history.py |
The last power action per machine |
probes.py |
Ping, SSH port, ARP, details, sessions and the /status verdict |
dockerapi.py |
The container list from Docker's API |
homelab.py |
/homelab's readings, alerts and the reboot button |
internet.py |
/net: the internet check and outage history |
power.py |
Wake, shutdown, reboot, sleep, one at a time per machine, follow-ups |
timers.py |
Scheduled shutdown and wake, and the "when?" prompt |
pctime.py |
When each machine was on |
digest.py |
/summary and the daily summary |
weekly.py |
/week and the weekly report |
about.py |
/about |
logs.py |
/logs, and masking the token in log lines |
handlers.py |
Commands, buttons and the error handler |
app.py |
Startup, background checks, main() |
Updating dependencies
Direct dependencies live in requirements.in and requirements-dev.in. The
.txt files are generated, hash-locked lock files; don't edit them by hand.
After editing an .in file, regenerate both locks (they target Linux and
Windows, Python 3.11+):
uv pip compile requirements.in --universal --python-version 3.11 --generate-hashes -o requirements.txt
uv pip compile requirements-dev.in --universal --python-version 3.11 --generate-hashes -o requirements-dev.txtAdd --upgrade to move everything to the latest allowed versions.
GitHub Actions runs three workflows: ci.yml
(lint, types and tests on Python 3.11 and 3.13, shellcheck),
docker.yml (builds the image and smoke-tests
it offline) and audit.yml (known
vulnerabilities in both locks, also weekly).
MIT, see LICENSE.
Questions and ideas: hello@candygoogy.dev. For security problems, please follow SECURITY.md instead of opening an issue.