AI Decision System
Replayable, offline-verifiable records for AI-assisted go/no-go decisions: experiment promotions, typed model answers and human-approved actions.
翻译准备中
Agentless fleet care for homelabs — wake machines that are asleep, update them, read their logs, and put them back the way they were found. One container, one volume, nothing installed on the machines you manage.
翻译准备中
取自仓库 README
正在加载完整 README。 以下为已保存的摘录。
Agentless fleet care for homelabs: wake machines that are asleep, update them, read their logs,
and put them back the way they were found.
Nothing is installed on the machines you manage — Timar needs SSH, and Wake-on-LAN for the ones
that sleep. It is one container with one data volume, so moving it is copying a directory.
There is a one-page tour at timar.tools.
Three states, not two. Asleep (grey) is a machine that is meant to be off, and it is not
painted like a fault — a status page that shows both in red teaches you to ignore red.查看完整 README ↗Agentless fleet care for homelabs: wake machines that are asleep, update them, read their logs, and put them back the way they were found.
Nothing is installed on the machines you manage — timar needs SSH, and Wake-on-LAN for the ones that sleep. It is one container with one data volume, so moving it is copying a directory. There is a one-page tour at timar.tools.
Three states, not two. Asleep (grey) is a machine that is meant to be off, and it is not painted like a fault — a status page that shows both in red teaches you to ignore red.
Status: early. Wake, update, log sweep, containers, the scheduler and key enrolment work and are tested. Read CHANGELOG.md before upgrading.
curl -O https://raw.githubusercontent.com/orkun-soylu/timar/main/docker-compose.yml
docker compose up -d
Open http://<host>:8080 and create the operator account — nothing else answers until you do.
The image is built for amd64 and arm64; a Raspberry Pi is a first-class host. :latest
follows the newest release; pin a version (ghcr.io/orkun-soylu/timar:0.2.51) to choose when
you move.
Add, edit and remove servers and containers from their pages. A server's form has three
sections — Server info, Update, SSH access — each with a ? that explains it; the SSH
section is also where the server is enrolled: timar installs its own key with your password once
(never stored) and proves the key works on its own. An address may carry an SSH port
(10.0.0.5:2222), and a web interface turns the server's name into a link while it is up —
a panel, or a program on your own machine through a scheme you allow (below).
It all lands in config.yaml in the timar-data volume, which you can also edit by hand — see
config.example.yaml.
A VM has no wake address of its own, so it names the hypervisor that starts it and inherits on-demand from it — otherwise every night it spends off would be reported as an outage.
⚠️ Do not expose this to the internet. timar holds an SSH key that reaches every machine it manages and can grant itself
sudoon them. Keep it on a private network, or behind a reverse proxy you control. Behind a proxy, setFORWARDED_ALLOW_IPSto its address (seedocker-compose.yml), so the login lockout tells clients apart.
The compose file uses network_mode: host because Wake-on-LAN does not work from a bridge
network: the send succeeds, no error, and the packet never reaches the LAN (measured with
tcpdump: bridge 0 packets, host 1).
If you do not need to wake machines, a bridge is fine — replace network_mode: host with:
ports:
- "8080:8080"
To wake from a bridge — or to wake a machine in a different subnet, which host networking cannot do either — give that server a wake relay (below).
Two settings are written in config.yaml by hand; the server form does not show them as fields.
It says when one is set, and saving the form keeps it untouched. Most installations need
neither: with host networking, one LAN and every machine on it, the defaults wake everything.
| Key | Default | Set it when |
|---|---|---|
wol_relay | none — timar sends the packet itself | the machine is in another subnet (a second site, an office VLAN), or timar runs on a bridge network |
wol_broadcast | 255.255.255.255 | timar's host has several networks and the packet leaves through the wrong one |
wol_relay names another server in the same file. A magic packet is a broadcast, and a
broadcast stops at the router, so the packet has to start inside the target's own subnet: timar
connects to the relay over SSH and sends it from there. The relay must be always on,
already enrolled, and have python3 or wakeonlan. If the relay is renamed in the form,
the reference follows. If it is removed, the wake fails and says the relay "is not configured";
it doesn't fail silently.
wol_broadcast is the address the packet is sent to. 255.255.255.255 leaves through the
interface of the default route, which is right on a single-LAN host. On a host with several
networks, give the target segment's own broadcast address (10.0.0.255 for a
10.0.0.0/24) so the kernel picks the interface that reaches it. With a relay it is the
address the relay sends to.
servers:
- name: office-nas
host: 10.1.0.20
user: deploy
wol_mac: "aa:bb:cc:dd:ee:20"
wol_relay: office-router # an always-on, enrolled host in 10.1.0.0/24
wol_broadcast: 10.1.0.255 # optional; the relay's own segment
Edit the file inside the volume — docker exec -it timar vi /data/config.yaml, or copy it out
and back with docker cp. It is read on every request: no restart.
An access point or a NAS appliance belongs on the servers page too, with its link, even though
timar cannot log in to it. Tick No SSH — only watch it and link to it in its form, or write
ssh: false in config.yaml:
servers:
- name: access-point
host: 10.0.0.2
platform: linux # required by the form, otherwise unused
ssh: false
logo: tplink # synology, qnap, truenas, unraid, tplink, ubiquiti, mikrotik, …
web_url: "https://10.0.0.2"
Such a device:
A server's web interface is normally a panel: https:// (added if you leave it out) or
http://. It can also be a link that opens a program on the machine you are looking from: a
handler registered for its own scheme, like claude://open for a terminal. timar draws that link
only if its scheme is listed in config.yaml:
link_schemes: [claude, pi]
servers:
- name: workstation
host: 10.0.0.8
user: deploy
web_url: "claude://open" # the form accepts it once claude is in link_schemes
The list lives only in the file, and the form cannot change it. Allowing a scheme decides what
this page may launch, and the page sits in front of a key that reaches every machine. Anything
not listed is refused, and the error names link_schemes. javascript, data, vbscript,
file and blob are refused even when listed: they run inside the page, not in another
program. A link to a program opens in place, with no new tab, because a new tab would be left
behind empty. The value is checked again every time the page is drawn. A hand-edited
javascript: therefore never reaches it, and removing a scheme from the list removes its links.
Container links stay http(s) only, because a container's address is also its health check.
Homelab machines are mostly off. Tools built for always-on fleets assume an agent that phones home — the one thing a sleeping machine cannot do. timar inverts it: waking the host is the first step of the job, and shutting it back down is the last.
qm. Any running machine can be shut down; one timar cannot wake again —
always-on, or switched on by hand — gets a confirmation that says it will stay off./reports, so a disk creeping upward or
an update failing every week shows as a series. Telegram delivery is a copy, not the only one. Each run's outcome is its
counts as coloured chips (red failed, yellow findings, green updated), and times read relatively
("2 hours ago", "in 8 hours") with the exact minute on hover.The written assessment sits above the findings it came from, never instead of them. A machine that was asleep is not checked, not clean — a sweep does not wake the fleet, and calling an unchecked host healthy is the one thing a status page must not do.
The reports page holds the two jobs at the top and every run they finished below.
A job runs on every server that is enrolled — timar has logged in to it with its key — and
not left out of that job. − leaves a server out after asking, + takes it back; a
server that is not enrolled links to its card's SSH section. The others are not woken and not
connected to, and the report names them as skipped. A VM is updated through its hypervisor, so
leaving the hypervisor out leaves its VMs out too. The lists are job_exclude in config.yaml.
A report opens over the list, so comparing runs does not lose your place:
Docker Compose projects are rows of the servers table, under the host they run on — one row per
project directory, however many containers it starts. Its address is where it is reached from
outside the host: its published ports (192.168.0.6:8090, 192.168.0.6:80,443 for several;
loopback-only ports do not count) — for one on the host's network, the TCP ports its processes
listen on, read with ss when the SSH user has passwordless sudo; else, for a service behind a
proxy, the name it is served under — its web address, or a Traefik Host() rule on its
containers. - when none of these says. Its system is Docker Container, and its directory is
in the name's tooltip. The counts above the table take the containers in too: a failing health
check counts as down, and unknown (the host is not up) appears only when there is one.
Sorted by address or system the rows are one list, without nesting.
Add one with the whale button by +: a name, the server it runs on, and the absolute
path of the directory holding its docker-compose.yml. Or let Find on a host, at the top of
that form, ask a server for its compose projects (docker compose ls): those not registered yet
are listed with their name, directory and a web address guessed from a Traefik Host() rule —
tick, adjust, Add selected. All ticked rows go in, or, if any is wrong, none.
docker ps -a per host over SSH, grouped by the compose
working-directory label, read in the background every minute — the page shows the last
answer and never waits for it. Green is running; yellow is running but a
container's healthcheck fails, a container exited with an error (a one-shot that exited 0 does
not count), or the optional health check address does not answer below HTTP 500; grey is
stopped and marked on-demand, or the host is not up and was not asked; red should run and
does not.vaultwarden/server
shows Vaultwarden. Project names are often not the application's (photos, vault), which is
why the images decide. A bundled database or proxy loses to the application it serves, and
anything not recognised, such as a local build, shows Docker's whale. The last logo seen is
kept, so a project on a host that is asleep looks the same as when it ran.docker compose up -d, stop and restart in the project's
directory. Start uses up -d because after a down the containers no longer exist./proc/self/mountinfo and refuses to take the page down with it.docker group or have passwordless sudo; timar tries plain
docker first and falls back to sudo -n docker.Updates. The update run updates each registered project right after its host, over the same connection — one line per project in the report:
update | What runs, in the project's directory |
|---|---|
pull (default) | docker compose pull --ignore-buildable, then docker compose up -d — only if the project was running |
custom | your update_cmd, for example docker compose up -d --build |
skip | nothing |
There is never a down first: a failed pull leaves the project as it was, where down + failed
pull would leave it removed. up -d recreates only the containers whose image changed. A
project that was not running is pulled and left stopped. Services with a build: section are not
pulled (rebuild them by hand or with a custom command). Unused images are pruned once per host
afterwards. timar's own project is always skipped — recreating itself would end the run halfway.
Log sweep. Stopped containers of a project marked on-demand are not reported; everything else that exited still is, registered or not.
The hardware page keeps what the fleet table cannot show — boards, cards, disks, which MAC
moved to which box — as Markdown documents, one tab each, the first one open. + uploads a
.md file or starts an empty one; the pencil switches a document to a text box.
/data/hardware/<name>.md, nothing else. Bind-mount that directory
(- ./hardware:/data/hardware beside the volume) to keep it in git or edit it with your own
tools; the container user (uid 1000) needs to be able to write it.title: in a leading --- block, or the first # heading, names a document's tab; order:
(a number) puts tabs first, the rest follow by title. A parts list for people as order: 1 and
a page of detailed notes for your scripts or agents beside it works well.| System log | Disk | Containers | Unattended updates | |
|---|---|---|---|---|
| Linux (systemd) | journalctl | ✅ | Docker | ✅ |
| Proxmox VE | journalctl | ✅ | — (guests via qm) | ✅ |
| OpenWrt | logread | ✅ | — | off by default |
| Windows 10/11 | Event Log (System, Application) | ✅ | — | ✅ Windows Update |
Each platform's update command, used when a server's own update_cmd is empty (the server
form shows them under platform default):
| Platform | Default update command |
|---|---|
| Linux (systemd) | sudo apt-get update -qq && sudo DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::=--force-confold upgrade --with-new-pkgs && sudo apt-get autoremove -y && sudo apt-get clean |
| Proxmox VE | apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get dist-upgrade -y && apt-get autoremove -y |
| OpenWrt | none |
| Windows | Windows Update (software, not drivers), as SYSTEM through a task that lasts the run |
--with-new-pkgs matters: plain apt-get upgrade holds back any update that needs a package
it does not have yet — a kernel ABI bump, a driver metapackage — so security kernels sit "kept
back" and are never installed, and nothing says so. Unlike full-upgrade it never removes a
package. --force-confold keeps a config file you edited instead of stopping at a prompt no
one will answer.
OpenWrt has no default update command on purpose: an unattended apk upgrade can fill the
overlay or land a kernel-module mismatch on the machine carrying the SSH session you would
repair it from. Set update_cmd yourself if you want it.
Windows needs its own OpenSSH Server, PowerShell as the login shell, and timar's key placed by hand — enrolment by password appends with POSIX commands Windows does not have. In an administrator PowerShell on the machine:
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0 # minutes; it comes from Windows Update
Set-Service sshd -StartupType Automatic; Start-Service sshd
# An administrator's keys are read from this file only, never from ~\.ssh\authorized_keys:
Set-Content $env:ProgramData\ssh\administrators_authorized_keys "<timar's public key>" -Encoding ascii
icacls $env:ProgramData\ssh\administrators_authorized_keys /inheritance:r /grant Administrators:F /grant SYSTEM:F
New-ItemProperty HKLM:\SOFTWARE\OpenSSH -Name DefaultShell -PropertyType String -Force `
-Value $env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe
Restart-Service sshd
timar's public key is on the settings page. Add the server with platform windows and an
administrator account, then press Enrol with the password left empty to check the key.
The update runs Windows Update as SYSTEM — an SSH login can search for updates but is refused the download. Drivers are left out on purpose, and so are games: Steam cannot sign in from a task while nobody is at the desktop (ARCHITECTURE.md has why), and it updates its games itself when it is opened. Before it starts, a game running out of a Steam library makes the machine in use: skipped, tried again next time. A restart the update asks for is done straight away when nobody is signed in — and before the shutdown, if the run woke the machine — and left pending, with a note in the report, when someone is.
A machine that should only be updated at certain hours — a gaming PC at night — gets a window
of its own in config.yaml:
- name: gaming-pc
platform: windows
wol_mac: "aa:bb:cc:dd:ee:ff"
update_window: "03:00-07:00" # may cross midnight: "23:00-05:00"
update_every_days: 7 # default
update_timeout: 14400
The fleet's update run then leaves it out (its report says when it was last updated), and it is
updated on its own the first night its window is open once update_every_days have passed:
woken if asleep, updated, put back. One attempt per window; a run that does not end in an update
— in use, did not wake, an update failed — is tried again the next night. Each of those runs is
archived and sent like any update run.
python3 -m venv .venv && .venv/bin/pip install -e '.[dev]'
.venv/bin/python -m pytest
TIMAR_DATA is the whole installation, so point it somewhere disposable to run against a
throwaway copy instead of your real fleet:
TIMAR_DATA=/tmp/timar-dev TIMAR_PORT=8099 .venv/bin/python -m timar.web
Contributions are welcome — see CONTRIBUTING.md. One habit up front: for anything that touches a machine, a network or a page, run it and put the measurement in the pull request. timar's failures are quiet — a magic packet that never leaves the host, a disk check that fails and reports all-clear — and a passing test does not always mean it works.
Commits are signed off under the DCO (git commit -s); there is no CLA. Design
decisions and their pitfalls are in ARCHITECTURE.md.
MIT © 2026 Orkun Soylu. Third-party components, including the vendored htmx, are listed in THIRD-PARTY-NOTICES.md.