Lightweight PowerShell agent for centralized script distribution, integrity verification, and scheduled execution across Windows endpoints.
Built to solve a real problem: distributing and running maintenance/automation scripts across a fleet of Windows PCs that don't share a domain controller or a full RMM (Remote Monitoring & Management) platform, using only an FTP server as the source of truth.
ITScripts/ ├── Agent.ps1 ├── Install.ps1 ├── README.md ├── LICENSE └── examples/ └── config.example.json
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ FTP Server │ ──────▶ │ Windows Agent │ ──────▶ │ Local Scripts │
│ │ │ (Task Scheduler,│ │ (per group, │
│ config.json │ │ runs as SYSTEM │ │ per schedule) │
│Scripts/*.ps1│ │ every 5 min) │ │ │
└─────────────┘ └──────────────────┘ └─────────────────┘
Install.ps1is run once, as administrator, on each target machine. It asks which group the machine belongs to, saves the FTP credentials and group intoagent-config.json, copiesAgent.ps1into place, and registers a Scheduled Task (running asSYSTEM) that triggers at startup and then every 5 minutes, forever.Agent.ps1runs on every trigger. Each run:- downloads the shared
config.jsonfrom the FTP server, - matches every script entry against the machine's group (with support for group inheritance, see below),
- downloads matching scripts, verifying their SHA-256 hash if one is provided,
- decides whether each script is actually due right now, based on its own
schedule(manual,interval,daily,weekly), - executes it, and logs everything to a rolling local log file.
- downloads the shared
The agent has no long-running process and no in-memory state: everything it needs to "remember" between runs (last execution time per script) is persisted to a small JSON state file, so it's resilient to reboots and crashes.
- Group-based script distribution, with one-way group inheritance (e.g. a
GROUP_A_SUBmachine gets everythingGROUP_Agets, plus its own scripts — but not the other way around). - Per-script scheduling independent of how often the agent itself is triggered:
manual— no automatic gating, purely controlled by anexecuteflaginterval— run every N minutesdaily— run once a day, at or after a given timeweekly— run once a week, on a given day and time
- Integrity verification via SHA-256 hash checking on downloaded scripts.
- Path traversal protection on script names and resolved download paths.
- Overlap protection, at two independent levels: a Scheduled Task setting (
MultipleInstances IgnoreNew) plus a named system Mutex inside the agent itself, so a manual test run and a scheduled run can never execute concurrently. - Self-healing schedule state: a corrupted or missing state file is transparently recreated rather than crashing the agent.
- Post-install verification: the installer re-reads the Scheduled Task right after creating it and confirms both triggers were actually saved (see "Lessons learned" below for why this matters).
{
"ftpServer": "ftp://ftp.example.com/ITScripts/",
"username": "ftp-user",
"password": "ftp-password",
"group": "GROUP_A"
}{
"scripts": [
{
"name": "cleanup.ps1",
"enabled": true,
"execute": true,
"group": "GROUP_A",
"sha256": "3B1A2C...",
"schedule": {
"type": "daily",
"time": "19:00"
}
},
{
"name": "healthcheck.ps1",
"enabled": true,
"execute": true,
"group": "ALL",
"schedule": {
"type": "interval",
"minutes": 5
}
},
{
"name": "weekly_report.ps1",
"enabled": true,
"execute": true,
"group": "GROUP_A_SUB",
"schedule": {
"type": "weekly",
"day": "Monday",
"time": "08:00"
}
}
]
}sha256is optional — if present, the download is rejected and discarded when it doesn't match.groupon a script is the minimum group required to run it; see group inheritance above.- Any script without a recognized
scheduleruns every time the agent finds it enabled and its group matches (legacy behavior).
On each target machine, as Administrator, with Install.ps1 and Agent.ps1 in the same folder:
powershell -ExecutionPolicy Bypass -File .\Install.ps1The installer will prompt for the target group and FTP credentials, then set everything up.
This project was built for an internal, trusted LAN with an FTP server under direct control — it is not hardened for a hostile network or an untrusted FTP host. Known limitations, by design trade-off rather than oversight:
- FTP is unencrypted. Credentials and script contents travel in plaintext. Use FTPS or move to an authenticated HTTPS endpoint if the network isn't fully trusted.
- Credentials are stored in plaintext in
agent-config.jsonon each machine's disk. - The SHA-256 check verifies integrity, not authenticity. Since both the script and its expected hash come from the same FTP server, a compromised server can update both consistently. There's no independent signing or trusted source verification.
- The agent runs as
SYSTEMand executes arbitrary downloaded code with-ExecutionPolicy Bypass. This is a supply-chain risk: control of the FTP server means code execution as SYSTEM on every enrolled machine. - No tamper-evidence on the local log or schedule-state files.
None of this is disqualifying for the use case it was built for (internal maintenance scripts on a controlled LAN), but it should be a conscious choice, not an assumption, before pointing this at anything more exposed.
New-ScheduledTaskTrigger -RepetitionDurationis oddly finicky. A large-but-finiteTimeSpan(e.g.New-TimeSpan -Days 3650) gets silently dropped byRegister-ScheduledTask— the task registers successfully, with no error, but ends up with only the startup trigger.[TimeSpan]::MaxValuefails loudly instead, with an explicit "value out of range" error. The only form that reliably produces an indefinite repeating trigger is to omit-RepetitionDurationentirely. This is why the installer verifies both triggers exist right after registration — the failure mode here is completely silent otherwise, and you won't notice until you wonder why scheduled scripts "sometimes" don't run.- Session 0 isolation makes background testing misleading. Any GUI program launched by a task running as
SYSTEM(or via WMI process creation) runs in Session 0, with no desktop to attach to — the process exists and runs successfully, but you'll never see a window, even testing with something as simple asnotepad.exeorcalc.exe(which, on modern Windows, is itself a thin launcher for a UWP app that fails to activate outside an interactive session). Debugging this required checking theSessionId(SI) column ofGet-Process, not just whether the process existed.
MIT