diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..105ca03
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,54 @@
+name: CI
+
+on:
+ push:
+ branches: [main, develop]
+ pull_request:
+ branches: [main, develop]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ # Oldest supported and current — catches syntax drift both ways.
+ python-version: ["3.9", "3.14"]
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+ cache: pip
+
+ - name: Install dependencies
+ run: pip install -r requirements-dev.txt
+
+ - name: Lint (ruff)
+ run: ruff check .
+
+ - name: Test (pytest)
+ run: python -m pytest -v
+
+ installer-hygiene:
+ # Guards bin/installer.sh (curled off main, run as root) against invisible-unicode
+ # paste corruption. The only legitimate non-ASCII lives in the spinner charsets
+ # (`local spin=` assignments); anything else is a stray ZWSP/NBSP/BOM and fails.
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Bash syntax check
+ run: bash -n bin/installer.sh
+
+ - name: Reject stray non-ASCII (paste corruption)
+ run: |
+ stray=$(grep -nP '[^\x00-\x7F]' bin/installer.sh | grep -vE 'local spin=' || true)
+ if [ -n "$stray" ]; then
+ echo "::error::Non-ASCII characters found outside the spinner charsets in bin/installer.sh:"
+ echo "$stray"
+ echo "These are almost certainly invisible paste corruption (zero-width space, NBSP, BOM)."
+ exit 1
+ fi
+ echo "OK: no stray non-ASCII in bin/installer.sh"
diff --git a/.gitignore b/.gitignore
index da0bd44..52157c2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -151,3 +151,15 @@ dmypy.json
# ruff
.ruff_cache
+
+# LLM generated files
+.claude/
+CLAUDE.md
+
+# JAWA runtime data
+data/server.json
+data/credentials.json
+data/cron.json
+data/webhooks.json
+resources/files/
+scripts/
diff --git a/README.md b/README.md
index 4e1042c..a7846b6 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,12 @@
-# Jamf Automation and Webhook Assistant ("JAWA") Version 3.1.1
+# Jamf Automation and Webhook Assistant ("JAWA") Version 3.2
JAWA allows an IT Administrator to focus on providing the best end user experience through automation.
+> **Prefer a hosted option?** JAWA is self-hosted — you run and maintain the server yourself. If you'd rather not operate infrastructure, **[Jamf Routines](https://learn.jamf.com/r/en-US/jamf-routines-documentation/jamf_workflow_automation)** is a Jamf-hosted, Jamf-supported automation service. You can run both — see [JAWA vs. Jamf Routines](#jawa-vs-jamf-routines) below.
+
***[!]** NOTE: Always test automations in a dev/eval environment before deploying to production.*
## What is JAWA?
@@ -16,15 +18,59 @@ JAWA, the Jamf Automation and Webhook Assistant, is a web server designed to str
*Read the [JAWA Admin Guide](https://github.com/jamf/JAWA/wiki) too!*
+## JAWA vs. Jamf Routines
+
+JAWA and [Jamf Routines](https://learn.jamf.com/r/en-US/jamf-routines-documentation/jamf_workflow_automation) both automate Jamf Pro workflows, in different ways.
+
+| | **JAWA** | **Jamf Routines** |
+|---|---|---|
+| **Hosting** | Self-hosted (your server) | Jamf-hosted |
+| **Maintenance** | You own the OS, TLS, updates, and uptime | Managed by Jamf |
+| **Support** | Community / open source | Jamf-supported |
+| **Automation model** | Your own scripts, triggered by webhooks or a schedule | Template-based workflows that connect tools to Jamf Pro |
+| **Setup effort** | Provision a server, certificate, and DNS | Sign in and go |
+
+**Choose JAWA** if you want full control, custom scripting, and don't mind running a server. **Choose Jamf Routines** if you'd rather not manage infrastructure and want a Jamf-supported, hosted experience. You can run both — they complement each other. For Jamf Routines availability and pricing, see the [Jamf Routines documentation](https://learn.jamf.com/r/en-US/jamf-routines-documentation/jamf_workflow_automation).
+
## Server Requirements
### General Server Requirements:
-- Ubuntu 20.04+ or RHEL 8.x+
+- Ubuntu 22.04+ or RHEL / Rocky 9.x+
- Minimum: 8GB RAM (16GB recommended)
+ - On RHEL 9, do not go below 2GB even for a trial: `dnf` is memory-hungry enough that package
+ installs get OOM-killed on a 1GB instance. Ubuntu completes on 1GB.
- Minimum: 128GB Storage (512GB recommended)
- Minimum: 2 CPU Core (4 Cores recommended)
-- Python 3.8+ (with pip)
+- Python 3.9+ (with pip)
+
+> The installer uses the distribution's default `python3` to build JAWA's virtual environment, so
+> the OS version is what determines the Python version. Ubuntu 20.04 ships Python 3.8 and
+> RHEL/Rocky 8 ships Python 3.6, neither of which satisfies JAWA's dependencies — use Ubuntu 22.04
+> or later, or RHEL/Rocky 9 or later.
+>
+> **Upgrading the OS of an existing JAWA server.** If your JAWA host is on Ubuntu 20.04, reaching a
+> supported platform means an in-place distribution upgrade (`do-release-upgrade`). That upgrade can
+> leave nginx no longer serving JAWA — the console becomes unreachable even though the `jawa`
+> service is running. **Re-run the JAWA installer after the distribution upgrade completes** and
+> choose the upgrade option; it rewrites and re-enables JAWA's nginx site, which restores the
+> console. Take a snapshot of the server before starting, as with any distribution upgrade.
+>
+> The installer checks this **before it touches an existing install** and stops with the detected
+> version if the host is below Python 3.9. The specific blocker is Werkzeug: its patched releases
+> require Python 3.9 or later, and no patched Werkzeug exists for 3.8, so a 3.8 host cannot run
+> JAWA without a known security advisory.
+>
+> If you accept that advisory and need to install on Python 3.8 anyway, set
+> `JAWA_ALLOW_UNPATCHED_WERKZEUG=1`:
+>
+> ```bash
+> sudo JAWA_ALLOW_UNPATCHED_WERKZEUG=1 bash ./installer.sh
+> ```
+>
+> This holds Werkzeug at 3.0.6 — the newest release available to Python 3.8 — and prints the
+> advisory you are accepting. It is a stopgap for hosts that cannot be upgraded yet, not a
+> supported configuration.
### Network Requirements:
@@ -80,10 +126,235 @@ When scripting for webhooks, verify JSON structure sent from source:
1. [Jamf Pro Webhook Event Info](https://developer.jamf.com/developer-guide/docs/webhooks)
2. [Okta Webhook Event Info](https://developer.okta.com/docs/reference/api/event-types/?q=event-hook-eligible)
+## Writing Automation Scripts
+
+JAWA runs **your** scripts in response to Jamf Pro (or Okta/custom) webhooks and on a schedule. A script can be written in any language JAWA's host can execute; the examples here are Python. This section describes the contract JAWA uses to call your script.
+
+### How JAWA calls a webhook script
+
+When a webhook fires, JAWA executes your script and passes the **entire event payload as a single JSON string in the first command-line argument** (`sys.argv[1]`). It does not use stdin, environment variables, or a file. Your script's first job is to parse it:
+
+```python
+import json
+import sys
+
+event_data = json.loads(sys.argv[1]) # the whole webhook payload
+```
+
+A Jamf Pro webhook payload has two top-level keys:
+
+- `event_data["webhook"]` — event metadata (`webhookEvent`, `eventTimestamp`, `id`)
+- `event_data["event"]` — the event's own fields (for example `groupAddedDevicesIds`, `name`)
+
+### Output and status (webhook automations)
+
+- Anything your script prints (stdout and stderr) is captured line-by-line into the JAWA log under the automation's name. Use `print()` for progress and diagnostics.
+- Exit `0` for success. A **non-zero exit code is recorded as a failure** in the log.
+
+### Credentials
+
+JAWA does not inject Jamf Pro or third-party credentials into your script. A script that calls the Jamf Pro API authenticates itself (for example, requesting its own OAuth token). Store secrets in your script's own configuration, not in JAWA.
+
+### Scheduled (timed) automations
+
+A timed automation runs your script on a schedule with **no webhook payload** — `sys.argv[1]` is not present. If one script serves both paths, guard for it:
+
+```python
+import json
+import sys
+
+event_data = json.loads(sys.argv[1]) if len(sys.argv) > 1 else {}
+```
+
+Timed automations run under the system's cron, so a script's output and exit status are handled by cron (for example, in the host's mail/syslog), not captured in the JAWA log.
+
+### Complete example
+
+This bundled script (`data/workflows/scripts/smart_group_slack.py`) posts to Slack when devices join a smart group:
+
+```python
+#!/usr/bin/env python3
+"""Send Slack notification on smart group membership change.
+
+Webhook event: SmartGroupComputerMembershipChange
+"""
+
+import json
+import requests
+import sys
+from datetime import datetime
+
+SLACK_WEBHOOK_URL = "https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
+
+
+def main():
+ event_data = json.loads(sys.argv[1])
+ event = event_data["event"]
+
+ id_list = event.get("groupAddedDevicesIds", [])
+ if not id_list:
+ print("No devices entered the group.")
+ sys.exit(12)
+
+ group_name = event.get("name", "Unknown Group")
+
+ slack_data = {
+ "attachments": [
+ {
+ "title": f"Smart Group Update: {group_name}",
+ "text": f"{len(id_list)} device(s) added to {group_name}",
+ "footer": "JAWA Webhook Automation",
+ "ts": datetime.timestamp(datetime.now()),
+ }
+ ]
+ }
+
+ resp = requests.post(
+ SLACK_WEBHOOK_URL,
+ data=json.dumps(slack_data),
+ headers={"Content-Type": "application/json"},
+ )
+ print(f"Slack notification: {resp.status_code}")
+
+
+if __name__ == "__main__":
+ main()
+```
+
+### Common mistakes
+
+- **The payload is `sys.argv[1]`, not stdin, not an environment variable, and not a file.**
+- **It is a JSON *string*** — you must `json.loads()` it before use.
+- **The event fields are nested** under `event_data["event"]`, not at the top level.
+- **Don't assume JAWA provides a Jamf Pro token** — your script authenticates itself.
+- **On the timed path there is no `sys.argv[1]`** — guard for it if a script serves both.
+
+## Planned for future releases
+
+Intended direction, not commitments to a date. Listed here so you can plan around the ones that
+change existing behaviour.
+
+- **Authenticated-by-default template automations.** Template webhooks currently run without
+ authentication unless you add it in the automation's edit screen. A future release will require
+ an authentication choice when enabling a template, and the **"None" option is deprecated and will
+ be removed** — so if you rely on unauthenticated template webhooks today, plan to add
+ authentication to them.
+- **Brute-force protection for the console login.** The console authenticates against Jamf Pro, so
+ repeated failed logins are attempts against a Jamf Pro account. A future release will rate-limit
+ or ban repeated failures. `fail2ban` is installed by the installer today but is not yet wired to
+ JAWA's login — it does not currently protect the console.
+- **More webhook authentication types.** Jamf Pro offers Mutual TLS and Hash Signature (HMAC) for
+ outbound webhooks; JAWA currently validates None, Basic, and a single API-key header. Support for
+ the remaining two is planned. If you select Mutual TLS or Hash Signature in Jamf Pro today, JAWA
+ has no matching validation for it.
+- **Better certificate handling.** Clearer full-chain validation at install time, an optional
+ ACME / Let's Encrypt path, and certificate expiry visibility and replacement from the console
+ instead of over SSH.
+- **Named Jamf Pro instances.** Give your primary and secondary Jamf Pro servers friendly labels,
+ shown on the login page in place of their hostnames.
+- **HTTP requests redirect to HTTPS.** Reaching the server over `http://` currently leads nowhere;
+ a future release will redirect it to the console.
+
## Releases
Find JAWA releases [here.](https://github.com/jamf/JAWA/releases)
+### JAWA v3.2.0 release
+
+**Upgrade notes — please read before upgrading**
+
+- **v3.2 is the last release that can migrate a JAWA v2 install.** The v2 upgrade path works in
+ this release and is unchanged. If you are still on v2, move to v3.2 before upgrading further.
+- **Template webhooks you previously enabled will begin firing.** A bug meant enabled and
+ imported template webhooks silently never triggered. Template webhooks run **without
+ authentication by default** — anyone who knows the hook name can trigger one. Review your
+ enabled templates and add webhook authentication in the automation's edit screen if an
+ endpoint should be protected. Authenticated-by-default templates are planned — see
+ [Planned for future releases](#planned-for-future-releases).
+- **New webhook names are validated more strictly.** `#` and `%` are no longer accepted in a new
+ webhook name, because Jamf Pro cannot call a URL containing them. Existing automations are
+ unaffected.
+- **Minimum platform is now Ubuntu 22.04 or RHEL/Rocky 9, and Python 3.9.** The installer builds
+ JAWA's virtual environment from the distribution's default `python3`, and JAWA's dependencies no
+ longer support Python 3.8. Ubuntu 20.04 (Python 3.8) and RHEL/Rocky 8 (Python 3.6) can no longer
+ run JAWA — RHEL/Rocky 8 in fact stopped being able to when JAWA moved to Flask 3, which the
+ stated requirements had not caught up with. Check `python3 --version` on the host before
+ upgrading. The installer now enforces this itself: it verifies the Python version **before**
+ backing up or removing anything, so an unsupported host is refused with its existing install
+ intact rather than left with a dead service. If you must install on Python 3.8 and accept an
+ unpatched Werkzeug, see `JAWA_ALLOW_UNPATCHED_WERKZEUG` under Server Requirements.
+ **If you get to a supported platform by running `do-release-upgrade` on an existing JAWA server,
+ re-run the JAWA installer afterwards** — a distribution upgrade can leave nginx no longer serving
+ JAWA, so the console goes unreachable while the `jawa` service itself is fine. Re-running the
+ installer rewrites and re-enables the nginx site and restores the console.
+- **Content JAWA ships inside `data/` is not upgraded in place.** The installer preserves your
+ `data/` directory across an upgrade, which protects your automations and settings, but it also
+ means the bundled template scripts and the webhook event catalog stay at the version you first
+ installed. A fresh install gets the current copies.
+
+- New features
+ - **Bundled templates now work as shipped.** Every bundled template runs when triggered; two
+ were incomplete sketches that failed immediately. Enabling a template also creates the
+ matching webhook in Jamf Pro for you and files the automation under Jamf Pro, so its
+ trigger event is visible and editable. Templates can be protected with Basic
+ authentication at enable time.
+ - **Importing a template package can create its webhook in Jamf Pro too** — a new *Create
+ webhook in Jamf Pro?* option, on by default. Clear it to install the script locally only.
+ Because the package name becomes part of the URL Jamf Pro calls, a name with spaces or
+ other URL-unsafe characters is refused on that path; fix the package file, or clear the box
+ to install locally under any name.
+ - **Webhook Reference page** documenting the Jamf Pro webhook events with sample payloads.
+ One event, `DeviceRateLimited`, is listed with its sample payload still pending.
+ - **Admin-configurable session timeout** in Setup: 15 minutes (default), 1 hour, 4 hours, or
+ 8 hours, with hardened session cookies. The 15-minute default remains the most secure; the
+ longer options are convenient for workflow testing but leave an unattended signed-in
+ console exposed for longer. Choose deliberately.
+ - Smoke-test harness and CI (ruff + pytest) running on every push and pull request.
+ - Documentation for writing automation scripts.
+ - Script Preview and Download Script now show the real substitution tokens rather than
+ generic placeholder text, so a downloaded script is self-documenting.
+ - Resource Files listing gained Size and Type columns.
+- Bugfixes
+ - Template webhooks now fire (see upgrade notes).
+ - Configuration values containing `&`, quotes, or angle brackets — Microsoft Teams and Power
+ Automate URLs, and some secrets — are no longer corrupted when written into a generated
+ script.
+ - Selecting a saved credential set on the template enable form now hides the server URL,
+ client ID and client secret fields it supplies, instead of showing empty fields alongside a
+ hint that claimed they would be auto-filled. A value typed into one of those fields was
+ previously discarded without warning, because the saved set takes precedence. Fields a
+ partial credential set cannot supply stay visible.
+ - Enabling a template no longer stores authentication values that locked the webhook out.
+ - Imported template packages are validated before installation: a `.jawa.json` whose script
+ is truncated or has a syntax error is rejected with the offending line number, instead of
+ installing a webhook that fails silently when it fires.
+ - Fixed a crash on templates whose trigger event was a boolean.
+ - The 401 response from an inbound webhook no longer echoes the requested hook name back.
+ - Rejected path traversal in template package import, and guarded the legacy redirect routes
+ against open redirects.
+ - Resource Files page: Download and Delete are no longer adjacent, identical buttons, Delete
+ routes through the shared confirmation screen, and hidden files no longer leak into the
+ listing.
+ - The success-page Back button no longer re-submits the action it just completed.
+ - Corrected dashboard links and removed dead Extras links.
+ - Uploads between 1 MB and 16 MB no longer fail with an opaque error; the server upload cap
+ is now set explicitly.
+ - Setup strips trailing slashes from Jamf Pro URLs, so generated webhook URLs no longer
+ contain double slashes.
+ - Script uploads with no `#!` shebang are rejected with a clear message instead of failing
+ cryptically at trigger time.
+ - The session-timeout warning now survives laptop sleep and backgrounded tabs.
+ - Fixed resource file deletion, added 403/405/500 error pages, and hardened receiver edge
+ cases including malformed form payloads.
+ - The "Setup Required" error page links directly to Setup.
+- Removed
+ - The *Enrollment Pipeline* template, which shipped as an incomplete outline and needs a
+ device-assignment CSV contract that will be designed properly in a future release.
+- Repository maintenance
+ - Removed dead code (legacy MongoEngine, stale stubs).
+ - `data/cron.json` is no longer tracked in git, so a checkout can no longer overwrite real
+ cron definitions with an empty seed file.
+
### JAWA v3.1.1 release
- Bugfix
- Resolved #49
diff --git a/app.py b/app.py
index 59b04d4..6b9d6cc 100644
--- a/app.py
+++ b/app.py
@@ -1,6 +1,6 @@
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
#
-# Copyright (c) 2024 Jamf. All rights reserved.
+# Copyright (c) 2026 Jamf. All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@@ -43,36 +43,79 @@
)
from markupsafe import escape
from waitress import serve
-from typing import Any, Dict, Union
+from typing import Any, Dict, Tuple, Union
from bin import logger
+from bin.context_processors import inject_common_vars, register_static_cache_bust
+from bin.url_safety import safe_path_segment
from bin.view_modifiers import response
-from views.home_view import load_home
+from views.home_view import _strip_trailing_slash, load_home
# Flask logging
logthis = logger.setup_child_logger("jawa", "app")
error_message = ""
+SESSION_TIMEOUT_CHOICES = (15, 60, 240, 480)
+DEFAULT_SESSION_TIMEOUT = 15
+
+
+def _resolve_session_timeout(config: dict) -> int:
+ """Resolve the configured session timeout (minutes) against the
+ allowed ladder. Any missing / malformed / off-ladder value fails
+ safe to the 15-minute default (never longer)."""
+ value = config.get("session_timeout_minutes")
+ if type(value) is int and value in SESSION_TIMEOUT_CHOICES:
+ return value
+ return DEFAULT_SESSION_TIMEOUT
+
# Initiate Flask
app = Flask(__name__)
+# Secure cookies require HTTPS. JAWA runs HTTPS behind nginx in
+# production, so Secure defaults on. A Secure cookie is dropped by the
+# browser over plain http, which breaks local `python3 app.py` runs
+# (login succeeds but the session cookie never returns -> login loop).
+# Set JAWA_INSECURE_COOKIES=1 for local http development only.
+_secure_cookies = os.environ.get("JAWA_INSECURE_COOKIES") != "1"
+app.config.update(
+ SESSION_COOKIE_SECURE=_secure_cookies,
+ SESSION_COOKIE_HTTPONLY=True,
+ SESSION_COOKIE_SAMESITE="Lax",
+ MAX_CONTENT_LENGTH=16 * 1024 * 1024,
+)
-# Session heartbeat
+# Session heartbeat: slide the window and apply the admin-configured
+# timeout (fail-safe to 15 min) on every request, so a /setup change
+# takes effect immediately with no restart.
@app.before_request
-def func() -> None:
+def _session_heartbeat() -> None:
+ from bin import data_store
+
+ minutes = _resolve_session_timeout(data_store.get_server_config())
+ app.permanent_session_lifetime = timedelta(minutes=minutes)
session.modified = True
+app.context_processor(inject_common_vars)
+register_static_cache_bust(app)
+
+
def main() -> None:
base_dir = os.path.dirname(__file__)
logthis.info(f"JAWA initializing...\n Sandcrawler home: {base_dir}")
- environment_setup(base_dir)
- register_blueprints()
- app.secret_key = str(uuid.uuid4())
- app.permanent_session_lifetime = timedelta(minutes=10)
- serve(
- app, url_scheme="https", host="0.0.0.0", port=8000, threads=15
- ) # Serve me the sky with a big slice of lemon
+ try:
+ environment_setup(base_dir)
+ register_blueprints()
+ app.secret_key = str(uuid.uuid4())
+ app.permanent_session_lifetime = timedelta(minutes=15)
+ serve(
+ app, url_scheme="https", host="0.0.0.0", port=8000, threads=15
+ ) # Serve me the sky with a big slice of lemon
+ except Exception as err:
+ logthis.critical(
+ f"JAWA failed to start: {err}. Check port availability."
+ )
+ raise
def environment_setup(project_dir: str) -> None:
@@ -99,18 +142,6 @@ def register_blueprints() -> None:
from webhook import jawa_receiver
app.register_blueprint(jawa_receiver.blueprint)
- # Jamf Pro Webhooks view
- from views import jamf_webhook
-
- app.register_blueprint(jamf_webhook.blueprint)
- # Okta Webhooks view
- from views.okta_webhook import blueprint
-
- app.register_blueprint(blueprint)
- # Create a new Cron Job
- from views.cron_view import blueprint
-
- app.register_blueprint(blueprint)
# Log view
from views import log_view
@@ -119,20 +150,130 @@ def register_blueprints() -> None:
from views import resource_view
app.register_blueprint(resource_view.blueprint)
- # Custom Webhooks view
- from views import custom_webhook
+ # Template catalog, enable, and import view
+ from views import template_view
+
+ app.register_blueprint(template_view.blueprint)
+ # Search view
+ from views import search_view
+
+ app.register_blueprint(search_view.blueprint)
+ # Webhook event reference (read-only docs)
+ from views import reference_view
+
+ app.register_blueprint(reference_view.blueprint)
+ # Credential management view
+ from views import credential_view
- app.register_blueprint(custom_webhook.blueprint)
- # Webhooks Base view
- from views import webhook_view
+ app.register_blueprint(credential_view.blueprint)
+ # Unified Automations view
+ from views import automation_view
- app.register_blueprint(webhook_view.blueprint)
+ app.register_blueprint(automation_view.blueprint)
# Home, Dashboard and Login view
from views import home_view
app.register_blueprint(home_view.blueprint)
+# --- Backward-compatibility 301 redirects ---
+# Old webhook routes → new /automations/ routes
+
+
+@app.route("/webhooks")
+def _redir_webhooks():
+ return redirect("/automations", code=301)
+
+
+@app.route("/webhooks/jamf")
+def _redir_jamf_list():
+ return redirect("/automations/jamfpro", code=301)
+
+
+@app.route("/webhooks/jamf/new")
+def _redir_jamf_new():
+ return redirect("/automations/jamfpro/new", code=301)
+
+
+@app.route("/webhooks/jamf/edit")
+def _redir_jamf_edit():
+ name = safe_path_segment(request.args.get("name", ""))
+ if not name:
+ return redirect("/automations/jamfpro", code=301)
+ return redirect(f"/automations/jamfpro/{name}/edit", code=301)
+
+
+@app.route("/webhooks/okta")
+def _redir_okta_list():
+ return redirect("/automations/okta", code=301)
+
+
+@app.route("/webhooks/okta/new")
+def _redir_okta_new():
+ return redirect("/automations/okta/new", code=301)
+
+
+@app.route("/webhooks/custom")
+def _redir_custom_list():
+ return redirect("/automations/custom", code=301)
+
+
+@app.route("/webhooks/custom/new")
+def _redir_custom_new():
+ return redirect("/automations/custom/new", code=301)
+
+
+@app.route("/webhooks/custom/edit")
+def _redir_custom_edit():
+ name = safe_path_segment(request.args.get("name", ""))
+ if not name:
+ return redirect("/automations/custom", code=301)
+ return redirect(f"/automations/custom/{name}/edit", code=301)
+
+
+@app.route("/cron")
+def _redir_cron_list():
+ return redirect("/automations/cron", code=301)
+
+
+@app.route("/cron/new")
+def _redir_cron_new():
+ return redirect("/automations/cron/new", code=301)
+
+
+@app.route("/cron/edit")
+def _redir_cron_edit():
+ name = safe_path_segment(request.args.get("name", ""))
+ if not name:
+ return redirect("/automations/cron", code=301)
+ return redirect(f"/automations/cron/{name}/edit", code=301)
+
+
+@app.route("/cron/delete")
+def _redir_cron_delete():
+ name = safe_path_segment(request.args.get("target_job", ""))
+ if not name:
+ return redirect("/automations/cron", code=301)
+ return redirect(f"/automations/cron/{name}/delete", code=301)
+
+
+@app.route("/webhooks/delete")
+def _redir_webhook_delete():
+ name = safe_path_segment(request.args.get("target_webhook", ""))
+ if not name:
+ return redirect("/automations", code=301)
+ # Need to look up the tag to route properly
+ from bin.data_store import get_webhook_by_name
+
+ webhook = get_webhook_by_name(name)
+ tag = (
+ safe_path_segment(webhook.get("tag", "custom"))
+ if webhook
+ else "custom"
+ )
+ return redirect(f"/automations/{tag}/{name}/delete", code=301)
+
+
# Server setup including making .json file necessary for webhooks
@app.route("/setup", methods=["GET", "POST"])
def setup() -> Union[Response, str]:
@@ -148,12 +289,21 @@ def setup() -> Union[Response, str]:
logthis.debug(
f"[{session.get('url')}] {session.get('username')} /setup - POST"
)
- server_url = request.form.get("address")
+ server_url = _strip_trailing_slash(request.form.get("address") or "")
if not server_url:
return redirect(url_for("setup"))
- jps_url = request.form.get("jss-lock")
+ jps_url = _strip_trailing_slash(request.form.get("jss-lock") or "")
jps2_check = request.form.get("alternate-jamf")
- jps_url2 = request.form.get("alternate")
+ jps_url2 = _strip_trailing_slash(request.form.get("alternate") or "")
+ timeout_raw = request.form.get("session_timeout_minutes", "")
+ try:
+ timeout_val = int(timeout_raw)
+ except (TypeError, ValueError):
+ timeout_val = DEFAULT_SESSION_TIMEOUT
+ # Clamp to the allowed ladder; never store an off-ladder value.
+ session_timeout = _resolve_session_timeout(
+ {"session_timeout_minutes": timeout_val}
+ )
logthis.info(
f"{session.get('username')} made JAWA Setup Changes\n"
f"JAWA URL: {server_url}\n"
@@ -172,6 +322,7 @@ def setup() -> Union[Response, str]:
"jawa_address": server_url,
"jps_url": jps_url,
"alternate_jps": jps_url2,
+ "session_timeout_minutes": session_timeout,
}
json.dump(server_json, outfile)
elif os.path.isfile(server_json_file):
@@ -180,6 +331,7 @@ def setup() -> Union[Response, str]:
"jawa_address": server_url,
"jps_url": jps_url,
"alternate_jps": jps_url2,
+ "session_timeout_minutes": session_timeout,
}
json.dump(server_json, outfile)
with open(server_json_file, "r") as fin:
@@ -188,11 +340,8 @@ def setup() -> Union[Response, str]:
with open(server_json_file, "w+") as outfile:
json.dump(data, outfile)
- return render_template(
- "success.html",
- webhooks="success",
- success_msg="JAWA Setup Complete!",
- username=str(escape(session["username"])),
+ return redirect(
+ url_for("success", success_msg="JAWA Setup Complete!")
)
else:
logthis.debug(
@@ -208,6 +357,7 @@ def setup() -> Union[Response, str]:
json.dump(server_json, outfile)
with open(server_json_file, "r") as fin:
server_json = json.load(fin)
+ session_timeout = _resolve_session_timeout(server_json)
jps_url2 = server_json.get("alternate_jps")
if jps_url2 == str(escape(session["url"])):
primary_jps = server_json["jps_url"]
@@ -220,6 +370,7 @@ def setup() -> Union[Response, str]:
jps_url=primary_jps,
jps_url2=jps_url2,
jawa_url=jawa_url,
+ session_timeout=session_timeout,
username=session.get("username"),
)
@@ -288,6 +439,14 @@ def success(success_msg="") -> Union[Response, str]:
error_message="Please sign in again",
)
)
+ flashed = session.pop("success_ctx", None)
+ if flashed:
+ return render_template(
+ "success.html",
+ login="true",
+ username=str(escape(session["username"])),
+ **flashed,
+ )
success_msg = request.args.get("success_msg")
if success_msg:
success_msg = escape(success_msg)
@@ -309,19 +468,19 @@ def error() -> Union[Response, str]:
error_message = escape(error_message)
if "username" not in session:
return redirect(url_for("home_view.logout"))
- logthis.info(
+ logthis.warning(
f"[{session.get('url')}] {session.get('username').title()} was a victim of a series of accidents, as are we all. (/error)"
)
return render_template(
"error.html",
username=session.get("username"),
- error_message=error_title,
- error=error_message,
+ error=error_title,
+ error_message=error_message,
)
@app.errorhandler(404)
-def page_not_found() -> Union[Response, str]:
+def page_not_found(e) -> Union[Response, str]:
if "username" in session:
logthis.info(
f"[{session.get('url')}] {session.get('username')} wandered off course ({request.path}) - redirecting to /dashboard."
@@ -333,5 +492,55 @@ def page_not_found() -> Union[Response, str]:
return load_home()
+@app.errorhandler(500)
+def internal_error(e) -> Union[Response, Tuple[str, int]]:
+ logthis.exception(
+ f"500 at {request.path} for "
+ f"{session.get('username', 'anonymous')}"
+ )
+ if "username" in session:
+ return (
+ render_template(
+ "error.html",
+ username=session.get("username"),
+ error="Something went wrong",
+ error_message="An unexpected error occurred. "
+ "The details have been logged.",
+ ),
+ 500,
+ )
+ return load_home(), 500
+
+
+@app.errorhandler(403)
+def forbidden(e) -> Union[Response, Tuple[str, int]]:
+ if "username" in session:
+ return (
+ render_template(
+ "error.html",
+ username=session.get("username"),
+ error="Forbidden",
+ error_message="You do not have access to that resource.",
+ ),
+ 403,
+ )
+ return load_home(), 403
+
+
+@app.errorhandler(405)
+def method_not_allowed(e) -> Union[Response, Tuple[str, int]]:
+ if "username" in session:
+ return (
+ render_template(
+ "error.html",
+ username=session.get("username"),
+ error="Method not allowed",
+ error_message="That action isn't allowed here.",
+ ),
+ 405,
+ )
+ return load_home(), 405
+
+
if __name__ == "__main__":
main()
diff --git a/bin/auth.py b/bin/auth.py
new file mode 100644
index 0000000..dfe61b6
--- /dev/null
+++ b/bin/auth.py
@@ -0,0 +1,52 @@
+# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
+#
+# Copyright (c) 2026 Jamf. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+# * Neither the name of the Jamf nor the names of its contributors may be
+# used to endorse or promote products derived from this software without
+# specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY JAMF SOFTWARE, LLC "AS IS" AND ANY
+# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL JAMF SOFTWARE, LLC BE LIABLE FOR ANY
+# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
+
+from functools import wraps
+from typing import Any, Callable, TypeVar
+
+from flask import redirect, session, url_for
+
+F = TypeVar("F", bound=Callable[..., Any])
+
+
+def login_required(f: F) -> F:
+ """Decorator that redirects to logout if no active session."""
+
+ @wraps(f)
+ def decorated(*args: Any, **kwargs: Any) -> Any:
+ if "username" not in session:
+ return redirect(
+ url_for(
+ "home_view.logout",
+ error_title="Session Timed Out",
+ error_message="Please sign in again",
+ )
+ )
+ return f(*args, **kwargs)
+
+ return decorated # type: ignore
diff --git a/bin/context_processors.py b/bin/context_processors.py
new file mode 100644
index 0000000..04b9b99
--- /dev/null
+++ b/bin/context_processors.py
@@ -0,0 +1,66 @@
+# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
+#
+# Copyright (c) 2026 Jamf. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+# * Neither the name of the Jamf nor the names of its contributors may be
+# used to endorse or promote products derived from this software without
+# specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY JAMF SOFTWARE, LLC "AS IS" AND ANY
+# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL JAMF SOFTWARE, LLC BE LIABLE FOR ANY
+# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
+
+import hashlib
+import os
+
+from flask import Flask, session
+
+
+def _compute_static_hash(static_folder: str) -> str:
+ """Return a short MD5 hex digest of the max mtime across all static files."""
+ max_mtime = 0.0
+ for root, _dirs, files in os.walk(static_folder):
+ for fname in files:
+ mtime = os.path.getmtime(os.path.join(root, fname))
+ if mtime > max_mtime:
+ max_mtime = mtime
+ return hashlib.md5(str(max_mtime).encode()).hexdigest()[:10]
+
+
+def register_static_cache_bust(app: Flask) -> None:
+ """Append ``?v=`` to every ``url_for('static', ...)`` URL."""
+ static_hash = _compute_static_hash(app.static_folder)
+
+ @app.url_defaults
+ def _add_static_hash(endpoint: str, values: dict) -> None:
+ if endpoint == "static":
+ values["v"] = static_hash
+
+
+def inject_common_vars() -> dict:
+ """Auto-inject session variables into all templates."""
+ from app import _resolve_session_timeout
+ from bin import data_store
+
+ minutes = _resolve_session_timeout(data_store.get_server_config())
+ return {
+ "username": session.get("username"),
+ "session_url": session.get("url"),
+ "session_timeout_seconds": minutes * 60,
+ }
diff --git a/bin/data_store.py b/bin/data_store.py
new file mode 100644
index 0000000..1aa8142
--- /dev/null
+++ b/bin/data_store.py
@@ -0,0 +1,272 @@
+# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
+#
+# Copyright (c) 2026 Jamf. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+# * Neither the name of the Jamf nor the names of its contributors may be
+# used to endorse or promote products derived from this software without
+# specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY JAMF SOFTWARE, LLC "AS IS" AND ANY
+# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL JAMF SOFTWARE, LLC BE LIABLE FOR ANY
+# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
+
+import json
+import os
+from typing import Any, Dict, List, Optional
+
+from werkzeug.utils import secure_filename
+
+from bin import logger
+
+logthis = logger.setup_child_logger("jawa", "data_store")
+
+_base_dir = os.path.dirname(os.path.dirname(__file__))
+
+WEBHOOKS_FILE = os.path.abspath(
+ os.path.join(_base_dir, "data", "webhooks.json")
+)
+CRON_FILE = os.path.abspath(os.path.join(_base_dir, "data", "cron.json"))
+SERVER_FILE = os.path.abspath(os.path.join(_base_dir, "data", "server.json"))
+TIME_FILE = os.path.abspath(os.path.join(_base_dir, "data", "time.json"))
+WEBHOOK_SCHEMAS_FILE = os.path.abspath(
+ os.path.join(_base_dir, "data", "webhook_schemas.json")
+)
+SCRIPTS_DIR = os.path.abspath(os.path.join(_base_dir, "scripts"))
+
+
+# --- Low-level I/O ---
+
+
+def _read_json(filepath: str, default: Any = None) -> Any:
+ if default is None:
+ default = []
+ if not os.path.isfile(filepath):
+ with open(filepath, "w") as f:
+ json.dump(default, f)
+ return default
+ with open(filepath, "r") as f:
+ try:
+ return json.load(f)
+ except json.JSONDecodeError:
+ return default
+
+
+def _write_json(filepath: str, data: Any) -> None:
+ with open(filepath, "w") as f:
+ json.dump(data, f, indent=4)
+
+
+# --- Webhooks ---
+
+
+def get_all_webhooks() -> List[Dict]:
+ return _read_json(WEBHOOKS_FILE)
+
+
+def get_webhooks_by_tag(tag: str) -> List[Dict]:
+ return [w for w in get_all_webhooks() if w.get("tag") == tag]
+
+
+def get_webhook_by_name(name: str) -> Optional[Dict]:
+ for w in get_all_webhooks():
+ if w.get("name") == name:
+ return w
+ return None
+
+
+def webhook_name_exists(name: str) -> bool:
+ return any(w.get("name") == name for w in get_all_webhooks())
+
+
+def add_webhook(entry: Dict) -> None:
+ data = get_all_webhooks()
+ data.append(entry)
+ _write_json(WEBHOOKS_FILE, data)
+
+
+def update_webhook_in_list(
+ webhooks: List[Dict], name: str, updates: Dict
+) -> None:
+ """Update a webhook entry in an already-loaded list (in-place)."""
+ for w in webhooks:
+ if w.get("name") == name:
+ w.update(updates)
+ break
+
+
+def save_all_webhooks(data: List[Dict]) -> None:
+ _write_json(WEBHOOKS_FILE, data)
+
+
+def remove_webhook(name: str) -> Optional[Dict]:
+ data = get_all_webhooks()
+ removed = None
+ for w in data:
+ if w.get("name") == name:
+ removed = dict(w)
+ data.remove(w)
+ break
+ _write_json(WEBHOOKS_FILE, data)
+ return removed
+
+
+# --- Crons ---
+
+
+def get_all_crons() -> List[Dict]:
+ return _read_json(CRON_FILE)
+
+
+def get_cron_by_name(name: str) -> Optional[Dict]:
+ for c in get_all_crons():
+ if c.get("name") == name:
+ return c
+ return None
+
+
+def cron_name_exists(name: str) -> bool:
+ return any(c.get("name") == name for c in get_all_crons())
+
+
+def add_cron(entry: Dict) -> None:
+ data = get_all_crons()
+ data.append(entry)
+ _write_json(CRON_FILE, data)
+
+
+def save_all_crons(data: List[Dict]) -> None:
+ _write_json(CRON_FILE, data)
+
+
+def remove_cron(name: str) -> Optional[Dict]:
+ data = get_all_crons()
+ removed = None
+ for c in data:
+ if c.get("name") == name:
+ removed = dict(c)
+ data.remove(c)
+ break
+ _write_json(CRON_FILE, data)
+ return removed
+
+
+# --- Server Config ---
+
+
+def get_server_config() -> Dict:
+ if not os.path.isfile(SERVER_FILE):
+ return {}
+ with open(SERVER_FILE, "r") as f:
+ try:
+ data = json.load(f)
+ except json.JSONDecodeError:
+ return {}
+ return data if isinstance(data, dict) else {}
+
+
+def get_jawa_address() -> Optional[str]:
+ return get_server_config().get("jawa_address")
+
+
+def get_time_data() -> Dict:
+ with open(TIME_FILE, "r") as f:
+ return json.load(f)
+
+
+def get_webhook_schemas() -> Dict[str, Any]:
+ """Read the static Jamf Pro webhook event catalog.
+
+ Hand-maintained reference data, not runtime state: the file ships
+ with JAWA and is edited directly when Jamf Pro's event set changes.
+ Read on every call (it is small, and no caching keeps a stale copy
+ alive after an edit). Degrades to empty structures instead of
+ raising, because the Jamf automation form's event dropdown reads
+ this too and must still render if the file is damaged.
+ """
+ empty: Dict[str, Any] = {
+ "categories": {},
+ "schemas": {},
+ "examples": {},
+ }
+ try:
+ with open(WEBHOOK_SCHEMAS_FILE, "r", encoding="utf-8") as f:
+ data = json.load(f)
+ except (OSError, ValueError):
+ # ValueError covers both a malformed JSON body
+ # (json.JSONDecodeError) and a file saved in a non-UTF-8
+ # encoding (UnicodeDecodeError) - neither is an OSError.
+ logthis.warning(
+ f"Webhook event catalog unreadable: {WEBHOOK_SCHEMAS_FILE}"
+ )
+ return empty
+ if not isinstance(data, dict):
+ logthis.warning("Webhook event catalog is not a JSON object.")
+ return empty
+ # Each section is iterated as a mapping by the reference pages and
+ # the event dropdown, so a hand edit that turns one into a list or
+ # a string degrades to empty here rather than failing in a template.
+ out: Dict[str, Any] = {}
+ for key in empty:
+ section = data.get(key)
+ out[key] = section if isinstance(section, dict) else {}
+ return out
+
+
+# --- Script Management ---
+
+
+def save_script(
+ file_storage: Any, name_prefix: str, separator: str = "-"
+) -> str:
+ """Save an uploaded script with a prefixed filename.
+
+ Returns the absolute path to the saved file.
+ """
+ # A JAWA automation script is executed directly by the receiver
+ # (Popen, argv form). Without a shebang the OS cannot pick an
+ # interpreter, so it would fail cryptically at trigger time. Reject
+ # it here so the upload fails clearly instead.
+ head = file_storage.read(2)
+ file_storage.seek(0)
+ if head != b"#!":
+ raise ValueError(
+ "Script must start with a shebang (e.g. #!/bin/bash)."
+ )
+ if not os.path.isdir(SCRIPTS_DIR):
+ os.mkdir(SCRIPTS_DIR)
+ filename = file_storage.filename
+ if " " in filename:
+ filename = filename.replace(" ", "-")
+ new_filename = f"{name_prefix}{separator}{filename}"
+ safe_name = secure_filename(new_filename)
+ filepath = os.path.join(SCRIPTS_DIR, safe_name)
+ file_storage.save(filepath)
+ # Owner-only. The receiver runs this as the same `jawa` user that
+ # owns it, so the group/other read bits only ever widened who could
+ # read a script that may hold credentials. The shebang is left as
+ # the operator wrote it -- an uploaded script may legitimately be
+ # bash, and choosing its interpreter is theirs.
+ os.chmod(filepath, mode=0o0700)
+ return filepath
+
+
+def retire_script(path: str) -> None:
+ """Rename a script to .old instead of deleting it."""
+ if os.path.exists(path):
+ os.rename(path, f"{path}.old")
diff --git a/bin/installer.sh b/bin/installer.sh
index ae9566e..b929d6f 100644
--- a/bin/installer.sh
+++ b/bin/installer.sh
@@ -102,22 +102,57 @@ installPackagesRedHat() {
/usr/bin/clear
echo -ne '[###### ](34%) Installing epel-release from dnf... '
echo '[###### ](34%) Installing epel-release from dnf...' >>/var/log/jawaInstall.log 2>&1
- /usr/bin/dnf install -y epel-release >>/var/log/jawaInstall.log 2>&1 & spinner $! ""
+ # This is the one place RHEL and Rocky genuinely diverge. Rocky carries
+ # epel-release in its own repos; RHEL does not, so `dnf install epel-release`
+ # exits "No match for argument" and every EPEL-only package after it --
+ # fail2ban, nload -- then fails silently too. Verified on RHEL 9.8. Install
+ # from the Fedora URL there, keyed to the running major version rather than a
+ # hardcoded 9.
+ if [ "$(detect_os)" == "redhat" ]; then
+ rhelMajor=$(/usr/bin/rpm -E %rhel 2>/dev/null)
+ if [ -z "$rhelMajor" ] || [ "$rhelMajor" == "%rhel" ]; then
+ rhelMajor=9
+ /bin/echo "Could not determine the RHEL major version; assuming $rhelMajor for EPEL." >>/var/log/jawaInstall.log 2>&1
+ fi
+ /usr/bin/dnf install -y "https://dl.fedoraproject.org/pub/epel/epel-release-latest-${rhelMajor}.noarch.rpm" >>/var/log/jawaInstall.log 2>&1 & spinner $! ""
+ else
+ /usr/bin/dnf install -y epel-release >>/var/log/jawaInstall.log 2>&1 & spinner $! ""
+ fi
+ # EPEL is optional -- JAWA runs without it -- but say so rather than letting
+ # fail2ban and nload vanish without a word.
+ if ! /usr/bin/rpm -q epel-release >/dev/null 2>&1; then
+ /bin/echo "NOTE: EPEL is not available on this host; fail2ban and nload will be skipped." >>/var/log/jawaInstall.log 2>&1
+ epelMissing="yes"
+ fi
/usr/bin/clear
/bin/echo -ne '[####### ](35%) Installing nginx from yum... '
/bin/echo '[####### ](35%) Installing nginx... ' >>/var/log/jawaInstall.log 2>&1
/usr/bin/yum install -y nginx >>/var/log/jawaInstall.log 2>&1 & spinner $! ""
/usr/bin/clear
- echo -ne '[######## ](40%) Installing python from yum... '
- echo '[######## ](40%) Installing python from yum...' >>/var/log/jawaInstall.log 2>&1
- /usr/bin/yum install -y python3 >>/var/log/jawaInstall.log 2>&1 & spinner $! ""
+ echo -ne '[######## ](40%) Installing python3, pip and headers from yum... '
+ echo '[######## ](40%) Installing python3, python3-pip, python3-devel...' >>/var/log/jawaInstall.log 2>&1
+ # python3 alone does NOT bring pip on RHEL 9 -- verified on RHEL 9.8, where a
+ # stock run aborted at the pip safety check with exit 2. Ubuntu's step has
+ # always installed python3-pip and python3-dev explicitly; this mirrors it.
+ # python3-devel is the RHEL name for python3-dev, and is needed to build any
+ # dependency without a wheel. venv needs no separate package here, unlike
+ # Debian, where it is split into python3-venv.
+ /usr/bin/yum install -y python3 python3-pip python3-devel >>/var/log/jawaInstall.log 2>&1 & spinner $! ""
# Stop the hackers
/usr/bin/clear
/bin/echo -ne '[######### ](45%) Installing fail2ban from yum... '
- /bin/echo '[######### ](45%)) Installing fail2ban from yum... ' >>/var/log/jawaInstall.log 2>&1
- /usr/bin/dnf install fail2ban -y >>/var/log/jawaInstall.log 2>&1 & spinner $! ""
+ /bin/echo '[######### ](45%) Installing fail2ban from yum... ' >>/var/log/jawaInstall.log 2>&1
+ # Both live in EPEL, so skip them with a word rather than emitting a
+ # confusing "No match for argument" when EPEL could not be added. nload is
+ # here to match Ubuntu's package set, which has always installed it.
+ if [ "${epelMissing:-no}" == "yes" ]; then
+ /bin/echo "Skipping fail2ban and nload: EPEL unavailable." >>/var/log/jawaInstall.log 2>&1
+ sleep 1 & spinner $! ""
+ else
+ /usr/bin/dnf install fail2ban nload -y >>/var/log/jawaInstall.log 2>&1 & spinner $! ""
+ fi
# For the bash inclined
/usr/bin/clear
/bin/echo -ne '[########## ](50%) Installing jq from yum... '
@@ -182,9 +217,35 @@ configure_firewall() {
case $os in
"rocky" | "redhat")
- # RedHat | Rocky Firewall
- /usr/bin/firewall-cmd --zone=public --add-port=443/tcp --permanent
- /usr/bin/firewall-cmd --zone=public --add-port=22/tcp --permanent
+ # RedHat | Rocky Firewall.
+ #
+ # firewall-cmd was called by absolute path with no existence check,
+ # so on an image without firewalld -- Red Hat's own EC2 AMI, for one
+ # -- the install printed two "No such file or directory" errors and
+ # carried on with no OS firewall and no mention of it. Verified on
+ # RHEL 9.8.
+ #
+ # It also never reloaded. --permanent writes the persistent config
+ # but does not touch the running firewall, so on a host that DOES
+ # run firewalld, 443 stayed closed until something reloaded it and
+ # JAWA was unreachable for reasons nothing reported.
+ firewallTool=$(command -v firewall-cmd 2>/dev/null)
+ if [ -z "$firewallTool" ]; then
+ /bin/echo ""
+ /bin/echo "${cYellow}NOTE${cReset} firewalld is not installed on this host, so no OS firewall was configured."
+ /bin/echo " JAWA needs inbound 443 (and 22 for SSH). If a firewall is added later,"
+ /bin/echo " or if your cloud security group restricts inbound traffic, open them there."
+ /bin/echo ""
+ /bin/echo "NOTE: firewall-cmd absent; no OS firewall configured" >>/var/log/jawaInstall.log 2>&1
+ else
+ "$firewallTool" --zone=public --add-port=443/tcp --permanent >>/var/log/jawaInstall.log 2>&1
+ "$firewallTool" --zone=public --add-port=22/tcp --permanent >>/var/log/jawaInstall.log 2>&1
+ # Apply it now; --permanent alone leaves the running firewall untouched.
+ "$firewallTool" --reload >>/var/log/jawaInstall.log 2>&1
+ if [ $? -ne 0 ]; then
+ /bin/echo "NOTE: firewall-cmd --reload failed; ports 22/443 may not be open until reboot" >>/var/log/jawaInstall.log 2>&1
+ fi
+ fi
;;
"ubuntu")
# Ubuntu Firewall
@@ -207,22 +268,6 @@ configure_firewall() {
esac
}
-addToLog(){
- ## Usage
- ## addToLog
- logFile="${$1}"
- fillerText="${$2}"
-
- echo "$(date -j)" "$2" >> "$1"
-
-}
-
-timestamp() {
- /bin/echo ""
- /bin/echo -n $(date +"%D %T -")
- /bin/echo -n " "
-}
-
readme() {
/usr/bin/clear
@@ -239,7 +284,7 @@ readme() {
| \`--' | / _____ \ \ /\ / / _____ \
\______/ /__/ \__\ \__/ \__/ /__/ \__\
- v3.1.1
+ v3.2.0
Welcome to the Jamf Automation and Webhook Assistant, we hope it provides the solution you are looking for.
@@ -253,35 +298,250 @@ Please make sure you:
"
}
-cancel() {
- /bin/echo "Canceling..."
- exit 0
+# Minimum Python minor version. Werkzeug's patched releases (3.1.4+) require
+# Python >= 3.9, and there is no patched Werkzeug for 3.8 -- PyPI serves
+# nothing above 3.0.6 to a 3.8 interpreter. So "runs on 3.8" and "ships
+# without the open Werkzeug advisory" are mutually exclusive.
+jawaMinPyMinor=9
+werkzeugPy38Fallback="3.0.6"
+holdWerkzeug="no"
+
+# Colour, as raw ANSI rather than tput: a server's terminfo frequently does not
+# know a modern $TERM forwarded over SSH (see the TERM fallback at the bottom),
+# and every tput call then fails. printf puts real ESC bytes into these
+# variables, so a plain `/bin/echo "${cRed}..."` prints them -- this script has
+# never relied on `echo -e`, whose behaviour differs between shells and builds.
+#
+# All six are empty unless stdout is a terminal, so a redirected run stays
+# clean, and the >>jawaInstall.log lines are written uncoloured on purpose: the
+# log must stay greppable. NO_COLOR is honoured (https://no-color.org).
+initColour() {
+ cReset=""
+ cBold=""
+ cRed=""
+ cGreen=""
+ cYellow=""
+ cDate=""
+ if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
+ cReset=$(printf '\033[0m')
+ cBold=$(printf '\033[1m')
+ cRed=$(printf '\033[1;31m')
+ cGreen=$(printf '\033[1;32m')
+ cYellow=$(printf '\033[1;33m')
+ cDate=$(printf '\033[36m')
+ fi
+}
+
+# Pads a role label to a fixed width BEFORE it is wrapped in colour. Padding
+# afterwards would count the ESC bytes toward the field width and misalign the
+# column by exactly the length of the escape sequence.
+certLabel() {
+ printf "%-${2:-8}s" "$1"
+}
+
+normalizeInstallDir() {
+ # Strip trailing slashes so "$installDir/jawa" cannot become
+ # "/usr/local//jawa". The paths were inconsistent -- one assignment used
+ # "/usr/local/" while two others used "/usr/local" -- which put a double
+ # slash into the systemd unit's ExecStart, the pip -r argument, and the
+ # final "JAWA installed at" line. Harmless to the kernel, but it makes
+ # the unit file and the logs look broken and defeats string comparison
+ # of paths. Keep "/" itself intact rather than reducing it to "".
+ while [ "$installDir" != "/" ] && [ "${installDir%/}" != "$installDir" ]; do
+ installDir="${installDir%/}"
+ done
+}
+
+checkPythonFloor() {
+ # Runs FIRST, before anything destructive. This used to be discovered at
+ # the 85% mark -- after cleaninstall had already removed the operator's
+ # working install -- so a 3.8 host was left with a dead service behind a
+ # "[########################](100%) Installation complete!" message.
+ if [ ! -x /usr/bin/python3 ]; then
+ /bin/echo "Python 3 is not present at /usr/bin/python3."
+ /bin/echo "JAWA requires Python 3.${jawaMinPyMinor} or later. Install it and run this installer again."
+ /bin/echo "Your existing JAWA install, if any, has NOT been modified. Exiting..."
+ exit 2
+ fi
+ local pyMajor pyMinor pyVer
+ pyVer=$(/usr/bin/python3 -c 'import sys; print("%d.%d" % sys.version_info[:2])' 2>/dev/null)
+ pyMajor=${pyVer%%.*}
+ pyMinor=${pyVer##*.}
+ if [ -z "$pyMajor" ] || [ -z "$pyMinor" ]; then
+ /bin/echo "Could not determine the Python version reported by /usr/bin/python3."
+ /bin/echo "Your existing JAWA install, if any, has NOT been modified. Exiting..."
+ exit 2
+ fi
+ /bin/echo "Detected Python $pyVer" >>/var/log/jawaInstall.log 2>&1
+ if [ "$pyMajor" -gt 3 ]; then
+ return 0
+ fi
+ if [ "$pyMajor" -eq 3 ] && [ "$pyMinor" -ge "$jawaMinPyMinor" ]; then
+ return 0
+ fi
+ if [ "${JAWA_ALLOW_UNPATCHED_WERKZEUG:-0}" = "1" ]; then
+ holdWerkzeug="yes"
+ /bin/echo ""
+ /bin/echo "WARNING: Python $pyVer is below JAWA's 3.${jawaMinPyMinor} floor, and"
+ /bin/echo "JAWA_ALLOW_UNPATCHED_WERKZEUG=1 is set. Werkzeug will be held at"
+ /bin/echo "$werkzeugPy38Fallback, which is the newest release available to Python $pyVer and"
+ /bin/echo "carries an OPEN, UNPATCHED security advisory. You are accepting that."
+ /bin/echo "The supported fix is Ubuntu 22.04+ / RHEL-Rocky 9+ (Python 3.${jawaMinPyMinor}+)."
+ /bin/echo ""
+ /bin/echo "Holding Werkzeug at $werkzeugPy38Fallback for Python $pyVer per JAWA_ALLOW_UNPATCHED_WERKZEUG=1" >>/var/log/jawaInstall.log 2>&1
+ /bin/sleep 5
+ return 0
+ fi
+ /bin/echo ""
+ /bin/echo "JAWA 3.2 requires Python 3.${jawaMinPyMinor} or later. This host has Python $pyVer."
+ /bin/echo ""
+ /bin/echo " Ubuntu 20.04 ships Python 3.8 and RHEL/Rocky 8 ships 3.6; neither is supported."
+ /bin/echo " Supported: Ubuntu 22.04+ or RHEL/Rocky 9+."
+ /bin/echo ""
+ /bin/echo " Reason: JAWA's Werkzeug security fix is only published for Python 3.${jawaMinPyMinor}+."
+ /bin/echo " There is no patched Werkzeug for Python $pyVer."
+ /bin/echo ""
+ /bin/echo " To install anyway with an UNPATCHED Werkzeug ($werkzeugPy38Fallback), re-run with"
+ /bin/echo " JAWA_ALLOW_UNPATCHED_WERKZEUG=1 set, e.g.:"
+ /bin/echo " sudo JAWA_ALLOW_UNPATCHED_WERKZEUG=1 bash ./installer.sh"
+ /bin/echo ""
+ /bin/echo "Your existing JAWA install, if any, has NOT been modified. Exiting..."
+ /bin/echo "Refusing to install: Python $pyVer is below the 3.${jawaMinPyMinor} floor." >>/var/log/jawaInstall.log 2>&1
+ exit 2
+}
+
+# Prints a certificate's expiry date, or "unreadable" if it will not parse.
+certExpiry() {
+ local enddate
+ enddate=$(/usr/bin/openssl x509 -enddate -noout -in "$1" 2>/dev/null) || {
+ /bin/echo "unreadable"
+ return
+ }
+ /bin/echo "${enddate#notAfter=}"
+}
+
+# 0 when the certificate is already expired. An unparseable file returns 1: a
+# file we cannot read is not a file we can call expired.
+certIsExpired() {
+ /usr/bin/openssl x509 -noout -in "$1" >/dev/null 2>&1 || return 1
+ ! /usr/bin/openssl x509 -checkend 0 -noout -in "$1" >/dev/null 2>&1
+}
+
+# 0 when the certificate both parses and has not expired -- i.e. nginx can
+# actually serve it. `-checkend 0` already fails on an unreadable file, which
+# is the behaviour we want here: garbage is not usable either.
+certIsUsable() {
+ /usr/bin/openssl x509 -checkend 0 -noout -in "$1" >/dev/null 2>&1
+}
+
+# Say so when the certificate JAWA will actually serve is expired or close to
+# it. An expired cert makes the console unreachable in a browser while every
+# service check still passes, so silence here reads as an installer bug.
+warnIfCertStale() {
+ if certIsExpired "$1"; then
+ /bin/echo ""
+ /bin/echo "${cRed}WARNING${cReset} the certificate JAWA will serve is ${cRed}EXPIRED${cReset}."
+ /bin/echo " $1"
+ /bin/echo " expired ${cDate}$(certExpiry "$1")${cReset}"
+ /bin/echo " Browsers will refuse the JAWA console until it is replaced."
+ /bin/echo ""
+ /bin/echo "WARNING: serving expired cert $1" >>/var/log/jawaInstall.log 2>&1
+ elif /usr/bin/openssl x509 -noout -in "$1" >/dev/null 2>&1 &&
+ ! /usr/bin/openssl x509 -checkend 2592000 -noout -in "$1" >/dev/null 2>&1; then
+ /bin/echo ""
+ /bin/echo "${cYellow}NOTE${cReset} the certificate JAWA will serve expires within 30 days."
+ /bin/echo " $1"
+ /bin/echo " expires ${cDate}$(certExpiry "$1")${cReset}"
+ /bin/echo ""
+ /bin/echo "NOTE: cert $1 expires within 30 days" >>/var/log/jawaInstall.log 2>&1
+ fi
}
install() {
+ checkPythonFloor
/usr/bin/clear
# Variables
- x=0
-
- if [ ! -f ./jawa.crt ] >/dev/null 2>&1; then
- /bin/echo "Unable to locate jawa.crt in $currentDir" >>/var/log/jawaInstall.log 2>&1
- x=$(($x + 1))
+ # Certificates. nginx reads /etc/ssl/certs/jawa.{crt,key}, so a host that
+ # already runs JAWA has a working pair there and the copy sitting in the
+ # current directory is often a stale leftover from the first install. This
+ # used to `cp` over the installed pair unconditionally, which is how a valid
+ # certificate gets replaced by an expired one -- a downgrade the operator
+ # only discovers later, as a browser error on a console the installer just
+ # reported as a success. Never trade a valid certificate for an expired one,
+ # and never make an upgrade re-supply certificates it already has.
+ certsDir="/etc/ssl/certs"
+ haveLocalCerts="no"
+ haveInstalledCerts="no"
+ if [ -f ./jawa.crt ] && [ -e ./jawa.key ]; then
+ haveLocalCerts="yes"
+ else
+ if [ ! -f ./jawa.crt ]; then
+ /bin/echo "Unable to locate jawa.crt in $currentDir" >>/var/log/jawaInstall.log 2>&1
+ fi
+ if [ ! -e ./jawa.key ]; then
+ /bin/echo "Unable to locate jawa.key in $currentDir" >>/var/log/jawaInstall.log 2>&1
+ fi
fi
- if [ ! -e ./jawa.key ] >/dev/null 2>&1; then
- /bin/echo "Unable to locate jawa.key in $currentDir" >>/var/log/jawaInstall.log 2>&1
- x=$(($x + 1))
+ if [ -f "$certsDir/jawa.crt" ] && [ -e "$certsDir/jawa.key" ]; then
+ haveInstalledCerts="yes"
fi
- if [[ $x -ne 0 ]]; then
+ if [ "$haveLocalCerts" == "no" ] && [ "$haveInstalledCerts" == "yes" ]; then
+ # Upgrading a host that already has certificates. Demanding them in the
+ # current directory again would push a working install into the
+ # self-signed menu for no reason.
+ /bin/echo ""
+ /bin/echo "${cBold}Certificate: no jawa.crt/jawa.key here, so the installed one is kept.${cReset}"
+ /bin/echo ""
+ /bin/echo " ${cBold}$(certLabel "IN USE")${cReset} $certsDir/jawa.crt"
+ /bin/echo " ${cGreen}$(certLabel "VALID" 10)${cReset} expires ${cDate}$(certExpiry "$certsDir/jawa.crt")${cReset}"
+ /bin/echo ""
+ /bin/echo "Keeping installed cert $certsDir/jawa.crt" >>/var/log/jawaInstall.log 2>&1
+ warnIfCertStale "$certsDir/jawa.crt"
+ elif [ "$haveLocalCerts" == "no" ]; then
/bin/echo "Security requirements are not satisfied.
Missing items are identified above--please provide the items in the installer directory before installing.
Consult the admin guide for more information concerning certificates."
certsMenu
+ elif [ "$haveInstalledCerts" == "yes" ] && certIsUsable "$certsDir/jawa.crt" && ! certIsUsable ./jawa.crt; then
+ # The copy in the current directory is expired or unreadable and the
+ # installed one still works. Installing the bad copy would take the
+ # console offline, so keep what works and say why.
+ # Roles first, paths second. The operator reading this at a progress bar
+ # needs to know which certificate is which and what it means before they
+ # need to know where either one lives.
+ if /usr/bin/openssl x509 -noout -in ./jawa.crt >/dev/null 2>&1; then
+ certRejectState="EXPIRED"
+ certRejectDetail="expired ${cDate}$(certExpiry ./jawa.crt)${cReset}"
+ certRejectReason="has expired ($(certExpiry ./jawa.crt))"
+ else
+ certRejectState="UNREADABLE"
+ certRejectDetail="not a valid certificate file"
+ certRejectReason="is not a readable certificate"
+ fi
+ /bin/echo ""
+ /bin/echo "${cBold}Certificate: keeping the one already in use. Nothing was replaced.${cReset}"
+ /bin/echo ""
+ /bin/echo " ${cBold}$(certLabel "IN USE")${cReset} $certsDir/jawa.crt"
+ /bin/echo " ${cGreen}$(certLabel "VALID" 10)${cReset} expires ${cDate}$(certExpiry "$certsDir/jawa.crt")${cReset}"
+ /bin/echo ""
+ /bin/echo " ${cBold}$(certLabel "OFFERED")${cReset} $currentDir/jawa.crt"
+ /bin/echo " ${cRed}$(certLabel "$certRejectState" 10)${cReset} $certRejectDetail"
+ /bin/echo ""
+ /bin/echo " ${cBold}Impact:${cReset} none. JAWA keeps serving the certificate marked IN USE,"
+ /bin/echo " so the console stays reachable. The OFFERED file was left untouched."
+ /bin/echo ""
+ /bin/echo " To install a new certificate, replace jawa.crt and jawa.key in"
+ /bin/echo " $currentDir and run this installer again."
+ /bin/echo ""
+ /bin/echo "Refused to overwrite valid $certsDir/jawa.crt with unusable ./jawa.crt (offered cert $certRejectReason)" >>/var/log/jawaInstall.log 2>&1
+ else
+ /bin/cp ./{jawa.crt,jawa.key} "$certsDir/"
+ warnIfCertStale "$certsDir/jawa.crt"
fi
- /bin/cp ./{jawa.crt,jawa.key} /etc/ssl/certs/
# checking for service
if [ -e /etc/systemd/system/jawa.service ]; then
status=$(/bin/systemctl is-active --quiet jawa && echo Service is running)
@@ -292,9 +552,29 @@ install() {
projectDir=$(systemctl status jawa.service | grep -i /jawa/app.py | awk '{ print $3 }' | rev | cut -c 7- | rev)
fi
installDir=$(dirname "$projectDir")
- if [[ $installDir != "" ]]; then
- installDir="/usr/local/"
+ # Trust the detected directory only after checking it, and fall back only
+ # when the check fails.
+ #
+ # This used to read `if [[ $installDir != "" ]]; then installDir=/usr/local`,
+ # which threw the detection away precisely when it had SUCCEEDED. That is
+ # not cosmetic: the backup below reads "$installDir/jawa/{scripts,resources,
+ # data}", so an operator who installed anywhere but /usr/local had their
+ # real install left untouched and unreferenced -- nothing backed up, a
+ # fresh install written to /usr/local, and every automation orphaned in the
+ # old directory.
+ #
+ # The fallback still has to exist, because the parse above is brittle: it
+ # slices a fixed number of characters off a `systemctl status` line, with a
+ # *different* width depending on whether the service is running. A garbage
+ # parse must never become the install target. Requiring app.py to actually
+ # be there is what separates the two cases -- it confirms the parse landed
+ # on a real JAWA install rather than merely producing a plausible string.
+ if [ -z "$installDir" ] || [ "${installDir:0:1}" != "/" ] ||
+ [ ! -f "$installDir/jawa/app.py" ]; then
+ /bin/echo "Detected install dir '$installDir' is not a JAWA install (no jawa/app.py); falling back to /usr/local" >>/var/log/jawaInstall.log 2>&1
+ installDir="/usr/local"
fi
+ normalizeInstallDir
echo "JAWA directory detected at $installDir" >> /var/log/jawaInstall.log 2>&1
read -r -p "Existing JAWA detected - would you like to upgrade? [y/n]: " yn
case $yn in
@@ -306,10 +586,9 @@ install() {
*) echo "Please answer yes or no." ;;
esac
fi
- if [ "$upgradeOption" == "yes" ]; then
- continue
- else
- # prompting for install directory
+ if [ "$upgradeOption" != "yes" ]; then
+ # prompting for install directory (upgrades reuse the detected installDir,
+ # which is now validated above rather than overwritten)
read -r -p "Where would you like to install JAWA? [Press RETURN for $installDir]: " new_dir
while true; do
if [ "$new_dir" != "" ]; then
@@ -319,6 +598,8 @@ install() {
else
installDir="/usr/local/$new_dir"
fi
+ # An operator answering "/opt/jawa/" must not produce "/opt/jawa//jawa".
+ normalizeInstallDir
fi
read -p "The jawa project directory will be created in $installDir
Please confirm [y|n]: " yn
@@ -391,7 +672,11 @@ install() {
/bin/echo "Exiting..."
exit 2
fi
- /usr/bin/python3 -m pip
+ # `python3 -m pip` with no subcommand dumps 30 lines of usage onto the
+ # operator's terminal, and its exit code is version-dependent -- newer
+ # pip exits 1 for no-args, which made this abort with "python3-pip was
+ # not installed successfully" while pip was in fact installed.
+ /usr/bin/python3 -m pip --version >>/var/log/jawaInstall.log 2>&1
if [ $? -eq 0 ]; then
/bin/echo "python3-pip installed." >>/var/log/jawaInstall.log 2>&1
else
@@ -413,12 +698,22 @@ install() {
/bin/echo '[############ ](60%) Cloning the JAWA project from GitHub... ' >>/var/log/jawaInstall.log 2>&1
git clone --branch "$branch" https://github.com/jamf/JAWA.git jawa >>/var/log/jawaInstall.log 2>&1 & spinner $! ""
+ cloneStatus=$?
+ # spinner propagates the background job's exit code; also confirm the clone actually landed
+ if [ "$cloneStatus" -ne 0 ] || [ ! -d "$installDir/jawa/.git" ]; then
+ /usr/bin/clear
+ /bin/echo "Unable to clone the JAWA project (branch '$branch') from GitHub."
+ /bin/echo "Check your network connection and that the branch exists, then see /var/log/jawaInstall.log for details."
+ /bin/echo "Unable to clone the JAWA project (branch '$branch') from GitHub." >>/var/log/jawaInstall.log 2>&1
+ exit 2
+ fi
# Restore backup?
/usr/bin/clear
/bin/echo -ne '[############# ](64%) Checking for backups... '
/bin/echo '[############# ](64%) Checking for backups... ' >>/var/log/jawaInstall.log 2>&1
/bin/sleep 1 & spinner $! ""
if [ -d "$currentDir/jawabackup-$timenow" ]; then
+ backupJAWA="$currentDir/jawabackup-$timenow"
while true; do
/bin/echo ""
if [ "$upgradeOption" == "yes" ]; then
@@ -440,8 +735,8 @@ if [ -d "$currentDir/jawabackup-$timenow" ]; then
# for gzip support in uwsgi
#/usr/bin/apt-get install --no-install-recommends -y -q libpcre3-dev libz-dev
- /bin/echo -ne '[############# ](65%) Setting permissions for $installDir/jawa... '
- /bin/echo '[############# ](65%) Setting permissions for $installDir/jawa ' >>/var/log/jawaInstall.log 2>&1
+ /bin/echo -ne "[############# ](65%) Setting permissions for $installDir/jawa... "
+ /bin/echo "[############# ](65%) Setting permissions for $installDir/jawa " >>/var/log/jawaInstall.log 2>&1
chown -R jawa "$installDir/jawa" & spinner $! ""
@@ -466,7 +761,29 @@ if [ -d "$currentDir/jawabackup-$timenow" ]; then
/usr/bin/clear
/bin/echo -ne '[################# ](85%) pip installing jawa requirements.txt file in venv... '
/bin/echo '[################# ](85%) pip installing jawa requirements.txt file in venv... ' >>/var/log/jawaInstall.log 2>&1
- "$installDir/jawa/venv/bin/python" -m pip install -r "$installDir/jawa/requirements.txt" >>/var/log/jawaInstall.log 2>&1 & spinner $! "" #
+ if [ "$holdWerkzeug" = "yes" ]; then
+ /bin/sed -i "s/^Werkzeug~=.*/Werkzeug~=$werkzeugPy38Fallback/" "$installDir/jawa/requirements.txt" >>/var/log/jawaInstall.log 2>&1
+ /bin/echo "Held Werkzeug at $werkzeugPy38Fallback in requirements.txt" >>/var/log/jawaInstall.log 2>&1
+ fi
+ "$installDir/jawa/venv/bin/python" -m pip install -r "$installDir/jawa/requirements.txt" >>/var/log/jawaInstall.log 2>&1 & spinner $! ""
+ # spinner propagates the background job's exit code (see the clone step).
+ # Unchecked, a failed resolve here marched on to "100% Installation
+ # complete!" and enabled a systemd unit that crash-looped on
+ # ModuleNotFoundError -- the dependency failure was only visible in the log.
+ requirementsStatus=$?
+ if [ "$requirementsStatus" -ne 0 ] || ! "$installDir/jawa/venv/bin/python" -c 'import flask' >/dev/null 2>&1; then
+ /usr/bin/clear
+ /bin/echo ""
+ /bin/echo "JAWA's Python dependencies failed to install, so the service cannot start."
+ /bin/echo "This is NOT a missing python3/pip/git/curl -- those are present."
+ /bin/echo ""
+ /bin/echo "The failing lines are in /var/log/jawaInstall.log. To see them:"
+ /bin/echo " grep -E 'ERROR|No matching distribution' /var/log/jawaInstall.log | tail"
+ /bin/echo ""
+ /bin/echo "Aborting before the service is created."
+ /bin/echo "Python dependency install FAILED (status $requirementsStatus); flask not importable. Aborting." >>/var/log/jawaInstall.log 2>&1
+ exit 2
+ fi #
#pip install --upgrade uwsgi
/usr/bin/clear
/bin/echo -ne '[################## ](90%) Creating jawa service in systemd... '
@@ -513,22 +830,73 @@ EOF
/bin/echo '[###################### ](98%) Restarting services... ' >>/var/log/jawaInstall.log 2>&1
/bin/systemctl enable jawa.service >>/var/log/jawaInstall.log 2>&1
/bin/systemctl restart jawa.service >>/var/log/jawaInstall.log 2>&1
+ jawaRestart=$?
+ [ "$jawaRestart" -ne 0 ] && /bin/echo "systemctl restart jawa.service exited $jawaRestart" >>/var/log/jawaInstall.log 2>&1
/usr/bin/clear
/bin/echo -ne '[####################### ](99%) Restarting services... \r'
/bin/echo '[####################### ](99%) Restarting services... ' >>/var/log/jawaInstall.log 2>&1
- /bin/systemctl restart nginx.service >>/var/log/jawaInstall.log 2>&1
+ # Validate before restarting. `systemctl restart` on a bad config takes
+ # nginx DOWN -- not just JAWA -- whereas `nginx -t` catches the problem
+ # first and names the offending file and line. Without this the installer
+ # wrote a config, restarted into it, and reported success while the console
+ # was unreachable, which is precisely how an upgrade lost a working site.
+ nginxBin=$(command -v nginx 2>/dev/null || /bin/echo /usr/sbin/nginx)
+ if ! "$nginxBin" -t >>/var/log/jawaInstall.log 2>&1; then
+ nginxTest=$("$nginxBin" -t 2>&1)
+ /bin/echo ""
+ /bin/echo "${cRed}ERROR${cReset} the nginx configuration is invalid; not restarting nginx."
+ /bin/echo " Leaving the running configuration in place so the host stays up."
+ /bin/echo ""
+ /bin/echo "$nginxTest" | while IFS= read -r line; do /bin/echo " $line"; done
+ /bin/echo ""
+ /bin/echo "ERROR: nginx -t failed, skipped restart" >>/var/log/jawaInstall.log 2>&1
+ nginxRestart=1
+ else
+ /bin/systemctl restart nginx.service >>/var/log/jawaInstall.log 2>&1
+ nginxRestart=$?
+ fi
+ [ "$nginxRestart" -ne 0 ] && /bin/echo "systemctl restart nginx.service exited $nginxRestart" >>/var/log/jawaInstall.log 2>&1
/usr/bin/clear
/bin/echo -ne '[########################](100%) Installation complete! \r'
/bin/echo '[########################](100%) Restarting services... untini! ' >>/var/log/jawaInstall.log 2>&1
/bin/sleep 1.5
/usr/bin/clear
- status=$(/bin/systemctl is-active --quiet jawa && echo Service is running)
- if [ "$status" != "Service is running" ]; then
- echo "Uh oh! The jawa service is not running. Check /var/log/jawaInstall.log for errors and restart the service."
- echo "Uh oh! The jawa service is not running. Check /var/log/jawaInstall.log for errors and restart the service." >>/var/log/jawaInstall.log 2>&1
- echo "Double-check your dependencies (python3, python3-pip, git, curl, etc.) and try again."
- echo "Double-check your dependencies (python3, python3-pip, git, curl, etc.) and try again." >>/var/log/jawaInstall.log 2>&1
+ # Check every unit the install depends on and name the ones that failed.
+ # Only jawa was ever verified, so a broken nginx reported "Installation
+ # complete!" with an unreachable console -- and real installs failed nginx
+ # repeatedly while this said nothing. The diagnostic differs per unit:
+ # jawa's reason is in journalctl, nginx's is almost always a config or
+ # certificate error, which `nginx -t` prints with the offending line.
+ failedUnits=""
+ for unit in jawa nginx; do
+ if ! /bin/systemctl is-active --quiet "$unit"; then
+ failedUnits="$failedUnits $unit"
+ fi
+ done
+ if [ -n "$failedUnits" ]; then
+ echo "Uh oh! The installation finished, but these services are NOT running:$failedUnits"
+ echo "Uh oh! Services not running after install:$failedUnits" >>/var/log/jawaInstall.log 2>&1
+ echo ""
+ for unit in $failedUnits; do
+ case "$unit" in
+ jawa)
+ echo " jawa.service - the JAWA application itself."
+ echo " JAWA will not answer at all until this starts."
+ echo " See why: journalctl -u jawa.service -n 50 --no-pager"
+ ;;
+ nginx)
+ echo " nginx.service - the TLS reverse proxy in front of JAWA."
+ echo " The web console is unreachable without it, even if jawa is healthy."
+ echo " Usually a config or certificate problem. Check the config first,"
+ echo " which names the offending file and line:"
+ echo " nginx -t"
+ echo " Then: journalctl -u nginx.service -n 30 --no-pager"
+ ;;
+ esac
+ echo ""
+ done
+ echo "The full installer log is at /var/log/jawaInstall.log"
echo ""
if [[ $jawaPassword != "" ]]; then
/bin/echo "The following service account was created on your OS for running the JAWA application, and for creating the cron tasks."
@@ -538,7 +906,7 @@ EOF
fi
exit 2
else
- echo "Jawa service is running!" >>/var/log/jawaInstall.log 2>&1
+ echo "jawa.service and nginx.service are both running." >>/var/log/jawaInstall.log 2>&1
fi
if [ -e "$installDir/jawa/static/jawadone.txt" ]; then
@@ -713,10 +1081,6 @@ function spinner() {
return $?
}
-("$@") &
-
-
-
displayMenu() {
while true; do
read -r -p "Please select from the following options:
@@ -764,7 +1128,10 @@ certsMenu() {
}
selfsigned() {
/bin/echo "Creating SSL cert" >>/var/log/jawaInstall.log 2>&1
- /usr/bin/openssl req -x509 -nodes -days 365 -newkey rsa:2048 -subj "/C=US/ST=MN/L=Minneapolis/CN=jawa" -keyout "$installDir/jawa.key" -out "$installDir/jawa.crt" >>/var/log/jawaInstall.log 2>&1
+ # Must land in $currentDir, not $installDir: install() re-checks ./jawa.crt
+ # in the current directory, so writing elsewhere sent the operator back to
+ # certsMenu forever unless they happened to be running from /usr/local.
+ /usr/bin/openssl req -x509 -nodes -days 365 -newkey rsa:2048 -subj "/C=US/ST=MN/L=Minneapolis/CN=jawa" -keyout "$currentDir/jawa.key" -out "$currentDir/jawa.crt" >>/var/log/jawaInstall.log 2>&1
install
}
upgradeFromV2() {
@@ -805,15 +1172,30 @@ restoreBackup() {
/bin/echo "Migrating cron..."
/bin/cp "$currentDir/jawabackup-$timenow/v2/cron.json" "$currentDir/jawabackup-$timenow/data/"
fi
- if [ -e "$currentDir/jawabackup-$timenow/v2/webhook.conf" -o "$currentDir/jawabackup-$timenow/v2/jp_webhooks.json" ]; then
+ if [ -e "$currentDir/jawabackup-$timenow/v2/webhook.conf" ] || [ -e "$currentDir/jawabackup-$timenow/v2/jp_webhooks.json" ]; then
/bin/echo "Migrating webhooks..."
/usr/bin/python3 "$installDir/jawa/bin/v2_upgrade.py" "$currentDir/jawabackup-$timenow" >>/var/log/jawaInstall.log 2>&1 & spinner $! ""
fi
fi
- /bin/cp -R "$currentDir/jawabackup-$timenow/data/" $installDir/jawa/
- /bin/cp -R "$currentDir/jawabackup-$timenow/resources/" $installDir/jawa/
- /bin/cp -R "$currentDir/jawabackup-$timenow/scripts/" $installDir/jawa/
- /bin/cp -R "$currentDir/jawabackup-$timenow/jawa_icon.png" $installDir/jawa/static/img/jawa_icon.png
+ # Guarded and logged, to match the backup half above. These four
+ # copies previously ran bare: an upgrade from an install that
+ # never had a scripts/ or resources/ directory -- the common
+ # case, since neither is tracked -- printed "cp: ... No such
+ # file or directory" straight onto the operator's terminal in
+ # the middle of the progress bar.
+ #
+ # "$item/." rather than "$item/": the trailing-slash form means
+ # different things to GNU and BSD cp (contents vs. the directory
+ # itself), and "/." is the explicit contents-merge on both.
+ for item in data resources scripts; do
+ if [ -d "$currentDir/jawabackup-$timenow/$item" ]; then
+ /bin/mkdir -p "$installDir/jawa/$item" >>/var/log/jawaInstall.log 2>&1
+ /bin/cp -R "$currentDir/jawabackup-$timenow/$item/." "$installDir/jawa/$item/" >>/var/log/jawaInstall.log 2>&1
+ fi
+ done
+ if [ -e "$currentDir/jawabackup-$timenow/jawa_icon.png" ]; then
+ /bin/cp "$currentDir/jawabackup-$timenow/jawa_icon.png" "$installDir/jawa/static/img/jawa_icon.png" >>/var/log/jawaInstall.log 2>&1
+ fi
}
@@ -838,6 +1220,7 @@ server {
server_name localhost;
server_name_in_redirect off;
+ client_max_body_size 16m;
location / {
# First attempt to serve request as file, then
proxy_pass http://jawa;
@@ -852,6 +1235,41 @@ server {
}
}
EOF
+
+ # SELinux denies nginx (httpd_t) name_connect to JAWA's port 8000 by default,
+ # so a stock RHEL/Rocky install reports success and then serves 502 for every
+ # request. Verified on RHEL 9.8: the audit log shows nginx in httpd_t denied
+ # name_connect to port 8000, and the console only came up after this boolean
+ # was set by hand. Nothing about it is visible from the install output, which
+ # is why it has to be set here rather than documented.
+ #
+ # -P persists it across reboots and is slow (it rebuilds policy), hence the
+ # progress line. Skipped entirely when SELinux is disabled or the tools are
+ # absent, so a host without SELinux is unaffected.
+ if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce 2>/dev/null)" != "Disabled" ]; then
+ selinuxTool=$(command -v setsebool 2>/dev/null)
+ if [ -n "$selinuxTool" ]; then
+ /bin/echo "Allowing nginx to reach JAWA through SELinux (httpd_can_network_connect)..." >>/var/log/jawaInstall.log 2>&1
+ "$selinuxTool" -P httpd_can_network_connect 1 >>/var/log/jawaInstall.log 2>&1
+ if [ $? -eq 0 ]; then
+ /bin/echo "SELinux httpd_can_network_connect enabled." >>/var/log/jawaInstall.log 2>&1
+ else
+ /bin/echo ""
+ /bin/echo "${cYellow}NOTE${cReset} could not set the SELinux boolean httpd_can_network_connect."
+ /bin/echo " nginx will return 502 until it is allowed to reach port 8000. Run:"
+ /bin/echo " sudo setsebool -P httpd_can_network_connect 1"
+ /bin/echo ""
+ /bin/echo "WARNING: setsebool httpd_can_network_connect failed" >>/var/log/jawaInstall.log 2>&1
+ fi
+ else
+ /bin/echo ""
+ /bin/echo "${cYellow}NOTE${cReset} SELinux is enabled but setsebool is missing (install policycoreutils)."
+ /bin/echo " nginx will return 502 until you run:"
+ /bin/echo " sudo setsebool -P httpd_can_network_connect 1"
+ /bin/echo ""
+ /bin/echo "WARNING: SELinux enabled but setsebool absent" >>/var/log/jawaInstall.log 2>&1
+ fi
+ fi
}
@@ -860,9 +1278,21 @@ configure_nginx_ubuntu() {
nginx_path="/etc/nginx/sites-available"
nginx_enabled="/etc/nginx/sites-enabled"
# Creating the nginx site
- if [ -e ${nginx_path}/jawa ]; then
- rm -f ${nginx_enabled}/jawa
- rm -f ${nginx_path}/jawa
+ # Clear each side independently. This used to gate BOTH removals on
+ # `[ -e $nginx_path/jawa ]` -- the availability file deciding whether the
+ # *enabled* symlink was cleaned, although they are separate files. Two ways
+ # that bites on an upgrade: `-e` is false for a dangling symlink, so a
+ # stale sites-enabled/jawa survived; and an older installer that wrote a
+ # regular file there left it in place. Either way the `ln` below then
+ # failed with "File exists" and nginx went on loading the old file -- or
+ # no JAWA site at all. -L catches the broken-symlink case that -e misses.
+ if [ -e "${nginx_enabled}/jawa" ] || [ -L "${nginx_enabled}/jawa" ]; then
+ /bin/echo "Removing existing ${nginx_enabled}/jawa" >>/var/log/jawaInstall.log 2>&1
+ rm -f "${nginx_enabled}/jawa" >>/var/log/jawaInstall.log 2>&1
+ fi
+ if [ -e "${nginx_path}/jawa" ] || [ -L "${nginx_path}/jawa" ]; then
+ /bin/echo "Removing existing ${nginx_path}/jawa" >>/var/log/jawaInstall.log 2>&1
+ rm -f "${nginx_path}/jawa" >>/var/log/jawaInstall.log 2>&1
fi
cat << EOF > ${nginx_path}/jawa
server {
@@ -879,6 +1309,7 @@ configure_nginx_ubuntu() {
server_name localhost;
server_name_in_redirect off;
+ client_max_body_size 16m;
location / {
# First attempt to serve request as file, then
proxy_pass http://localhost:8000;
@@ -892,8 +1323,20 @@ configure_nginx_ubuntu() {
EOF
- # Enabling nginx
- /bin/ln -s ${nginx_path}/jawa ${nginx_enabled}
+ # Enabling nginx. This was `ln -s ` with no exit check and
+ # no redirect, so a failure printed "File exists" to the terminal and the
+ # `clear` on the next line of install() erased it -- the install then
+ # reported success with an unreachable console. Name the destination
+ # explicitly and use -f -n so an existing file or symlink is replaced
+ # rather than being a fatal collision.
+ if ! /bin/ln -sfn "${nginx_path}/jawa" "${nginx_enabled}/jawa" >>/var/log/jawaInstall.log 2>&1; then
+ /bin/echo ""
+ /bin/echo "${cRed}ERROR${cReset} could not enable the JAWA nginx site."
+ /bin/echo " ${nginx_enabled}/jawa could not be created."
+ /bin/echo " The console will NOT be reachable. See /var/log/jawaInstall.log."
+ /bin/echo ""
+ /bin/echo "ERROR: failed to link ${nginx_enabled}/jawa -> ${nginx_path}/jawa" >>/var/log/jawaInstall.log 2>&1
+ fi
if [ -e ${nginx_path}/default ]; then
while true; do
@@ -924,12 +1367,13 @@ EOF
currentDir=$(pwd)
installDir=/usr/local
timenow=$(date +%m-%d-%y_%T)
+initColour
#branch="main" # Default branch name if no arguments are provided
-
+
while [[ $# -gt 0 ]]; do
key="$1"
-
+
case $key in
b|branch)
branch="$2"
@@ -942,12 +1386,12 @@ while [[ $# -gt 0 ]]; do
;;
esac
done
-
-# If no branch argument was provided, default to "develop"
+
+# If no branch argument was provided, default to "main" (production install path)
if [ -z "$branch" ]; then
branch="main"
fi
-
+
# Checking for sudo
@@ -956,6 +1400,16 @@ if [ "$EUID" -ne 0 ]; then
exit 1
fi
+# 47 `clear` calls and 3 `tput` calls assume the host's terminfo knows
+# $TERM. A modern terminal forwarded over SSH (ghostty, kitty, wezterm) is
+# often absent from a server's terminfo, and every one of them then fails --
+# which also breaks the carriage-return progress redraw, so lines arrive
+# mangled ("'xterm-ghostty': unknown terminal type.ing services...").
+if ! tput clear >/dev/null 2>&1; then
+ /bin/echo "TERM='${TERM:-}' is unknown to this host's terminfo; falling back to TERM=xterm." >>/var/log/jawaInstall.log 2>&1
+ export TERM=xterm
+fi
+
readme
displayMenu
diff --git a/bin/logger.py b/bin/logger.py
index c1ac61c..14288bf 100644
--- a/bin/logger.py
+++ b/bin/logger.py
@@ -70,3 +70,37 @@ def setup_child_logger(
logthis = setup_logger(logger_name, f"{logger_name}.log")
+
+
+def is_debug_enabled() -> bool:
+ """Check if DEBUG logging is currently enabled."""
+ logger = logging.getLogger(logger_name)
+ return logger.level == logging.DEBUG
+
+
+def toggle_debug() -> str:
+ """
+ Toggle between DEBUG and INFO log levels.
+
+ Returns:
+ String indicating the new level ("DEBUG" or "INFO")
+ """
+ logger = logging.getLogger(logger_name)
+
+ if logger.level == logging.DEBUG:
+ # Switch to INFO
+ new_level = logging.INFO
+ level_name = "INFO"
+ else:
+ # Switch to DEBUG
+ new_level = logging.DEBUG
+ level_name = "DEBUG"
+
+ logger.setLevel(new_level)
+
+ # Also update all handlers
+ for handler in logger.handlers:
+ handler.setLevel(new_level)
+
+ logger.info(f"Log level changed to {level_name}")
+ return level_name
diff --git a/bin/okta_verification.py b/bin/okta_verification.py
index f4c29af..7a27614 100755
--- a/bin/okta_verification.py
+++ b/bin/okta_verification.py
@@ -30,11 +30,3 @@
def verify_new_webhook(challenge: Optional[str]) -> Dict[str, str]:
return {"verification": f"{challenge}"}
-
-
-def main() -> None:
- pass
-
-
-if __name__ == "__main__":
- main()
diff --git a/bin/tokens.py b/bin/tokens.py
index 08cd0b9..5d8fb96 100644
--- a/bin/tokens.py
+++ b/bin/tokens.py
@@ -37,16 +37,33 @@
def get_token() -> Optional[Response]:
+ # Clear any prior token up front so a failed fetch can never leave a
+ # stale token from an earlier (real) login behind — that stale token
+ # was the crux of the bogus-credential bypass: _validate_credentials
+ # only checks that session["token"] is truthy.
+ session.pop("token", None)
+ session.pop("expires", None)
try:
resp = requests.post(
f"{session['url']}/api/v1/auth/token",
headers={"Authorization": f"Basic {session['b64_auth'].decode()}"},
)
+ # Without this, a non-2xx passes: Jamf Pro answers a JSON body on
+ # 401/503 too, so resp.json() succeeds and data.get("token")
+ # returns None -- which was then stored and returned exactly like
+ # a success, leaving _validate_credentials' truthiness check as
+ # the only thing standing between a failed fetch and a session.
+ resp.raise_for_status()
data = resp.json()
- session["token"] = data.get("token")
- session["expires"] = data.get("expires")
+ token = data.get("token")
+ expires = data.get("expires")
+ if not token or not expires:
+ # A 2xx carrying no token is not a token.
+ raise ValueError("token response contained no token/expires")
+ session["token"] = token
+ session["expires"] = expires
except Exception as err:
- logthis.info(
+ logthis.error(
f"[{session.get('url')}] Could not get a token using session credentials: {err}. Logging out."
)
return redirect(
@@ -67,7 +84,7 @@ def validate_token(expires: str) -> bool:
if time_to_expire > timedelta(0):
return True
else:
- logthis.info(
+ logthis.warning(
f"[{session.get('url')}] API token expired ({time_to_expire}). Attempting to fetch new token..."
)
return False
@@ -82,7 +99,7 @@ def invalidate_token() -> None:
headers={"Authorization": f"Bearer {session.get('token')}"},
)
except Exception as err:
- logthis.info(
+ logthis.error(
f"[{session.get('url')}] Error accessing Jamf Pro API endpoint for token invalidation. {err}"
)
return
diff --git a/bin/url_safety.py b/bin/url_safety.py
new file mode 100644
index 0000000..e977d80
--- /dev/null
+++ b/bin/url_safety.py
@@ -0,0 +1,57 @@
+# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
+#
+# Copyright (c) 2026 Jamf. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+# * Neither the name of the Jamf nor the names of its contributors may be
+# used to endorse or promote products derived from this software without
+# specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY JAMF SOFTWARE, LLC "AS IS" AND ANY
+# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL JAMF SOFTWARE, LLC BE LIABLE FOR ANY
+# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
+
+"""URL-safety helpers for building local redirect targets (bug B8).
+
+Several legacy compatibility routes build a redirect ``Location`` by
+interpolating a user-controlled value straight into a path string
+(``f"/automations/{name}/edit"``). A value such as ``//evil.com`` or
+``/\\evil.com`` turns the result into a protocol-relative URL that the
+browser resolves off-site (open redirect / phishing; CodeQL
+``py/url-redirection``).
+
+This module has no third-party imports, so it is safe to import from
+``app.py`` and any blueprint without creating an import cycle.
+"""
+
+
+def safe_path_segment(value: str) -> str:
+ """Sanitize a user value that will be interpolated as a *single*
+ path segment in a local redirect target.
+
+ The interpolated value is an automation name and must never contain
+ a path separator. Stripping every slash and backslash guarantees the
+ result cannot introduce an extra segment, a ``//`` run, or a
+ ``/\\`` sequence -- the three ways an interpolated value can make the
+ final path protocol-relative or otherwise escape its intended slot.
+
+ Returns a bare segment safe to embed in an f-string path.
+ """
+ if not value:
+ return ""
+ return value.replace("/", "").replace("\\", "")
diff --git a/data/cron.json b/data/cron.json
deleted file mode 100644
index 0637a08..0000000
--- a/data/cron.json
+++ /dev/null
@@ -1 +0,0 @@
-[]
\ No newline at end of file
diff --git a/data/webhook_schemas.json b/data/webhook_schemas.json
new file mode 100644
index 0000000..3e46846
--- /dev/null
+++ b/data/webhook_schemas.json
@@ -0,0 +1,1057 @@
+{
+ "_comment": "Static reference catalog of Jamf Pro webhook events: display grouping, per-event field schemas, and sample payloads. Hand-maintained, like data/time.json - edit this file directly when Jamf Pro's event set changes. Read by the Webhook Reference pages and by the Jamf Pro automation form's event dropdown, so every event listed under 'categories' needs a matching 'schemas' entry. An entry with \"pending\": true renders as 'not yet confirmed' instead of showing fields.",
+ "categories": {
+ "Computer Events": [
+ "ComputerAdded",
+ "ComputerCheckIn",
+ "ComputerInventoryCompleted",
+ "ComputerPatchPolicyCompleted",
+ "ComputerPolicyFinished",
+ "ComputerPushCapabilityChanged",
+ "SmartGroupComputerMembershipChange"
+ ],
+ "Mobile Device Events": [
+ "MobileDeviceCheckIn",
+ "MobileDeviceCommandCompleted",
+ "MobileDeviceEnrolled",
+ "MobileDeviceInventoryCompleted",
+ "MobileDevicePushSent",
+ "MobileDeviceUnEnrolled",
+ "SmartGroupMobileDeviceMembershipChange"
+ ],
+ "System Events": [
+ "DeviceAddedToDEP",
+ "DeviceRateLimited",
+ "JSSShutdown",
+ "JSSStartup",
+ "PatchSoftwareTitleUpdated",
+ "PushSent",
+ "RestAPIOperation",
+ "SCEPChallenge",
+ "SmartGroupUserMembershipChange"
+ ]
+ },
+ "schemas": {
+ "ComputerAdded": {
+ "description": "Triggered when a new computer record is created in Jamf Pro",
+ "schema": {
+ "alternateMacAddress": " Secondary MAC address (e.g., Ethernet)",
+ "building": " Building name assigned to the computer",
+ "department": " Department name assigned to the computer",
+ "deviceName": " Computer name as reported by the device",
+ "emailAddress": " Email address of the assigned user",
+ "ipAddress": " Current IP address of the computer",
+ "jssID": " Unique Jamf Pro computer record ID",
+ "macAddress": " Primary MAC address (usually Wi-Fi)",
+ "model": " Hardware model identifier",
+ "osBuild": " macOS build number",
+ "osVersion": " macOS version string",
+ "phone": " Phone number of the assigned user",
+ "position": " Job title of the assigned user",
+ "realName": " Full name of the assigned user",
+ "reportedIpAddress": " IP address reported during last check-in",
+ "room": " Room number/name assigned to the computer",
+ "serialNumber": " Hardware serial number",
+ "udid": " Unique Device Identifier",
+ "userDirectoryID": " Directory service user ID (e.g., from AD/LDAP)",
+ "username": " Username of the assigned user"
+ }
+ },
+ "ComputerCheckIn": {
+ "description": "Triggered when a computer checks in with Jamf Pro",
+ "schema": {
+ "alternateMacAddress": " Secondary MAC address",
+ "building": " Building name",
+ "department": " Department name",
+ "deviceName": " Computer name",
+ "emailAddress": " Assigned user email",
+ "ipAddress": " Current IP address",
+ "jssID": " Jamf Pro computer ID",
+ "macAddress": " Primary MAC address",
+ "model": " Hardware model",
+ "osBuild": " macOS build number",
+ "osVersion": " macOS version",
+ "phone": " User phone number",
+ "position": " User job title",
+ "realName": " User full name",
+ "reportedIpAddress": " Reported IP address",
+ "room": " Room assignment",
+ "serialNumber": " Serial number",
+ "udid": " UDID",
+ "userDirectoryID": " Directory user ID",
+ "username": " Username"
+ }
+ },
+ "ComputerInventoryCompleted": {
+ "description": "Triggered when a computer completes a full inventory update",
+ "schema": {
+ "alternateMacAddress": " Secondary MAC address",
+ "building": " Building name",
+ "department": " Department name",
+ "deviceName": " Computer name",
+ "emailAddress": " Assigned user email",
+ "ipAddress": " Current IP address",
+ "jssID": " Jamf Pro computer ID",
+ "macAddress": " Primary MAC address",
+ "model": " Hardware model",
+ "osBuild": " macOS build number",
+ "osVersion": " macOS version",
+ "phone": " User phone number",
+ "position": " User job title",
+ "realName": " User full name",
+ "reportedIpAddress": " Reported IP address",
+ "room": " Room assignment",
+ "serialNumber": " Serial number",
+ "udid": " UDID",
+ "userDirectoryID": " Directory user ID",
+ "username": " Username"
+ }
+ },
+ "ComputerPatchPolicyCompleted": {
+ "description": "Triggered when a patch policy completes on a computer",
+ "schema": {
+ "alternateMacAddress": " Secondary MAC address",
+ "building": " Building name",
+ "department": " Department name",
+ "deviceName": " Computer name",
+ "emailAddress": " Assigned user email",
+ "ipAddress": " Current IP address",
+ "jssID": " Jamf Pro computer ID",
+ "macAddress": " Primary MAC address",
+ "model": " Hardware model",
+ "osBuild": " macOS build number",
+ "osVersion": " macOS version",
+ "patchPolicyId": " ID of the patch policy that ran",
+ "patchPolicyName": " Name of the patch policy",
+ "phone": " User phone number",
+ "position": " User job title",
+ "realName": " User full name",
+ "reportedIpAddress": " Reported IP address",
+ "room": " Room assignment",
+ "serialNumber": " Serial number",
+ "softwareTitleId": " ID of the software title being patched",
+ "softwareTitleName": " Name of the software title",
+ "successful": " Whether the patch was successfully installed",
+ "udid": " UDID",
+ "userDirectoryID": " Directory user ID",
+ "username": " Username"
+ }
+ },
+ "ComputerPolicyFinished": {
+ "description": "Triggered when a policy finishes executing on a computer",
+ "schema": {
+ "alternateMacAddress": " Secondary MAC address",
+ "building": " Building name",
+ "department": " Department name",
+ "deviceName": " Computer name",
+ "emailAddress": " Assigned user email",
+ "ipAddress": " Current IP address",
+ "jssID": " Jamf Pro computer ID",
+ "macAddress": " Primary MAC address",
+ "model": " Hardware model",
+ "osBuild": " macOS build number",
+ "osVersion": " macOS version",
+ "phone": " User phone number",
+ "policyId": " ID of the policy that finished",
+ "position": " User job title",
+ "realName": " User full name",
+ "reportedIpAddress": " Reported IP address",
+ "room": " Room assignment",
+ "serialNumber": " Serial number",
+ "successful": " Whether the policy completed successfully",
+ "udid": " UDID",
+ "userDirectoryID": " Directory user ID",
+ "username": " Username"
+ }
+ },
+ "ComputerPushCapabilityChanged": {
+ "description": "Triggered when a computer's push notification capability changes",
+ "schema": {
+ "alternateMacAddress": " Secondary MAC address",
+ "building": " Building name",
+ "department": " Department name",
+ "deviceName": " Computer name",
+ "emailAddress": " Assigned user email",
+ "ipAddress": " Current IP address",
+ "jssID": " Jamf Pro computer ID",
+ "macAddress": " Primary MAC address",
+ "model": " Hardware model",
+ "osBuild": " macOS build number",
+ "osVersion": " macOS version",
+ "phone": " User phone number",
+ "position": " User job title",
+ "realName": " User full name",
+ "reportedIpAddress": " Reported IP address",
+ "room": " Room assignment",
+ "serialNumber": " Serial number",
+ "udid": " UDID",
+ "userDirectoryID": " Directory user ID",
+ "username": " Username"
+ }
+ },
+ "MobileDeviceCheckIn": {
+ "description": "Triggered when a mobile device checks in with Jamf Pro",
+ "schema": {
+ "bluetoothMacAddress": " Bluetooth MAC address",
+ "building": " Building name",
+ "department": " Department name",
+ "deviceName": " Device name",
+ "emailAddress": " Assigned user email",
+ "icciID": " Integrated Circuit Card ID for SIM",
+ "imei": " International Mobile Equipment Identity",
+ "ipAddress": " Current IP address",
+ "jssID": " Jamf Pro mobile device ID",
+ "model": " Device model",
+ "modelDisplay": " Human-readable model name",
+ "osBuild": " iOS/iPadOS build number",
+ "osVersion": " iOS/iPadOS version",
+ "phone": " User phone number",
+ "position": " User job title",
+ "product": " Product identifier",
+ "realName": " User full name",
+ "room": " Room assignment",
+ "serialNumber": " Serial number",
+ "udid": " UDID",
+ "userDirectoryID": " Directory user ID",
+ "username": " Username",
+ "version": " Device version",
+ "wifiMacAddress": " Wi-Fi MAC address"
+ }
+ },
+ "MobileDeviceCommandCompleted": {
+ "description": "Triggered when an MDM command completes on a mobile device",
+ "schema": {
+ "bluetoothMacAddress": " Bluetooth MAC address",
+ "building": " Building name",
+ "department": " Department name",
+ "deviceName": " Device name",
+ "emailAddress": " Assigned user email",
+ "icciID": " ICC ID",
+ "imei": " IMEI",
+ "ipAddress": " Current IP address",
+ "jssID": " Jamf Pro mobile device ID",
+ "model": " Device model",
+ "modelDisplay": " Model display name",
+ "osBuild": " OS build number",
+ "osVersion": " OS version",
+ "phone": " User phone number",
+ "position": " User job title",
+ "product": " Product identifier",
+ "realName": " User full name",
+ "room": " Room assignment",
+ "serialNumber": " Serial number",
+ "udid": " UDID",
+ "userDirectoryID": " Directory user ID",
+ "username": " Username",
+ "version": " Device version",
+ "wifiMacAddress": " Wi-Fi MAC address"
+ }
+ },
+ "MobileDeviceEnrolled": {
+ "description": "Triggered when a mobile device completes MDM enrollment",
+ "schema": {
+ "bluetoothMacAddress": " Bluetooth MAC address",
+ "building": " Building name",
+ "department": " Department name",
+ "deviceName": " Device name",
+ "emailAddress": " Assigned user email",
+ "icciID": " ICC ID",
+ "imei": " IMEI",
+ "ipAddress": " Current IP address",
+ "jssID": " Jamf Pro mobile device ID",
+ "model": " Device model",
+ "modelDisplay": " Model display name",
+ "osBuild": " OS build number",
+ "osVersion": " OS version",
+ "phone": " User phone number",
+ "position": " User job title",
+ "product": " Product identifier",
+ "realName": " User full name",
+ "room": " Room assignment",
+ "serialNumber": " Serial number",
+ "udid": " UDID",
+ "userDirectoryID": " Directory user ID",
+ "username": " Username",
+ "version": " Device version",
+ "wifiMacAddress": " Wi-Fi MAC address"
+ }
+ },
+ "MobileDeviceInventoryCompleted": {
+ "description": "Triggered when a mobile device completes a full inventory update",
+ "schema": {
+ "bluetoothMacAddress": " Bluetooth MAC address",
+ "building": " Building name",
+ "department": " Department name",
+ "deviceName": " Device name",
+ "emailAddress": " Assigned user email",
+ "icciID": " ICC ID",
+ "imei": " IMEI",
+ "ipAddress": " Current IP address",
+ "jssID": " Jamf Pro mobile device ID",
+ "model": " Device model",
+ "modelDisplay": " Model display name",
+ "osBuild": " OS build number",
+ "osVersion": " OS version",
+ "phone": " User phone number",
+ "position": " User job title",
+ "product": " Product identifier",
+ "realName": " User full name",
+ "room": " Room assignment",
+ "serialNumber": " Serial number",
+ "udid": " UDID",
+ "userDirectoryID": " Directory user ID",
+ "username": " Username",
+ "version": " Device version",
+ "wifiMacAddress": " Wi-Fi MAC address"
+ }
+ },
+ "MobileDevicePushSent": {
+ "description": "Triggered when a push notification is sent to a mobile device",
+ "schema": {
+ "bluetoothMacAddress": " Bluetooth MAC address",
+ "building": " Building name",
+ "department": " Department name",
+ "deviceName": " Device name",
+ "emailAddress": " Assigned user email",
+ "icciID": " ICC ID",
+ "imei": " IMEI",
+ "ipAddress": " Current IP address",
+ "jssID": " Jamf Pro mobile device ID",
+ "model": " Device model",
+ "modelDisplay": " Model display name",
+ "osBuild": " OS build number",
+ "osVersion": " OS version",
+ "phone": " User phone number",
+ "position": " User job title",
+ "product": " Product identifier",
+ "realName": " User full name",
+ "room": " Room assignment",
+ "serialNumber": " Serial number",
+ "udid": " UDID",
+ "userDirectoryID": " Directory user ID",
+ "username": " Username",
+ "version": " Device version",
+ "wifiMacAddress": " Wi-Fi MAC address"
+ }
+ },
+ "MobileDeviceUnEnrolled": {
+ "description": "Triggered when a mobile device is unenrolled from MDM",
+ "schema": {
+ "bluetoothMacAddress": " Bluetooth MAC address",
+ "building": "