Skip to content

Latest commit

 

History

History
259 lines (214 loc) · 14.2 KB

README.md

File metadata and controls

259 lines (214 loc) · 14.2 KB

ScriptRunner

A self-hosted web UI for running PowerShell and Batch scripts on a Windows host, with support for running a script under a different Windows account (optionally elevated), and role-based access control (Admin / User with per-script Execute/Write grants).

Read SECURITY.md before deploying this anywhere near production - it covers the two secrets you must configure, how the elevation/run-as mechanism works, and what to test on a real Windows box before trusting it (this project was built in a Linux sandbox with no Windows host available, so the Win32 interop has not been run for real - see the "Testing the elevation path" section).

Why Windows

PowerShell/Batch execution, real UAC-style elevation, and "run this script as a different Windows account" are all Win32 operating-system features - there's no elevation, no LogonUser, no Windows accounts to run as outside a Windows host. This app runs directly on Windows, either via dotnet run for development or, for production, installed as a Windows Service via the MSI installer (see "Installing as a Windows Service" below).

Architecture

src/
  ScriptRunner.Core   - domain models, execution engine (script types, Win32 process
                         launcher for elevation/run-as), credential encryption, auth
                         primitives. No ASP.NET or EF dependency - it's plain .NET.
  ScriptRunner.Data   - EF Core (SQLite) DbContext and startup seeding.
  ScriptRunner.Api    - ASP.NET Core Web API + SignalR hub (live script output) +
                         controllers + JWT auth + authorization policies + the
                         first-run setup wizard's backend (IDataDirectoryProvider,
                         SetupController, SetupGateMiddleware).
web/                  - React + TypeScript (Vite) single-page app.
installer/            - MSI installer (WiX) that packages the published app as a
                        Windows Service - see "Installing as a Windows Service" below.

Everything needed to add a new script type later (e.g. Python) is isolated behind IScriptExecutor in ScriptRunner.Core/Services/Execution - implement the interface, register it in Program.cs, and it shows up in the UI's type picker automatically.

Prerequisites

  • Windows 10/11 or Windows Server, with:
  • This project was built and its frontend was verified in a Linux sandbox that could not install the .NET SDK (no network access to nuget/dotnet in that environment), so the backend has not been compiled here - run dotnet build yourself as the first step and fix anything a real compiler flags. The React frontend, in contrast, has been installed, built, and linted successfully as part of building this.

First run

Local development (dotnet run) skips the setup wizard described below entirely - appsettings.Development.json points the database straight at a local scriptrunner.db file, which is treated as "setup already complete" (see IDataDirectoryProvider.IsConnectionStringOverridden). An installed Windows Service (the MSI, or Install-Service.ps1) instead shows a first-run web setup wizard - see "First run for an installed service" further down.

  1. Generate the two required secrets (see SECURITY.md for details) and set them as environment variables:

    $env:SCRIPTRUNNER_MASTER_KEY = [Convert]::ToBase64String([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32))
    $env:SCRIPTRUNNER_JWT_SIGNING_KEY = [Convert]::ToBase64String([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32))

    (Don't use Get-Random for this - it's a fast, statistically-random PRNG, not a cryptographically secure one, and both of these values are encryption/signing keys where that distinction matters. RandomNumberGenerator.GetBytes is the CSPRNG. If you're on Windows PowerShell 5.1 rather than PowerShell 7, use [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) instead - see SECURITY.md.)

    (Set these permanently via System Properties > Environment Variables, or in your Windows Service configuration - setx only affects future processes, not already-open shells.)

  2. Restore and run the API:

    cd src/ScriptRunner.Api
    dotnet restore
    dotnet run

    On first startup, if no admin account exists yet, one is created automatically as admin. If you set SCRIPTRUNNER_ADMIN_USERNAME/SCRIPTRUNNER_ADMIN_PASSWORD first, those are used; otherwise the account is created with a fixed default password (ScriptRunner - see DbSeeder.DefaultAdminPassword). This is a known, public credential, not a randomly-generated one - anyone with the source knows it, so treat it as a placeholder to change immediately after your first login, not as a real secret. A warning is printed to the console/log, and also written to a first-run-admin-password.txt file next to the database (handy for a headless Windows Service, where console output isn't visible) - delete that file once you've logged in and changed the password. Set SCRIPTRUNNER_ADMIN_PASSWORD before first run for anything beyond local testing so the default is never used at all.

    The SQLite database itself defaults to %ProgramData%\ScriptRunner\scriptrunner.db in every environment except local development (dotnet run, which uses scriptrunner.db right in src/ScriptRunner.Api/ for convenience - see appsettings.Development.json). Override either with ConnectionStrings:Default in config or the environment.

  3. In a second terminal, run the frontend dev server:

    cd web
    npm install
    npm run dev

    Open the URL Vite prints (default http://localhost:5173). It proxies /api and /hubs to the backend (see web/vite.config.ts - adjust the target port if your dotnet run printed a different one).

  4. Log in as the admin, then:

    • Admin > Credentials: add any Windows accounts you want scripts to be able to run as (and/or to authenticate to a network script share - see below).
    • Admin > Manage Scripts: create a script (PowerShell or Batch), either pasted directly into ScriptRunner ("Stored") or as a live reference to a UNC path ("Network reference" - see "Network-referenced scripts" below), declare its parameters, decide whether it needs elevation, and pick which stored credentials it's allowed to run as. Grant specific users Execute and/or Write access from the same screen.
    • Admin > Users: create accounts for your team.

Network-referenced scripts

A script can either be Stored (its content lives in ScriptRunner's own database, as it always has) or a Network reference - a UNC path (e.g. \\fileserver\scripts\deploy.ps1) that ScriptRunner reads live at the start of every run, instead of copying the content into the database. This is for teams that already keep a script repository elsewhere (a file share, a synced folder from source control, etc.) and want that share to stay the single source of truth - ScriptRunner always executes whatever's on the share right now, not a stale copy.

Pick "Network reference" in the script form (Admin > Manage Scripts), enter the UNC path, and optionally pick a stored credential to authenticate to that share with (reuses the same encrypted-at-rest credential store as run-as/elevation - see SECURITY.md). Use the Test connection button to verify the path is reachable and readable before saving; if it comes back "access denied," the form lets you pick a different credential (or links to Admin > Credentials to add one) right there. No credential is needed if the ScriptRunner service account itself already has read access to the share.

Under the hood this uses a one-shot Windows impersonation (LogonUser with LOGON32_LOGON_NEW_CREDENTIALS + WindowsIdentity) scoped to just that one file read, not a persistent mapped-drive-style session - see the comment on NetworkScriptContentReader for why that specifically avoids Windows's one-credential-per-server-per-session limitation for SMB/UNC connections, which would otherwise bite you the moment two scripts on the same server need different credentials.

Installing as a Windows Service

Recommended: build and run the MSI installer. .\build-installer.ps1 produces installer\bin\Release\ScriptRunnerSetup.msi, which installs the app to C:\Program Files\ScriptRunner, registers and starts the ScriptRunner service, generates SCRIPTRUNNER_MASTER_KEY/SCRIPTRUNNER_JWT_SIGNING_KEY and stores them scoped to that service's own registry key (not machine-wide), opens a firewall rule for the port, and sets %ProgramData%\ScriptRunner as the default data directory (you choose the real one from the web setup wizard - see "First run for an installed service" below). See installer/README.md for prerequisites (the WiX CLI), what exactly gets installed, upgrading, and uninstalling - including a note on what to budget for testing on your specific target machine.

First run for an installed service

Unlike local development, an installed service has no admin account and (until you choose one) no finalized data directory - browsing to http://<machine>:8080 after install lands you on a first-run setup wizard instead of a login page:

  1. Welcome - a reminder that the master key/JWT signing key were already generated automatically at install time (see above); this wizard only handles the data directory and admin account.
  2. Data directory - a folder-tree browser (backed by a small server-side API, since a browser can't open a native OS folder dialog against a remote machine's filesystem) for picking where the SQLite database lives. The default - %ProgramData%\ScriptRunner - works for most installs; browse to a different drive or folder if you'd rather, or create a new folder right from the browser.
  3. Admin account - choose the first admin's username and password. There's no fixed default password in this path (unlike the env-var/local-dev fallback described above) - you set a real one here.

Finishing the wizard signs you in as that admin account immediately - no separate login step. From then on GET /api/setup/status reports setup complete and every other API endpoint (previously blocked by SetupGateMiddleware with a 503 setup_required response) becomes available.

Skipping the wizard for scripted/unattended deployments: if SCRIPTRUNNER_ADMIN_USERNAME/SCRIPTRUNNER_ADMIN_PASSWORD are already set as environment variables before the service's first startup (the same variables used by local development, above), ScriptRunner auto-completes setup at the default data directory and creates that admin account immediately - the wizard never appears. This is the same "set env vars to skip the default-password path" behavior local development has always had; it now also skips the wizard entirely.

If you'd rather not use the MSI, Program.cs already calls builder.Host.UseWindowsService(), so a plain publish also runs as a normal Windows Service - installer\scripts\Install-Service.ps1 does this by hand (idempotent, handles the registry env vars and firewall rule for you):

dotnet publish src\ScriptRunner.Api -c Release -o C:\ScriptRunner
.\installer\scripts\Install-Service.ps1 -InstallDir C:\ScriptRunner -DataDir "$env:ProgramData\ScriptRunner"

or, doing it fully by hand with raw sc.exe (what the script above automates):

dotnet publish src/ScriptRunner.Api -c Release -o C:\ScriptRunner
sc.exe create ScriptRunner binPath= "C:\ScriptRunner\ScriptRunner.Api.exe" start= auto
sc.exe failure ScriptRunner reset= 86400 actions= restart/5000

Either way, set SCRIPTRUNNER_MASTER_KEY/SCRIPTRUNNER_JWT_SIGNING_KEY somewhere the service process will see them - not a user-level env var from your own login session, which a service running under a different account won't inherit. The MSI and Install-Service.ps1 both use the service's own registry Environment key (HKLM:\SYSTEM\CurrentControlSet\Services\ScriptRunner\Environment) so the secrets stay scoped to this one service rather than polluting every process on the machine; if you're setting them by hand instead, System Properties > Environment Variables (machine-level) also works.

Which account should the service run as? See SECURITY.md's "Choosing the service account" section - short version: LocalSystem is the simplest choice that reliably supports LogonUser/CreateProcessAsUser/elevation for other accounts, but consider a dedicated service account with just the privileges it needs if your security policy requires avoiding LocalSystem.

Extending script types

  1. Implement IScriptExecutor in ScriptRunner.Core/Services/Execution (see PowerShellExecutor/BatchExecutor for the pattern - you mostly need to build the right command line for your interpreter).
  2. Register it: builder.Services.AddSingleton<IScriptExecutor, YourExecutor>(); in Program.cs.
  3. Add its key to ScriptType.KnownTypes in ScriptRunner.Core/Models/ScriptType.cs so it shows up in the admin "New Script" type picker.

No other code needs to change - the execution pipeline, permission model, credential handling, and UI all key off IScriptExecutor.ScriptTypeKey/FileExtension.

What's intentionally out of scope for this first pass

  • EF Core Migrations (the app uses EnsureCreated() for simplicity). Switch to dotnet ef migrations once the schema stabilizes for your deployment. Practically: this means an existing scriptrunner.db from before a schema change (e.g. the SourceMode/NetworkPath/NetworkCredentialId columns added for network-referenced scripts) won't gain the new columns on its own - delete the old database (and any bootstrap.json next to it) and let ScriptRunner recreate it, or migrate the data by hand, after pulling a build with schema changes.
  • Audit-log export, script versioning/history, scheduled (cron-style) runs, and multi-node/HA deployment (the SQLite file and in-memory run-cancellation registry are single-instance).
  • Fine-grained rate limiting/lockout on login attempts.