feat: verbose sign-in diagnostics, debug logging, log rollover and per-server certificate exception
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 5e6bfd71ee8a5674882f7680c695a0ada599622f] (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (pull_request) Successful in 13s
QRun-IO/ci-policy: Shared .NET CI / verify (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / portable (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (pull_request) Successful in 11s
QRun-IO/ci-policy: Shared source and config CI / secrets (pull_request) Successful in 12s
QRun-IO/ci-policy: Shared source and config CI / gate (pull_request) Successful in 8s
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Successful in 29s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
QRun-IO/ci-policy: Shared .NET CI / hosted (pull_request) Failing after 6m14s
QRun-IO/ci-policy: Shared .NET CI / gate (pull_request) Failing after 16s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Failing after 6m33s
QRun-IO/ci-policy: native / native-result (pull_request) Failing after 6s

The session client narrates every step of discovery, authentication metadata,
native grant and provider validation, browser callback handling, sign-in,
restore, renewal, identity, device binding and the realtime upgrade, and says
which server-side requirement is missing when it refuses a server. The service
logs each sign-in, sign-out, enrollment, authorization and reconnect step and
every failure with its inner causes; the companion logs its side and shows the
reason in the dialog. TLS rejections are logged with the policy error,
certificate identity and remedy.

Log settings add a Debug logging switch and replace the daily cap that
silenced the log with size rollover to numbered segments (default 100 MiB,
10 per day). Connection settings add "Allow untrusted server certificate for
this server", bound to one origin, persisted in the service store, honored by
the service and companion and logged with a warning on every session.

Version 1.1.0. No wire change; native printing stays 2.3 with wire payload v1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
This commit is contained in:
james.maesandClaude Fable 5.1 committed 2026-11-05 09:04:12 -06:00
1 parent acb3238086
commit aaa5051f8e
38 files changed
+1851 -295

No files matched your search

+19
View File
@@ -1,5 +1,24 @@
# Changelog
## 1.1.0
Diagnostics for new accounts and servers. The session client now narrates every step of discovery,
authentication metadata, native grant validation, provider discovery, browser callback handling,
password sign-in, session restore and renewal, identity checks, device binding and the realtime
socket upgrade, and says exactly which server-side requirement is missing when it refuses a server;
the service logs each sign-in, sign-out, enrollment, authorization and reconnect step and every
failure with its inner causes; the companion logs its side and shows the failure reason in the
dialog instead of a generic message. TLS certificate rejections are logged with the policy error,
certificate identity and remedy. A new **Allow untrusted server certificate for this server** option (Connection
settings, bound to one server origin, persisted in the protected store, honored by the service and
companion, logged with a warning on every session) supports development servers with self-signed or
private-authority certificates without weakening any other server connection.
Log settings gain **Debug logging** (request routes, status codes, timings, realtime frames and
full failure detail; never credentials) and replace the daily cap that silenced the log with size
rollover: a file that reaches the limit (default 100 MiB, up to 1024) becomes a numbered segment and
logging continues, keeping at most a configurable number of segments per day (default 10). No wire
change; native printing stays 2.3 with wire payload v1.
## 1.0.1
Packaging fix; no application, wire or dependency change. The `v1.0.0` release run passed
+94 -2
View File
@@ -1,3 +1,95 @@
# Agent guidance
# CLAUDE.md
See [AGENTS.md](AGENTS.md) for repository instructions and [CONTRIBUTING.md](CONTRIBUTING.md) for development conventions.
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Policy lives in [AGENTS.md](AGENTS.md) (repository rules: hosting split, authorization, logging, printing) and [CONTRIBUTING.md](CONTRIBUTING.md) (conventions, script quality). Read both; this file adds the commands and architecture they leave implicit.
## Prerequisites
- Windows x64, PowerShell 7.4+, .NET SDK pinned by `global.json` (10.0.x).
- `qqq-print-protocol` checked out as a **sibling directory** at the SHA pinned in `.github/workflows/ci.yml`. Only `Core.Tests` consumes it (it links `../qqq-print-protocol/fixtures/*.json` at build time; override with `-p:PrintingProtocolFixtures=<dir>` or `Verify.ps1 -ProtocolFixtures`), so the NativeCI solution builds without it.
- Scripts tooling: `uv sync --locked` and `npm ci --ignore-scripts` (Node 24+).
## Commands
**Host restriction first.** `Qqq.PrintAgent.Core.Tests` is hosted-CI-only. On this development PC, Windows Defender quarantines the Core test assembly, so that project must not be built or run locally (see `docs/SESSION-STATE.md`; do not add exclusions or restore quarantine to work around it). Local work uses `Qqq.PrintAgent.NativeCI.slnx`, which is the full solution minus `Core.Tests`. Core test evidence comes from the hosted Windows CI run on the pushed commit.
Local default (locked restore, Release x64 build with `-warnaserror`, Windows/CLI tests), always with `-p:Platform=x64` because the solution maps test projects to AnyCPU and a direct `dotnet test` without it picks stale output. Never pass `--no-build` to `dotnet test`: the solution build writes the test DLL to the AnyCPU folder, so an x64 `--no-build` run executes whatever old x64 DLL is lying around and reports a pass against stale code.
```powershell
dotnet restore Qqq.PrintAgent.NativeCI.slnx -p:Platform=x64 --locked-mode
dotnet build Qqq.PrintAgent.NativeCI.slnx -c Release -p:Platform=x64 --no-restore -warnaserror
dotnet test tests/Qqq.PrintAgent.Cli.Tests -c Release -p:Platform=x64 --no-restore
```
Single test (xunit):
```powershell
dotnet test tests/Qqq.PrintAgent.Cli.Tests -p:Platform=x64 --filter "FullyQualifiedName~AgentPipeSecurityTests"
```
Full verification, **only on a permitted host or hosted CI** (adds Core.Tests, coverage and TRX output under `TestResults/verify-<guid>`; requires the sibling protocol checkout):
```powershell
./build/Verify.ps1
```
Packaging (unsigned self-contained x64 MSI; never installs or prints):
```powershell
./build/Build-Installer.ps1 -ProductVersion 1.0.1 -ReleaseVersion 1.0.1
./build/Package-Release.ps1 # runs the CI packaging checks and assembles artifacts/release
```
Script quality gates (CI runs the same):
```powershell
./build/Analyze-Scripts.ps1 # PSScriptAnalyzer 1.25 over build/*.ps1, one rule at a time
uv run --locked --no-sync python .github/scripts/test-script-quality.py
uv run --locked --no-sync python .github/scripts/check-script-quality.py
python .github/scripts/check-source-headers.py # owned-file copyright/SPDX notices
npm run typecheck; npm run lint; npm test # tools/virtual-agent (Node, ESLint Allman)
```
Interactive-only suites (not run by hosted CI):
- **UI tests**: elevated interactive desktop; build the companion, optionally set `K22_UITEST_EXE`, then `dotnet test tests/Qqq.PrintAgent.UiTests -p:Platform=x64`.
- **Layout tests**: `tests/Qqq.PrintAgent.LayoutTests/Run.ps1 -EvidenceDirectory <new dir>`; synthetic service only, not in the solution file.
Pre-commit hooks (`pre-commit install --hook-type pre-commit --hook-type commit-msg`) enforce Conventional Commit types, DCO sign-off (`git commit -s`), gitleaks, and no direct commits to `main`.
## Architecture
Five projects, strict layering (Core → Windows → Service/UI/CLI). Business logic never lives above Core; OS calls never live in Core.
| Project | Owns | Key entry points |
| --- | --- | --- |
| `Qqq.PrintAgent.Core` | QQQ session auth, HTTP/WSS clients, realtime settings/policy, document streaming, job state machine | `Authentication/QqqSessionClient*`, `Printing/PrintApiClient*`, `Printing/PrintWorker`, `Printing/PrintingRealtimeChannel` |
| `Qqq.PrintAgent.Windows` | Spooler/PDF/ZPL backends, SQLite + DPAPI store, named-pipe IPC, the long-running runtime | `AgentRuntime.*.cs` (partial class split by concern), `AgentPipe*`, `AgentStore` |
| `Qqq.PrintAgent.Service` | Windows Service host only | `PrintService.cs` |
| `Qqq.PrintAgent` | WinUI/AppKit companion: tray, Printers/Jobs/Logs/Settings views, MSI update UX | `MainWindow.*.cs` (partial class split by concern) |
| `Qqq.PrintAgent.Cli` | Authenticated service management over the pipe; isolated legacy diagnostics | `Program.cs` |
Large classes are deliberately split into partial files by concern (`AgentRuntime.Realtime.cs`, `AgentRuntime.Jobs.cs`, `MainWindow.Updates.cs`, ...). Add new behavior to the matching partial rather than growing `AgentRuntime.cs` / `MainWindow.xaml.cs`.
**Runtime model.** The service is the only process holding credentials, the journal and printer access. It opens an outbound authenticated WebSocket (`/qqq/v1/qbit/printing/device/realtime`, subprotocol `qqq-printing.v1`) for commands, status and job notifications; documents stream separately over HTTPS into a protected staging directory with length + SHA-256 verification. There is intentionally **no polling fallback**. The companion and CLI talk to the service only through the `QqqPrintAgent` named pipe (administrator clients) and receive credential-free snapshots. Printing runs in an isolated child process with a deadline. See `docs/REALTIME.md` for the worker split, bounds and reconnect rules.
**Reliability boundary.** `PrintWorker` persists receipt and `SUBMITTING` before calling Windows, records `SPOOLED` plus the spooler job ID afterward, and replays pending cloud events. A crash between spool acceptance and recording is not atomic; the worker reports `OUTCOME_UNKNOWN` rather than auto-reprinting. Preserve this distinction in any retry/cancel change, and keep "spool accepted" and "paper came out" separate in docs and messages.
**Versions.** Product version (`VersionPrefix` in `Directory.Build.props`, release workflow overrides with `-p:Version`), native printing API (2.3), wire payload (v1) and the WebSocket subprotocol are independent. Do not conflate them when bumping.
**Updates.** `MsiUpdateService` + `GitHubMsiReleaseClient` read the GitHub release feed and apply a machine MSI through Windows Installer, updating service, companion and CLI together. That GitHub feed is why the GitHub shadow must stay even though Gitea is primary.
## Test layout
- `tests/Qqq.PrintAgent.Core.Tests`: protocol fixtures (`ProtocolFixtureTests`), loopback WebSocket peer, worker state machine, media contracts. Hosted CI only on this PC (see Commands); new Core tests are written here and validated by the CI run.
- `tests/Qqq.PrintAgent.Cli.Tests`: despite the name, this is the Windows runtime suite (`AgentRuntime*`, `AgentRealtime*`, `AgentPipe*`, `AgentStore*`). Put Windows-layer tests here.
- `tests/Qqq.PrintAgent.WorkerFixture`: child-process fixture used by runtime tests.
## Conventions worth knowing
- `.editorconfig`: 3-space indentation, Allman braces, everywhere (C#, PowerShell, scripts). Analyzers run in build; zero warnings is enforced by `TreatWarningsAsErrors`.
- Every owned source file starts with the `Copyright (c) 2026 James Maes` / `SPDX-License-Identifier: MIT` header; `check-source-headers.py` fails otherwise. `tools/virtual-agent` keeps its QRun.IO attribution.
- Package versions are central (`Directory.Packages.props`); after changing them, regenerate each affected `packages.lock.json` (`dotnet restore --force-evaluate`).
- Coverage is reported, not gated, per `docs/adr/0002-prerelease-quality-and-packaging.md`.
- `docs/SESSION-STATE.md` records the current release/verification state; consult it before claiming anything is verified.
+1 -1
View File
@@ -60,7 +60,7 @@
commit as "+<sha>" on AssemblyInformationalVersion, which the About dialog and the
CLI's version output show as a short hash. -->
<PropertyGroup>
<VersionPrefix>1.0.1</VersionPrefix>
<VersionPrefix>1.1.0</VersionPrefix>
<VersionSuffix></VersionSuffix>
</PropertyGroup>
+1 -1
View File
@@ -65,7 +65,7 @@ The client reports the Windows queue's machine-default media as a revision-bound
| Transport | Outbound authenticated WSS plus same-origin HTTPS document streams, with automatic reconnect |
| Capacity | Bounded download/print concurrency, staging, bandwidth and timeouts; one download and one print lane by default, ordered per printer |
| Local history | 50-row pages; default detailed retention 30 days or latest 300,000 detailed jobs, with active/uncertain evidence and duplicate-suppression records retained |
| Diagnostic logs | Daily rotation; defaults 30 days and 20 MiB per day, configurable in the client |
| Diagnostic logs | Daily rotation plus size rollover to numbered segments; defaults 30 days, 100 MiB per file and 10 segments per day, with optional debug logging, configurable in the client |
The client supports native printing extension `>=2.0.0 <3.0.0`; optional media negotiation requires stable extension 2.3 or later in that major. Limits are resource bounds, not a printer-throughput guarantee. See [Realtime settings](docs/REALTIME.md) and [History and log limits](docs/SCALABILITY.md).
+2 -2
View File
@@ -4,8 +4,8 @@
[CmdletBinding()]
param(
[string] $ProductVersion = '1.0.1',
[string] $ReleaseVersion = '1.0.1',
[string] $ProductVersion = '1.1.0',
[string] $ReleaseVersion = '1.1.0',
# Assemble artifacts/release from an already built MSI (the signed stable flow builds and signs it in
# separate workflow steps). With ExpectedPublisherSubject the MSI must carry that publisher's signature.
[switch] $AssembleOnly,
+1 -1
View File
@@ -50,7 +50,7 @@ Creation time is separate from the latest status-update time. Receipt captures t
## Logs
AppKit retains its local-midnight daily filename rotation and shared log viewer. Machine-wide settings default to 30 days and 20 MiB per daily file; supported ranges are 1–365 days and 1–100 MiB. Service and companion use one cross-process lock for appends, settings, retention and clearing.
AppKit retains its local-midnight daily filename rotation and shared log viewer. Machine-wide settings default to 30 days retention, 100 MiB per file and 10 rolled-over segments per day; supported ranges are 1–365 days, 1–1024 MiB and 1–100 segments. A file that reaches its limit is renamed to `qqq-print-agent-yyyy-MM-dd.N.log` and a fresh file continues, so the bound per day is the file size times the kept segments plus the live file, and no entry is ever suppressed. Debug logging (off by default) adds request-level detail. Service and companion use one cross-process lock for appends, rollover, settings, retention and clearing.
At the daily limit, the backend writes a warning when space permits and persists a cap marker. Settings exposes current bytes and whether that day's limit was reached. Further diagnostics are suppressed until the next local day, an increased limit or explicit clearing; printing continues. The cap is visible rather than silently discarding ongoing errors. Detailed high-frequency logs may need a higher limit than the normal three status entries per job used by the synthetic workload.
+3 -1
View File
@@ -3,7 +3,9 @@
| Symptom | Check |
| --- | --- |
| Companion cannot connect to service | Confirm the MSI is installed, `QqqPrintAgent` is running, and the companion is elevated. A development CLI worker does not host the installed service pipe. |
| Enrollment fails | Complete standard QQQ sign-in, then check the server URL, fresh/unconsumed code, device/facility configuration and server logs. Remote endpoints require HTTPS. |
| Sign-in or discovery fails | Open **View logs**: every step (discovery, authentication metadata, native grant validation, provider discovery, browser callback, session verification by the service, device binding, identity check) is logged with the reason it stopped, and the dialog shows the same reason. Turn on **Debug logging** in Log settings for request routes, status codes, timings and realtime frames. |
| Sign-in works in the companion but "Service authorization not completed" | The service verifies the session itself as LocalSystem. A self-signed or private-authority certificate (for example a local Caddy root) that your user account trusts is not in the machine store, so the service's TLS check fails; the log shows `TLS certificate for host rejected: RemoteCertificateChainErrors`. Install the issuing root in **Local Machine → Trusted Root Certification Authorities**, or enter the server address and enable **Allow untrusted server certificate for this server** in Connection settings for a development server (the exception is bound to that one server). |
| Enrollment fails | Complete standard QQQ sign-in, then check the server URL, fresh/unconsumed code, device/facility configuration and server logs. Remote endpoints require HTTPS. The log records each enrollment step and the server's answer. |
| No printers in the service list | Install the queue for the machine/service identity. User-specific network mappings may not be visible to LocalSystem. Confirm Spooler is running. |
| Windows test page fails | Repair driver, connection or printer hardware locally before testing cloud printing. |
| Discovered printer never receives work | Share the queue locally, enable the matching discovered printer in QQQ and submit to that printer on the intended device. |
+2 -2
View File
@@ -10,7 +10,7 @@ This guide describes **1.0.1**, the signed stable Windows release. Use it with q
## Connect and select printers
Open **QQQ Print Agent** from Start. In **Settings → Server connection → Sign in / Connection settings**, enter your HTTPS QQQ server address and select **Discover sign-in options**. Sign in with the advertised username/password or browser flow. **Reuse the saved pairing** is the default; an older unbound device can be explicitly associated with your account. For a new pairing, obtain an enrollment code from QQQ and choose **Connect this device**. The Windows service holds the device credential; the companion never receives it. **Reconnect** requests another connection attempt. **Sign in again** restores user authentication while preserving the saved pairing and history. Changing servers or replacing a revoked pairing requires the explicit **Set up a new pairing** choice; see [QQQ sign-in](QQQ-SIGN-IN.md).
Open **QQQ Print Agent** from Start. In **Settings → Server connection → Sign in / Connection settings**, enter your HTTPS QQQ server address and select **Discover sign-in options**. Every step of discovery, sign-in, the hand-off to the service and the service's own verification is written to the activity log, and a failure's reason is shown in the dialog as well as logged; open **View logs** when something stops. For a development server with a self-signed or private-authority certificate, enter its address and turn on **Allow untrusted server certificate for this server**; both the companion and the Windows service then accept that one server's certificate, and no other server is affected. The service runs as LocalSystem and trusts only the machine certificate store, so a certificate that your user account trusts can still be refused by the service; either install the issuing root in the Local Machine Trusted Root store or use this option. With it on, any certificate is accepted and every session is logged with a warning, so turn it off for production servers. Sign in with the advertised username/password or browser flow. **Reuse the saved pairing** is the default; an older unbound device can be explicitly associated with your account. For a new pairing, obtain an enrollment code from QQQ and choose **Connect this device**. The Windows service holds the device credential; the companion never receives it. **Reconnect** requests another connection attempt. **Sign in again** restores user authentication while preserving the saved pairing and history. Changing servers or replacing a revoked pairing requires the explicit **Set up a new pairing** choice; see [QQQ sign-in](QQQ-SIGN-IN.md).
If enrollment times out, check QQQ before trying again: the code may already have been consumed. Generate a fresh code and revoke an unused/orphaned device through your administrator. An unreadable saved credential can be repaired by explicit enrollment; the previous encrypted file and journal are preserved.
@@ -62,7 +62,7 @@ The footer and tray use text plus color: green for connected/ready, orange for e
**Logs**, **View logs**, and **Open activity log** all open the same built-in AppKit viewer. Service and companion activity are together, persisted under the protected machine data directory with configurable daily rotation and retention. Use **Follow**, **Errors only**, **Copy**, and **Open logs folder**. Credentials and document payloads must never be logged; local identifiers remain operational data.
**Settings → Diagnostic logs → Log settings** controls retention (1–365 days, default 30) and daily size (1–100 MiB, default 20). It shows usage and whether the daily cap was reached. At the cap, printing continues but diagnostics pause until the next local day, an increased limit or explicit clearing. **Clear local logs** confirms removal of diagnostic logs only, preserving credentials, jobs, settings and journal history. The same controls are available on Logs.
**Settings → Diagnostic logs → Log settings** controls retention (1–365 days, default 30), the file size at which a day's log rolls over (1–1024 MiB, default 100), how many rolled-over segments of a day are kept (1–100, default 10) and **Debug logging**. A file that reaches the size limit is renamed to a numbered segment (`qqq-print-agent-yyyy-MM-dd.1.log`, `.2.log`, …) and logging continues in a fresh file; logging never stops because of size, and the oldest segments beyond the kept count are removed. Debug logging adds every request route, status code and timing, every realtime frame and full failure detail; credentials, documents and enrollment codes are never logged at any level. **Clear local logs** confirms removal of diagnostic logs only, preserving credentials, jobs, settings and journal history. The same controls are available on Logs.
Settings shows actual Windows service state and provides **Start service**, **Restart service**, **Windows Services**, and an installer link. Restarting can interrupt a submission; wait for active work when possible. The service continues printing when the companion exits.
+3 -2
View File
@@ -35,14 +35,15 @@ The QQQ server supplies assigned document bytes over HTTPS. The Windows LocalSys
| Rendering/driver execution | Native Windows PDF/GDI/spooler APIs; bounded render dimensions | Disposable child with bounded timeouts and parent-exit cleanup; child retains service privileges, so this is fault isolation, not a security sandbox |
| RAW ZPL | Explicit compatible queue, trusted server workflow | ZPL can contain printer-control commands; not a sandboxed format |
| Duplicate output | Persistent receipts and events; uncertain submissions require review | Database and physical printer cannot share a transaction |
| Tray snapshot/logs | No device credential or document bytes; bounded recent jobs/logs | IDs/errors remain operational data and must be reviewed before sharing |
| Tray snapshot/logs | No device credential or document bytes; bounded recent jobs/logs; debug logging adds routes, status codes, timings, frame types and certificate identities, never secrets | IDs/errors remain operational data and must be reviewed before sharing |
| Server certificate trust | Windows machine-store validation for the service and user-store validation for the companion; rejections are logged with the policy error and certificate identity | An administrator may enable "Allow untrusted server certificate" for one named server origin (scheme, host and port) for development; it accepts any certificate (including a forged one) from that server only until turned off, is persisted in the protected store, never applies to another server, and every session created under it is logged with a warning |
| Software distribution | Public source, pinned actions/dependencies, audited locked restore, checksums and exact-release attestations | Published prereleases remain unsigned; source checks do not qualify installed behavior |
## Data handling
The service owns `connection.dpapi`, `journal.db` and its WAL. The MSI retains state through uninstall/reinstall. Revoke/decommission the device and resolve pending jobs before deliberately deleting retained state. Only delivered terminal detail whose failures were reviewed is pruned; final-state evidence is retained. Never publish raw state or customer documents.
Service and companion persist serialized, credential-redacted AppKit logs with 30-day retention; recent job snapshots come from durable connection-scoped metadata. Tray colors describe observed connectivity/queue state, not a guarantee of physical printer readiness or completed output.
Service and companion persist serialized, credential-redacted AppKit logs with 30-day retention, rolling a day's file over to numbered segments at a configurable size (default 100 MiB, at most 10 segments kept per day) rather than suppressing entries; recent job snapshots come from durable connection-scoped metadata. Tray colors describe observed connectivity/queue state, not a guarantee of physical printer readiness or completed output.
## Follow-up
@@ -33,28 +33,69 @@ public sealed class QqqApiDiscovery
/// <summary>A one-line, credential-free summary for the log: API version and every advertised extension with its version and path.</summary>
public string Describe()
{
string extensions = Extensions is null || Extensions.Count == 0 ? "no extensions"
: string.Join(", ", Extensions.Where(extension => extension is not null).Select(extension => $"{extension.Name} {extension.Version} at {extension.PathPrefix}"));
return $"QQQ API {(string.IsNullOrEmpty(ApiVersion) ? "(missing)" : ApiVersion)}, {Features?.Count ?? 0} feature(s), client channels [{string.Join(", ", ClientChannels ?? [])}], {extensions}.";
}
/// <summary>
/// Checks that the server offers what this agent needs: an API version of 1.8 or later (1.9 when native browser sign-in is
/// required, together with that feature), the core features the agent calls, the api client channel, and exactly one printing
/// extension at the standard path in major version 1 or 2. Anything else throws a configuration error, so an unsuitable
/// server is refused with one clear message instead of failing later in some other way.
/// </summary>
internal void Validate(bool nativeSession = false)
internal void Validate(bool nativeSession = false, Action<string>? report = null)
{
List<string> problems = Problems(nativeSession);
if(problems.Count != 0)
{
report?.Invoke("Discovery rejected: " + string.Join("; ", problems) + ".");
throw Configuration();
}
}
/// <summary>
/// Lists every way the discovery document falls short of what this agent needs, in plain words, so the log can say which
/// server-side requirement is missing. The thrown failure stays generic; only the local log sees this detail.
/// </summary>
private List<string> Problems(bool nativeSession)
{
var problems = new List<string>();
string[] required = ["version", "metaData", "processes", "processCancel", "extensions", "widgets"];
if(!SupportedVersion(ApiVersion, nativeSession ? 9u : 8u) || Features is null
|| required.Any(feature => !Features.Contains(feature, StringComparer.Ordinal))
|| ClientChannels is null || !ClientChannels.Contains("api", StringComparer.Ordinal)
|| Extensions is null || Extensions.Count(extension => extension is not null && string.Equals(extension.Name, "printing", StringComparison.Ordinal)) != 1)
uint minimumMinor = nativeSession ? 9u : 8u;
if(!SupportedVersion(ApiVersion, minimumMinor))
{ problems.Add($"API version '{ApiVersion}' is not a plain 1.{minimumMinor}+ release{(nativeSession ? " (1.9 is needed for native browser sign-in)" : "")}"); }
if(Features is null)
{ problems.Add("no features list"); }
else
{
throw Configuration();
string[] missing = [.. required.Where(feature => !Features.Contains(feature, StringComparer.Ordinal))];
if(missing.Length != 0)
{ problems.Add("missing feature(s) " + string.Join(", ", missing)); }
if(nativeSession && !Features.Contains("nativeSessionLogin", StringComparer.Ordinal))
{ problems.Add("the nativeSessionLogin feature is not advertised, so browser sign-in cannot be used"); }
}
Extension printing = Extensions.Single(extension => extension is not null && string.Equals(extension.Name, "printing", StringComparison.Ordinal));
if(!(SupportedVersion(printing.Version, 0) || SupportedVersion(printing.Version, 0, "2")) || !string.Equals(printing.PathPrefix, "/qbit/printing", StringComparison.Ordinal)
|| (nativeSession && !Features.Contains("nativeSessionLogin", StringComparer.Ordinal)))
if(ClientChannels is null || !ClientChannels.Contains("api", StringComparer.Ordinal))
{ problems.Add("the 'api' client channel is not offered"); }
int printingCount = Extensions?.Count(extension => extension is not null && string.Equals(extension.Name, "printing", StringComparison.Ordinal)) ?? 0;
if(printingCount != 1)
{ problems.Add(printingCount == 0 ? "no printing extension is advertised (is the printing QBit installed and enabled?)" : $"{printingCount} printing extensions are advertised, expected exactly one"); }
else
{
throw Configuration();
Extension printing = Extensions!.Single(extension => extension is not null && string.Equals(extension.Name, "printing", StringComparison.Ordinal));
if(!(SupportedVersion(printing.Version, 0) || SupportedVersion(printing.Version, 0, "2")))
{ problems.Add($"printing extension version '{printing.Version}' is not a plain 1.x or 2.x release"); }
if(!string.Equals(printing.PathPrefix, "/qbit/printing", StringComparison.Ordinal))
{ problems.Add($"printing extension path '{printing.PathPrefix}' is not /qbit/printing"); }
}
return problems;
}
@@ -28,25 +28,11 @@ public sealed partial class QqqSessionClient
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
_diagnostics.Info($"Browser sign-in requested with callback {redirectUri.AbsoluteUri}.");
QqqAuthenticationMetadata metadata = await AuthenticationCoreAsync(cancellationToken).ConfigureAwait(false);
QqqAuthenticationMetadata.NativeGrant grant = ValidateNativeGrant(metadata);
if(!ValidRedirect(redirectUri) || !grant.RedirectUris.Contains(redirectUri.AbsoluteUri, StringComparer.Ordinal))
{ throw QqqApiDiscovery.Configuration(); }
using HttpResponseMessage response = await SendCoreAsync(new Uri(grant.DiscoveryUrl), HttpMethod.Get, null, MetadataLimit,
null, null, null, cancellationToken, providerDiscovery: true).ConfigureAwait(false);
RequireSuccess(response);
using JsonDocument document = ParseDocument(response);
JsonElement provider = document.RootElement;
if(!provider.TryGetProperty("issuer", out JsonElement issuer) || issuer.ValueKind != JsonValueKind.String
|| !string.Equals(issuer.GetString(), grant.Issuer, StringComparison.Ordinal)
|| !provider.TryGetProperty("authorization_endpoint", out JsonElement authorization) || authorization.ValueKind != JsonValueKind.String
|| !TryHttps(authorization.GetString(), out Uri? authorizationUri)
|| !SameOrigin(authorizationUri!, new Uri(grant.Issuer))
|| !provider.TryGetProperty("code_challenge_methods_supported", out JsonElement methods) || methods.ValueKind != JsonValueKind.Array
|| !methods.EnumerateArray().Any(value => value.ValueKind == JsonValueKind.String && string.Equals(value.GetString(), "S256", StringComparison.Ordinal)))
{
throw QqqApiDiscovery.Configuration();
}
RequireListedRedirect(grant, redirectUri);
Uri authorizationUri = await ProviderAuthorizationEndpointAsync(grant, cancellationToken).ConfigureAwait(false);
string state = Base64Url(RandomNumberGenerator.GetBytes(32));
string verifier = Base64Url(RandomNumberGenerator.GetBytes(32));
var query = new Dictionary<string, string>(StringComparer.Ordinal)
@@ -61,19 +47,77 @@ public sealed partial class QqqSessionClient
["code_challenge_method"] = "S256",
["code_challenge"] = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))),
};
var builder = new UriBuilder(authorizationUri!)
var builder = new UriBuilder(authorizationUri)
{
Query = string.Join("&", query.Select(pair => Uri.EscapeDataString(pair.Key) + "=" + Uri.EscapeDataString(pair.Value))),
};
ClearSession();
_browserLogin = new QqqBrowserLogin(builder.Uri, redirectUri, state, verifier, grant.Issuer, _timeProvider.GetTimestamp());
_diagnostics.Info($"Browser sign-in attempt started: the authorization page is {QqqSessionDiagnostics.Describe(builder.Uri)} (query not logged); waiting for the callback, which expires after five minutes.");
return _browserLogin;
}
catch(Exception exception) when(ReportFailure("Browser sign-in start", exception))
{ throw; }
finally { _gate.Release(); }
}
/// <summary>Refuses a callback address that is not an app link or private scheme, or that the server's native grant does not list exactly, saying which in the log.</summary>
private void RequireListedRedirect(QqqAuthenticationMetadata.NativeGrant grant, Uri redirectUri)
{
if(!ValidRedirect(redirectUri))
{
_diagnostics.Info($"Browser sign-in refused locally: the callback {redirectUri.AbsoluteUri} is not an HTTPS app link or dotted private scheme.");
throw QqqApiDiscovery.Configuration();
}
if(!grant.RedirectUris.Contains(redirectUri.AbsoluteUri, StringComparer.Ordinal))
{
_diagnostics.Info($"Browser sign-in refused: the server's native grant does not list the callback {redirectUri.AbsoluteUri}; it lists [{string.Join(", ", grant.RedirectUris)}]. Add this callback to the QQQ native session grant.");
throw QqqApiDiscovery.Configuration();
}
}
/// <summary>
/// Fetches the identity provider's discovery document and returns its authorization endpoint, after checking that the
/// provider names the same issuer QQQ advertises, an HTTPS endpoint on that issuer's origin, and S256 support. Each
/// shortfall is logged before the generic configuration failure is thrown.
/// </summary>
private async Task<Uri> ProviderAuthorizationEndpointAsync(QqqAuthenticationMetadata.NativeGrant grant, CancellationToken cancellationToken)
{
_diagnostics.Info($"Fetching the identity provider's discovery document from {QqqSessionDiagnostics.Describe(new Uri(grant.DiscoveryUrl))}.");
using HttpResponseMessage response = await SendCoreAsync(new Uri(grant.DiscoveryUrl), HttpMethod.Get, null, MetadataLimit,
null, null, null, cancellationToken, providerDiscovery: true).ConfigureAwait(false);
RequireSuccess(response);
using JsonDocument document = ParseDocument(response);
JsonElement provider = document.RootElement;
string? providerIssuer = provider.TryGetProperty("issuer", out JsonElement issuer) && issuer.ValueKind == JsonValueKind.String ? issuer.GetString() : null;
string? providerAuthorization = provider.TryGetProperty("authorization_endpoint", out JsonElement authorization) && authorization.ValueKind == JsonValueKind.String ? authorization.GetString() : null;
bool s256 = provider.TryGetProperty("code_challenge_methods_supported", out JsonElement methods) && methods.ValueKind == JsonValueKind.Array
&& methods.EnumerateArray().Any(value => value.ValueKind == JsonValueKind.String && string.Equals(value.GetString(), "S256", StringComparison.Ordinal));
_diagnostics.Info($"Provider discovery: issuer {providerIssuer ?? "(missing)"}, authorization endpoint {providerAuthorization ?? "(missing)"}, S256 {(s256 ? "supported" : "not listed")}.");
if(!string.Equals(providerIssuer, grant.Issuer, StringComparison.Ordinal))
{
_diagnostics.Info($"Browser sign-in refused: the provider's issuer '{providerIssuer}' does not exactly match the issuer QQQ advertises '{grant.Issuer}'.");
throw QqqApiDiscovery.Configuration();
}
if(!TryHttps(providerAuthorization, out Uri? authorizationUri) || !SameOrigin(authorizationUri!, new Uri(grant.Issuer)))
{
_diagnostics.Info("Browser sign-in refused: the provider's authorization endpoint is not an HTTPS address on the issuer's origin.");
throw QqqApiDiscovery.Configuration();
}
if(!s256)
{
_diagnostics.Info("Browser sign-in refused: the provider does not list S256 among its supported code challenge methods.");
throw QqqApiDiscovery.Configuration();
}
return authorizationUri!;
}
/// <summary>
/// Finishes a browser sign-in from the provider's redirect. The attempt is consumed whatever happens, so a callback can be
/// used once; it must be the pending attempt, less than five minutes old, with no fragment or credentials and exactly the
@@ -90,26 +134,55 @@ public sealed partial class QqqSessionClient
QqqBrowserLogin? pending = _browserLogin;
_browserLogin = null;
TimeSpan age = _timeProvider.GetElapsedTime(attempt.StartedAt);
if(!ReferenceEquals(pending, attempt)
|| age < TimeSpan.Zero || age > TimeSpan.FromMinutes(5)
|| !callback.IsAbsoluteUri || callback.Fragment.Length != 0 || callback.UserInfo.Length != 0
_diagnostics.Info($"Browser callback received at {QqqSessionDiagnostics.Describe(callback)} after {age.TotalSeconds:0} s.");
if(!ReferenceEquals(pending, attempt))
{
_diagnostics.Info("Browser callback refused: it does not belong to the pending sign-in attempt (a new attempt was started, or the attempt was already used).");
throw InvalidResponse();
}
if(age < TimeSpan.Zero || age > TimeSpan.FromMinutes(5))
{
_diagnostics.Info("Browser callback refused: the attempt is older than five minutes. Start the browser sign-in again.");
throw InvalidResponse();
}
if(!callback.IsAbsoluteUri || callback.Fragment.Length != 0 || callback.UserInfo.Length != 0
|| !string.Equals(callback.GetLeftPart(UriPartial.Path), attempt.RedirectUri.AbsoluteUri, StringComparison.Ordinal))
{
_diagnostics.Info($"Browser callback refused: its address does not match the registered callback {attempt.RedirectUri.AbsoluteUri} exactly, or it carries a fragment or user information.");
throw InvalidResponse();
}
Dictionary<string, string> values = CallbackValues(callback);
if(values.ContainsKey("error") || !values.TryGetValue("state", out string? state) || !string.Equals(state, attempt.State, StringComparison.Ordinal)
|| !values.TryGetValue("code", out string? code) || string.IsNullOrWhiteSpace(code) || code.Length > 4096
|| (values.TryGetValue("iss", out string? issuer) && !string.Equals(issuer, attempt.Issuer, StringComparison.Ordinal)))
_diagnostics.Debug(() => $"Browser callback parameters: [{string.Join(", ", values.Keys)}] (values not logged).");
if(values.TryGetValue("error", out string? providerError))
{
_diagnostics.Info($"Browser sign-in refused by the identity provider: error '{providerError}'{(values.TryGetValue("error_description", out string? description) ? " (" + description + ")" : "")}.");
throw InvalidResponse();
}
if(!values.TryGetValue("state", out string? state) || !string.Equals(state, attempt.State, StringComparison.Ordinal))
{
_diagnostics.Info("Browser callback refused: the state parameter is missing or does not match this attempt.");
throw InvalidResponse();
}
if(!values.TryGetValue("code", out string? code) || string.IsNullOrWhiteSpace(code) || code.Length > 4096)
{
_diagnostics.Info("Browser callback refused: no usable authorization code was returned.");
throw InvalidResponse();
}
if(values.TryGetValue("iss", out string? issuer) && !string.Equals(issuer, attempt.Issuer, StringComparison.Ordinal))
{
_diagnostics.Info($"Browser callback refused: the issuer '{issuer}' differs from the expected '{attempt.Issuer}'.");
throw InvalidResponse();
}
_diagnostics.Info("Browser callback validated; exchanging the authorization code with QQQ for a server-managed session.");
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, "/qqq/v1/manageSession"), HttpMethod.Post,
JsonBody(new { code, codeVerifier = attempt.Verifier, redirectUri = attempt.RedirectUri.AbsoluteUri, authenticationFlow = QqqAuthenticationMetadata.NativeSessionFlow }),
MetadataLimit, null, null, null, cancellationToken).ConfigureAwait(false);
RequireSuccess(response);
AdoptManagedSession(response);
_diagnostics.Info("Browser sign-in accepted; a server-managed session is now held.");
}
catch(Exception exception) when(ReportFailure("Browser sign-in completion", exception))
{ throw; }
finally { _gate.Release(); }
}
@@ -122,19 +195,46 @@ public sealed partial class QqqSessionClient
/// </summary>
private QqqAuthenticationMetadata.NativeGrant ValidateNativeGrant(QqqAuthenticationMetadata metadata)
{
_discovery!.Validate(nativeSession: true);
_discovery!.Validate(nativeSession: true, report: _diagnostics.Info);
QqqAuthenticationMetadata.NativeGrant? grant = metadata.Values?.NativeSessionLogin;
if(!string.Equals(Origin.Scheme, "https", StringComparison.Ordinal) || !string.Equals(metadata.Type, "OAUTH2", StringComparison.Ordinal)
|| grant is null || !string.Equals(grant.Flow, QqqAuthenticationMetadata.NativeSessionFlow, StringComparison.Ordinal)
|| !string.Equals(grant.CodeChallengeMethod, "S256", StringComparison.Ordinal) || !string.Equals(grant.Prompt, "login", StringComparison.Ordinal)
|| !TryHttps(grant.Issuer, out _) || !TryHttps(grant.DiscoveryUrl, out _)
|| string.IsNullOrWhiteSpace(metadata.Values!.ClientId) || metadata.Values.ClientId.Length > 1024
|| string.IsNullOrWhiteSpace(metadata.Values.Scopes) || !metadata.Values.Scopes.Split(' ', StringSplitOptions.RemoveEmptyEntries).Contains("openid", StringComparer.Ordinal)
|| grant.RedirectUris is null || grant.RedirectUris.Count is < 1 or > 32
|| grant.RedirectUris.Any(value => !Uri.TryCreate(value, UriKind.Absolute, out Uri? uri) || !ValidRedirect(uri)))
var problems = new List<string>();
if(!string.Equals(Origin.Scheme, "https", StringComparison.Ordinal))
{ problems.Add("the server origin is not HTTPS"); }
if(!string.Equals(metadata.Type, "OAUTH2", StringComparison.Ordinal))
{ problems.Add($"authentication type is '{metadata.Type}', not OAUTH2"); }
if(grant is null)
{ problems.Add("no nativeSessionLogin grant is published"); }
else
{
if(!string.Equals(grant.Flow, QqqAuthenticationMetadata.NativeSessionFlow, StringComparison.Ordinal))
{ problems.Add($"grant flow is '{grant.Flow}', expected {QqqAuthenticationMetadata.NativeSessionFlow}"); }
if(!string.Equals(grant.CodeChallengeMethod, "S256", StringComparison.Ordinal))
{ problems.Add($"code challenge method is '{grant.CodeChallengeMethod}', expected S256"); }
if(!string.Equals(grant.Prompt, "login", StringComparison.Ordinal))
{ problems.Add($"prompt is '{grant.Prompt}', expected login"); }
if(!TryHttps(grant.Issuer, out _))
{ problems.Add($"issuer '{grant.Issuer}' is not a plain HTTPS address"); }
if(!TryHttps(grant.DiscoveryUrl, out _))
{ problems.Add($"discovery address '{grant.DiscoveryUrl}' is not a plain HTTPS address"); }
if(grant.RedirectUris is null || grant.RedirectUris.Count is < 1 or > 32)
{ problems.Add($"{grant.RedirectUris?.Count ?? 0} redirect address(es) listed; expected 1 to 32"); }
else
{
string[] invalid = [.. grant.RedirectUris.Where(value => !Uri.TryCreate(value, UriKind.Absolute, out Uri? uri) || !ValidRedirect(uri))];
if(invalid.Length != 0)
{ problems.Add("redirect address(es) not an HTTPS app link or dotted private scheme: " + string.Join(", ", invalid)); }
}
}
if(string.IsNullOrWhiteSpace(metadata.Values?.ClientId) || metadata.Values.ClientId.Length > 1024)
{ problems.Add("client id is missing or too long"); }
if(string.IsNullOrWhiteSpace(metadata.Values?.Scopes) || !metadata.Values.Scopes.Split(' ', StringSplitOptions.RemoveEmptyEntries).Contains("openid", StringComparer.Ordinal))
{ problems.Add($"scopes '{metadata.Values?.Scopes}' do not include openid"); }
if(problems.Count != 0)
{
_diagnostics.Info("Native browser sign-in grant rejected: " + string.Join("; ", problems) + ".");
throw QqqApiDiscovery.Configuration();
}
_diagnostics.Info($"Native browser sign-in grant accepted: issuer {grant!.Issuer}, {grant.RedirectUris!.Count} redirect address(es).");
return grant;
}
@@ -16,7 +16,7 @@ public sealed partial class QqqSessionClient
/// <summary>Refreshes standard discovery once per socket establishment, then connects only to that origin with the current session and approved device proof.</summary>
public async Task<WebSocket> ConnectPrintingAsync(TimeSpan heartbeatInterval, TimeSpan heartbeatTimeout, CancellationToken cancellationToken = default)
{
var invoker = new HttpMessageInvoker(new SocketsHttpHandler { AllowAutoRedirect = false, UseCookies = false });
HttpMessageInvoker invoker = CreateSocketInvoker();
var socket = new ClientWebSocket();
long generation;
try
@@ -25,46 +25,49 @@ public sealed partial class QqqSessionClient
try
{
RequireAuthenticated();
_diagnostics.Info("Preparing the realtime printing socket: refreshing discovery first.");
await RefreshPrintingDiscoveryAsync(cancellationToken).ConfigureAwait(false);
if(_device is null || _binding?.HasApprovedDevice != true)
{
_diagnostics.Info("Realtime socket refused locally: no approved device binding is held. Enroll or re-associate this computer first.");
throw new QqqSessionException(QqqSessionException.FailureKind.PermissionDenied, "An approved device binding is required.");
}
generation = _generation;
socket.Options.AddSubProtocol("qqq-printing.v1");
socket.Options.CollectHttpResponseDetails = true;
socket.Options.SetRequestHeader("Cookie", CookieHeader());
socket.Options.SetRequestHeader("Origin", Origin.GetLeftPart(UriPartial.Authority));
socket.Options.SetRequestHeader("X-QQQ-Client", ClientChannel);
socket.Options.SetRequestHeader("X-QQQ-Device-Id", _device.DeviceId);
socket.Options.SetRequestHeader("X-QQQ-Device-Token", _device.DeviceToken);
socket.Options.KeepAliveInterval = heartbeatInterval;
socket.Options.KeepAliveTimeout = heartbeatTimeout;
ConfigureSocket(socket, _device, heartbeatInterval, heartbeatTimeout);
}
finally { _gate.Release(); }
var uri = new UriBuilder(new Uri(Origin, PrintingPath + "device/realtime")) { Scheme = string.Equals(Origin.Scheme, "https", StringComparison.Ordinal) ? "wss" : "ws" };
_diagnostics.Info($"Opening the realtime socket to {QqqSessionDiagnostics.Describe(uri.Uri)} with subprotocol qqq-printing.v1 (session cookie and device proof attached; heartbeat every {heartbeatInterval.TotalSeconds:0} s, timeout {heartbeatTimeout.TotalSeconds:0} s).");
long started = System.Diagnostics.Stopwatch.GetTimestamp();
await socket.ConnectAsync(uri.Uri, invoker, cancellationToken).ConfigureAwait(false);
_diagnostics.Info($"Realtime socket upgraded in {Elapsed(started)}; server selected subprotocol {socket.SubProtocol ?? "(none)"}.");
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if(generation != _generation)
{ throw AuthenticationRequired(); }
{
_diagnostics.Info("Realtime socket discarded: the session changed while it was connecting.");
throw AuthenticationRequired();
}
if(!string.Equals(socket.SubProtocol, "qqq-printing.v1", StringComparison.Ordinal))
{
_diagnostics.Info("Realtime socket refused: the server did not select the qqq-printing.v1 subprotocol.");
throw InvalidResponse();
}
}
finally { _gate.Release(); }
return new OwnedPrintingSocket(socket, invoker);
}
catch(WebSocketException) when(socket.HttpStatusCode is System.Net.HttpStatusCode.Unauthorized or System.Net.HttpStatusCode.Forbidden)
catch(WebSocketException exception) when(socket.HttpStatusCode is System.Net.HttpStatusCode.Unauthorized or System.Net.HttpStatusCode.Forbidden)
{
_diagnostics.Info($"Realtime socket upgrade refused with HTTP {(int)socket.HttpStatusCode} ({exception.WebSocketErrorCode}); authorization is required.");
socket.Dispose();
invoker.Dispose();
throw new QqqSessionException(QqqSessionException.FailureKind.AuthenticationRequired, "QQQ authorization is required to restore the realtime connection.");
}
catch
catch(Exception exception)
{
ReportSocketFailure(exception, socket);
socket.Dispose();
invoker.Dispose();
throw;
@@ -73,6 +76,57 @@ public sealed partial class QqqSessionClient
/// <summary>
/// The HTTP invoker for the WebSocket upgrade: no redirects, no cookie jar (the cookie is attached by hand) and the same
/// certificate policy as the HTTP client, so a rejected or explicitly allowed certificate is logged the same way. See the
/// MA0039 note on the constructor.
/// </summary>
private HttpMessageInvoker CreateSocketInvoker()
{
#pragma warning disable MA0039
return new HttpMessageInvoker(new SocketsHttpHandler
{
AllowAutoRedirect = false,
UseCookies = false,
SslOptions = new System.Net.Security.SslClientAuthenticationOptions
{
RemoteCertificateValidationCallback = (sender, certificate, chain, errors) => EvaluateCertificate(Origin.Authority,
certificate is null ? null : certificate as System.Security.Cryptography.X509Certificates.X509Certificate2 ?? new System.Security.Cryptography.X509Certificates.X509Certificate2(certificate), errors),
},
});
#pragma warning restore MA0039
}
/// <summary>Sets the subprotocol, session cookie, origin, client channel, device proof and keep-alive on a socket before it connects; the caller holds the gate.</summary>
private void ConfigureSocket(ClientWebSocket socket, QqqDeviceCredential device, TimeSpan heartbeatInterval, TimeSpan heartbeatTimeout)
{
socket.Options.AddSubProtocol("qqq-printing.v1");
socket.Options.CollectHttpResponseDetails = true;
socket.Options.SetRequestHeader("Cookie", CookieHeader());
socket.Options.SetRequestHeader("Origin", Origin.GetLeftPart(UriPartial.Authority));
socket.Options.SetRequestHeader("X-QQQ-Client", ClientChannel);
socket.Options.SetRequestHeader("X-QQQ-Device-Id", device.DeviceId);
socket.Options.SetRequestHeader("X-QQQ-Device-Token", device.DeviceToken);
socket.Options.KeepAliveInterval = heartbeatInterval;
socket.Options.KeepAliveTimeout = heartbeatTimeout;
}
/// <summary>Logs why the socket could not be established: the WebSocket error and any HTTP status from the upgrade, a cancellation, or the exception type and message.</summary>
private void ReportSocketFailure(Exception exception, ClientWebSocket socket)
{
int status = (int)socket.HttpStatusCode;
_diagnostics.Info(exception is WebSocketException webSocket
? $"Realtime socket upgrade failed: {webSocket.WebSocketErrorCode}{(status == 0 ? "" : ", HTTP " + status.ToString(System.Globalization.CultureInfo.InvariantCulture))}: {webSocket.Message}"
: exception is OperationCanceledException ? "Realtime socket connection was cancelled or timed out."
: "Realtime socket connection failed: " + QqqSessionDiagnostics.Describe(exception));
}
/// <summary>Refreshes discovery under the caller-held session gate and rejects incompatible printing majors before sending any WebSocket credentials.</summary>
private async Task RefreshPrintingDiscoveryAsync(CancellationToken token)
{
@@ -81,7 +135,9 @@ public sealed partial class QqqSessionClient
QqqApiDiscovery.Extension extension = discovery.Extensions.Single(item => string.Equals(item.Name, "printing", StringComparison.Ordinal));
if(!Version.TryParse(extension.Version, out Version? version) || version.Major != 2)
{
_diagnostics.Info($"Realtime printing needs printing extension 2.x; the server advertises {extension.Version}.");
throw new QqqSessionException(QqqSessionException.FailureKind.ConfigurationRequired, "Upgrade the server to printing extension 2.x for realtime printing.");
}
_diagnostics.Info($"Printing extension {extension.Version} supports realtime; media negotiation {(discovery.SupportsPrinterMedia() ? "available" : "not available (needs 2.3+)")}.");
}
}
@@ -5,6 +5,8 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using Qqq.PrintAgent.Core.Transport;
@@ -22,6 +24,7 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
private readonly HttpClient _http;
private readonly TimeProvider _timeProvider;
private readonly ApplicationTrafficMeter? _traffic;
private readonly QqqSessionDiagnostics _diagnostics;
private readonly SemaphoreSlim _gate = new(1, 1);
private CookieContainer _cookies = new();
private QqqApiDiscovery? _discovery;
@@ -40,9 +43,12 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
/// manages cookies, because redirects are refused and the session cookie is attached by hand to each request, and the
/// HTTP client has no overall timeout because every request sets its own bound.
/// </summary>
public QqqSessionClient(Uri origin, HttpMessageHandler? handler = null, TimeProvider? timeProvider = null, bool allowLoopbackHttp = false, ApplicationTrafficMeter? traffic = null)
public QqqSessionClient(Uri origin, HttpMessageHandler? handler = null, TimeProvider? timeProvider = null, bool allowLoopbackHttp = false, ApplicationTrafficMeter? traffic = null,
QqqSessionDiagnostics? diagnostics = null, bool allowUntrustedCertificate = false)
{
ArgumentNullException.ThrowIfNull(origin);
_diagnostics = diagnostics ?? QqqSessionDiagnostics.None;
AllowsUntrustedCertificate = allowUntrustedCertificate;
if(!origin.IsAbsoluteUri || origin.UserInfo.Length != 0 || origin.Query.Length != 0 || origin.Fragment.Length != 0
|| !string.Equals(origin.AbsolutePath, "/", StringComparison.Ordinal)
|| (!string.Equals(origin.Scheme, "https", StringComparison.Ordinal)
@@ -53,10 +59,23 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
Origin = new Uri(origin.GetLeftPart(UriPartial.Authority) + "/", UriKind.Absolute);
_timeProvider = timeProvider ?? TimeProvider.System;
_traffic = traffic;
_http = new HttpClient(handler ?? new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false })
// MA0039: the callback keeps the platform's verdict (it accepts only SslPolicyErrors.None) unless the administrator
// explicitly allowed untrusted certificates for a development server; it exists so a rejection is logged with its
// reason instead of failing silently, and so the opt-in is visible in the log on every connection.
#pragma warning disable MA0039
_http = new HttpClient(handler ?? new HttpClientHandler
{
AllowAutoRedirect = false,
UseCookies = false,
ServerCertificateCustomValidationCallback = (request, certificate, chain, errors) => EvaluateCertificate(request.RequestUri?.Authority ?? Origin.Authority, certificate, errors),
})
{
Timeout = Timeout.InfiniteTimeSpan,
};
#pragma warning restore MA0039
_diagnostics.Info($"Session client created for {Origin.Scheme}://{Origin.Authority} (plain HTTP {(string.Equals(Origin.Scheme, "http", StringComparison.Ordinal) ? "allowed for loopback" : "not allowed")}; untrusted server certificates {(allowUntrustedCertificate ? "ALLOWED" : "refused")}).");
if(allowUntrustedCertificate)
{ _diagnostics.Info($"WARNING: certificate validation is relaxed for {Origin.Authority}. Any certificate, including a forged one, will be accepted. Use this only for development servers."); }
}
@@ -64,6 +83,40 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
public Uri Origin { get; }
public bool IsAuthenticated => _sessionToken is not null;
/// <summary>True when this client was told to accept a server certificate that Windows does not trust (self-signed or private authority), for development servers only.</summary>
public bool AllowsUntrustedCertificate { get; }
/// <summary>
/// Decides whether a TLS server certificate is accepted and, either way, says why in the log. A certificate Windows trusts
/// is accepted silently (details at debug level). One it does not trust is rejected with the exact policy errors, the
/// subject, issuer and validity, and the remedy, unless untrusted certificates were explicitly allowed for this client,
/// in which case it is accepted with a warning naming the certificate so the exception is never silent.
/// </summary>
internal bool EvaluateCertificate(string host, X509Certificate2? certificate, SslPolicyErrors errors)
{
string summary = certificate is null ? "no certificate presented"
: $"subject '{certificate.Subject}', issuer '{certificate.Issuer}', valid {certificate.NotBefore.ToUniversalTime():yyyy-MM-dd} to {certificate.NotAfter.ToUniversalTime():yyyy-MM-dd}, thumbprint {certificate.Thumbprint}";
if(errors == SslPolicyErrors.None)
{
_diagnostics.Debug(() => $"TLS certificate for {host} accepted by Windows: {summary}.");
return true;
}
if(AllowsUntrustedCertificate)
{
_diagnostics.Info($"TLS certificate for {host} is NOT trusted by Windows ({errors}) but was accepted because untrusted certificates are allowed for this server: {summary}.");
return true;
}
_diagnostics.Info($"TLS certificate for {host} rejected: {errors}. Certificate: {summary}. "
+ (errors.HasFlag(SslPolicyErrors.RemoteCertificateChainErrors)
? "Windows does not trust the issuing authority for the account this process runs as (the service runs as LocalSystem and reads the machine store, not your user store). Install the issuer's root certificate in the Local Machine Trusted Root store, or enable 'Allow untrusted server certificate' in Connection settings for a development server."
: errors.HasFlag(SslPolicyErrors.RemoteCertificateNameMismatch)
? "The certificate is not issued for this host name or address; use the address the certificate names, or reissue it."
: "The server presented no usable certificate."));
return false;
}
/// <summary>Returns the validated discovery document, fetching it when none is cached; the client gate makes concurrent callers share one fetch.</summary>
@@ -108,16 +161,25 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
_diagnostics.Info("Password sign-in requested.");
_diagnostics.Debug(() => $"Password sign-in user name: '{username}' ({username.Length} characters); password {password.Length} characters (never logged).");
QqqAuthenticationMetadata authentication = await AuthenticationCoreAsync(cancellationToken).ConfigureAwait(false);
if(!string.Equals(authentication.Type, "TABLE_BASED", StringComparison.Ordinal))
{ throw QqqApiDiscovery.Configuration(); }
{
_diagnostics.Info($"Password sign-in refused locally: the server authenticates with {authentication.Type}, not TABLE_BASED.");
throw QqqApiDiscovery.Configuration();
}
ClearSession();
string basic = Convert.ToBase64String(Encoding.UTF8.GetBytes(username + ":" + password));
_diagnostics.Info("Sending password sign-in to /qqq/v1/manageSession (HTTP Basic, this request only).");
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, "/qqq/v1/manageSession"), HttpMethod.Post, JsonBody(new { }),
MetadataLimit, null, null, basic, cancellationToken).ConfigureAwait(false);
RequireSuccess(response);
AdoptManagedSession(response);
_diagnostics.Info("Password sign-in accepted; a server-managed session is now held.");
}
catch(Exception exception) when(ReportFailure("Password sign-in", exception))
{ throw; }
finally { _gate.Release(); }
}
@@ -131,17 +193,24 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
{
ArgumentNullException.ThrowIfNull(credential);
if(credential.Origin != Origin)
{ throw new ArgumentException("The session belongs to another server.", nameof(credential)); }
{
_diagnostics.Info($"Saved session refused: it belongs to {QqqSessionDiagnostics.Describe(credential.Origin)}, not {QqqSessionDiagnostics.Describe(Origin)}.");
throw new ArgumentException("The session belongs to another server.", nameof(credential));
}
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
_diagnostics.Info($"Restoring a saved session for {Origin.Authority}: checking discovery and authentication metadata first.");
await AuthenticationCoreAsync(cancellationToken).ConfigureAwait(false);
ClearSession();
SetSession(credential.SessionToken);
_diagnostics.Info("Asking the server to confirm the saved session.");
await ResumeCoreAsync(cancellationToken).ConfigureAwait(false);
_diagnostics.Info("Saved session confirmed by the server.");
}
catch
catch(Exception exception)
{
ReportFailure("Session restore", exception);
ClearSession();
throw;
}
@@ -155,7 +224,12 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{ await ResumeCoreAsync(cancellationToken).ConfigureAwait(false); }
{
_diagnostics.Info("Renewing the session with the server.");
await ResumeCoreAsync(cancellationToken).ConfigureAwait(false);
}
catch(Exception exception) when(ReportFailure("Session renewal", exception))
{ throw; }
finally { _gate.Release(); }
}
@@ -169,11 +243,17 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
{
if(_sessionToken is not null)
{
_diagnostics.Info($"Signing out of {Origin.Authority}: ending the server session.");
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, "/qqq/v1/logout"), HttpMethod.Post, JsonBody(new { }),
MetadataLimit, CookieHeader(), null, null, cancellationToken).ConfigureAwait(false);
RequireSuccess(response);
_diagnostics.Info("Server session ended.");
}
else
{ _diagnostics.Info("Sign-out requested with no held session; clearing local state only."); }
}
catch(Exception exception) when(ReportFailure("Sign-out", exception))
{ throw; }
finally
{
ClearSession();
@@ -211,20 +291,28 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
try
{
RequireAuthenticated();
_diagnostics.Info($"Binding device {credential.DeviceId} to the session{(claimLegacyDevice ? ", claiming it as a legacy device first" : "")}.");
if(claimLegacyDevice)
{
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, PrintingPath + "device/bind"), HttpMethod.Post, JsonBody(new { }),
MetadataLimit, CookieHeader(), credential, null, cancellationToken).ConfigureAwait(false);
RequireSuccess(response);
_diagnostics.Info($"Server accepted the legacy claim for device {credential.DeviceId}.");
}
QqqSessionBinding identity = await IdentityCoreAsync(credential, cancellationToken).ConfigureAwait(false);
if(!string.Equals(identity.DeviceId, credential.DeviceId, StringComparison.Ordinal))
{ throw InvalidResponse(); }
{
_diagnostics.Info($"Device binding refused: the server reports device {identity.DeviceId ?? "(none)"} for this session, not {credential.DeviceId}.");
throw InvalidResponse();
}
_device = credential;
_binding = identity;
_generation++;
_diagnostics.Info($"Device {credential.DeviceId} bound to principal {identity.PrincipalId} in tenant {identity.TenantId}.");
return identity;
}
catch(Exception exception) when(ReportFailure("Device binding", exception))
{ throw; }
finally { _gate.Release(); }
}
@@ -246,6 +334,7 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
device = relativePath.StartsWith("device/", StringComparison.Ordinal) ? _device : null;
if(relativePath.StartsWith("device/", StringComparison.Ordinal) && (device is null || _binding?.HasApprovedDevice != true))
{
_diagnostics.Info($"Printing request {relativePath} refused locally: no approved device binding is held.");
throw new QqqSessionException(QqqSessionException.FailureKind.PermissionDenied, "An approved device binding is required.");
}
if(!relativePath.StartsWith("device/", StringComparison.Ordinal) && !relativePath.StartsWith("admin/", StringComparison.Ordinal)
@@ -287,11 +376,14 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
{
if(_discovery is not null)
{ return _discovery; }
_diagnostics.Info($"Discovering QQQ at {Origin.Authority}: requesting /qqq/v1/version.");
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, "/qqq/v1/version"), HttpMethod.Get, null, MetadataLimit,
null, null, null, cancellationToken).ConfigureAwait(false);
RequireSuccess(response);
QqqApiDiscovery discovery = Parse<QqqApiDiscovery>(response);
discovery.Validate();
_diagnostics.Info("Discovery received: " + discovery.Describe());
_diagnostics.Debug(() => $"Discovery features: [{string.Join(", ", discovery.Features ?? [])}]; client channels: [{string.Join(", ", discovery.ClientChannels ?? [])}].");
discovery.Validate(report: _diagnostics.Info);
_discovery = discovery;
return discovery;
}
@@ -307,12 +399,19 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
await DiscoverCoreAsync(cancellationToken).ConfigureAwait(false);
if(_authentication is not null)
{ return _authentication; }
_diagnostics.Info("Requesting authentication metadata from /qqq/v1/metaData/authentication.");
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, "/qqq/v1/metaData/authentication"), HttpMethod.Get, null, MetadataLimit,
null, null, null, cancellationToken).ConfigureAwait(false);
RequireSuccess(response);
QqqAuthenticationMetadata metadata = Parse<QqqAuthenticationMetadata>(response);
QqqAuthenticationMetadata.NativeGrant? grant = metadata.Values?.NativeSessionLogin;
_diagnostics.Info($"Authentication metadata: type {(string.IsNullOrEmpty(metadata.Type) ? "(missing)" : metadata.Type)}, provider '{metadata.Name}', native browser sign-in {(grant is null ? "not offered" : "offered")}.");
_diagnostics.Debug(() => grant is null
? $"Provider values present: {metadata.Values is not null}; client id {(string.IsNullOrEmpty(metadata.Values?.ClientId) ? "(none)" : "present")}; scopes '{metadata.Values?.Scopes}'."
: $"Native grant: flow {grant.Flow}, challenge {grant.CodeChallengeMethod}, prompt {grant.Prompt}, issuer {grant.Issuer}, discovery {grant.DiscoveryUrl}, {grant.RedirectUris?.Count ?? 0} redirect address(es) [{string.Join(", ", grant.RedirectUris ?? [])}], scopes '{metadata.Values?.Scopes}'.");
if(!string.Equals(metadata.Type, "TABLE_BASED", StringComparison.Ordinal) && !string.Equals(metadata.Type, "OAUTH2", StringComparison.Ordinal))
{
_diagnostics.Info($"Authentication type '{metadata.Type}' is not supported; only TABLE_BASED and OAUTH2 are.");
throw QqqApiDiscovery.Configuration();
}
if(string.Equals(metadata.Type, "OAUTH2", StringComparison.Ordinal))
@@ -327,12 +426,17 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
private async Task ResumeCoreAsync(CancellationToken cancellationToken)
{
RequireAuthenticated();
string? previous = _sessionToken;
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, "/qqq/v1/manageSession"), HttpMethod.Post, JsonBody(new { }),
MetadataLimit, CookieHeader(), null, null, cancellationToken).ConfigureAwait(false);
RequireSuccess(response);
AdoptManagedSession(response);
_diagnostics.Info($"Session renewed; the session token {(string.Equals(previous, _sessionToken, StringComparison.Ordinal) ? "is unchanged" : "was rotated by the server")}.");
if(_binding is not null)
{ await IdentityCoreAsync(_device, cancellationToken).ConfigureAwait(false); }
{
_diagnostics.Info("Re-checking the bound identity after renewal.");
await IdentityCoreAsync(_device, cancellationToken).ConfigureAwait(false);
}
}
@@ -345,6 +449,7 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
private async Task<QqqSessionBinding> IdentityCoreAsync(QqqDeviceCredential? credential, CancellationToken cancellationToken)
{
RequireAuthenticated();
_diagnostics.Info($"Reading the session identity from the printing session route{(credential is null ? "" : " with device proof for " + credential.DeviceId)}.");
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, PrintingPath + "session"), HttpMethod.Get, null,
MetadataLimit, CookieHeader(), credential, null, cancellationToken).ConfigureAwait(false);
RequireSuccess(response);
@@ -354,10 +459,14 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
string tenant = IdentityValue(root, "tenantId");
string? device = root.TryGetProperty("deviceId", out JsonElement value) && value.ValueKind != JsonValueKind.Null ? IdentityValue(root, "deviceId") : null;
var identity = new QqqSessionBinding(Origin, principal, tenant, device);
_diagnostics.Info($"Session identity: principal {principal}, tenant {tenant}, device {device ?? "(none)"}.");
if((_binding is not null && (!string.Equals(_binding.PrincipalId, principal, StringComparison.Ordinal)
|| !string.Equals(_binding.TenantId, tenant, StringComparison.Ordinal)))
|| (credential is not null && !string.Equals(credential.DeviceId, device, StringComparison.Ordinal)))
{
_diagnostics.Info(_binding is not null && (!string.Equals(_binding.PrincipalId, principal, StringComparison.Ordinal) || !string.Equals(_binding.TenantId, tenant, StringComparison.Ordinal))
? $"Identity changed: the session was bound to principal {_binding.PrincipalId} in tenant {_binding.TenantId}; the server now reports principal {principal} in tenant {tenant}. Session cleared."
: $"Device mismatch: proof was sent for device {credential!.DeviceId} but the server reports device {device ?? "(none)"}. Session cleared.");
ClearSession();
throw new QqqSessionException(QqqSessionException.FailureKind.PermissionDenied, "The server returned a different principal or device binding.");
}
@@ -378,11 +487,57 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
QqqDeviceCredential? device, string? basic, CancellationToken cancellationToken, bool providerDiscovery = false, bool streamContent = false, bool boundedByCaller = false)
{
if(!providerDiscovery && !SameOrigin(uri, Origin))
{ throw new ArgumentException("Cross-origin QQQ requests are forbidden.", nameof(uri)); }
{
_diagnostics.Info($"Refused a request to {QqqSessionDiagnostics.Describe(uri)}: it is not on the session origin {Origin.Authority}.");
throw new ArgumentException("Cross-origin QQQ requests are forbidden.", nameof(uri));
}
string route = QqqSessionDiagnostics.Describe(uri);
long started = System.Diagnostics.Stopwatch.GetTimestamp();
_diagnostics.Debug(() => $"HTTP {method} {route}: session cookie {(cookie is not null ? "yes" : "no")}, device proof {(device is not null ? "yes" : "no")}, basic credential {(basic is not null ? "yes" : "no")}, body {(content is null ? "none" : content.Headers.ContentType?.MediaType ?? "present")}, limit {(boundedByCaller ? "caller" : "20 s")}, response bound {maximumBytes} bytes.");
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
if(!boundedByCaller)
{ timeout.CancelAfter(TimeSpan.FromSeconds(20)); }
using var request = new HttpRequestMessage(method, uri)
using HttpRequestMessage request = BuildRequest(uri, method, content, cookie, device, basic, providerDiscovery);
HttpResponseMessage response;
try
{ response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token).ConfigureAwait(false); }
catch(Exception exception) when(exception is HttpRequestException or OperationCanceledException or IOException)
{
ReportSendFailure(method, route, started, exception, cancellationToken);
throw;
}
try
{
_diagnostics.Debug(() => $"HTTP {method} {route} -> {(int)response.StatusCode} {response.ReasonPhrase} in {Elapsed(started)}; content type {response.Content.Headers.ContentType?.MediaType ?? "(none)"}, length {response.Content.Headers.ContentLength?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "unknown"}, Set-Cookie {(response.Headers.Contains("Set-Cookie") ? "present" : "absent")}.");
if(!providerDiscovery && _traffic is not null)
{ response.Content = new TrafficHttpContent(response.Content, _traffic.RecordDownload); }
if((int)response.StatusCode is >= 300 and < 400)
{
_diagnostics.Info($"HTTP {method} {route} answered {(int)response.StatusCode} (redirect); redirects are refused, so the request failed. Check the server address: it must be the exact HTTPS origin QQQ serves from.");
throw new QqqSessionException(QqqSessionException.FailureKind.TransportRefused, "Redirected authentication or API requests are refused.");
}
if(streamContent && response.IsSuccessStatusCode)
{
response.RequestMessage = null;
return response;
}
await BufferResponseAsync(response, maximumBytes, timeout.Token).ConfigureAwait(false);
response.RequestMessage = null;
return response;
}
catch
{
response.Dispose();
throw;
}
}
/// <summary>Builds the outgoing request: no-store caching, this agent's channel header, and only the cookie, origin, Basic credential and device proof the caller passed.</summary>
private HttpRequestMessage BuildRequest(Uri uri, HttpMethod method, HttpContent? content, string? cookie, QqqDeviceCredential? device, string? basic, bool providerDiscovery)
{
var request = new HttpRequestMessage(method, uri)
{
Content = !providerDiscovery && _traffic is not null && content is not null ? new TrafficHttpContent(content, _traffic.RecordUpload) : content,
};
@@ -404,29 +559,19 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
request.Headers.Add("X-QQQ-Device-Id", device.DeviceId);
request.Headers.Add("X-QQQ-Device-Token", device.DeviceToken);
}
HttpResponseMessage response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token).ConfigureAwait(false);
try
{
if(!providerDiscovery && _traffic is not null)
{ response.Content = new TrafficHttpContent(response.Content, _traffic.RecordDownload); }
if((int)response.StatusCode is >= 300 and < 400)
{
throw new QqqSessionException(QqqSessionException.FailureKind.TransportRefused, "Redirected authentication or API requests are refused.");
}
if(streamContent && response.IsSuccessStatusCode)
{
response.RequestMessage = null;
return response;
}
await BufferResponseAsync(response, maximumBytes, timeout.Token).ConfigureAwait(false);
response.RequestMessage = null;
return response;
}
catch
{
response.Dispose();
throw;
}
return request;
}
/// <summary>Logs a request that produced no response headers: cancelled by the caller, timed out, or a connection failure with its inner cause (DNS, TLS, refused).</summary>
private void ReportSendFailure(HttpMethod method, string route, long started, Exception exception, CancellationToken cancellationToken)
{
_diagnostics.Info(cancellationToken.IsCancellationRequested
? $"HTTP {method} {route} was cancelled by the caller after {Elapsed(started)}."
: exception is OperationCanceledException
? $"HTTP {method} {route} timed out after {Elapsed(started)} with no response headers."
: $"HTTP {method} {route} could not be sent after {Elapsed(started)}: {QqqSessionDiagnostics.Describe(exception)}{(exception.InnerException is null ? "" : " (" + QqqSessionDiagnostics.Describe(exception.InnerException) + ")")}.");
}
@@ -435,10 +580,13 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
/// Reads a response body into memory but never more than the stated limit, whether announced by Content-Length or
/// discovered while reading, so a hostile or broken server cannot exhaust memory. The buffered content keeps the original headers.
/// </summary>
private static async Task BufferResponseAsync(HttpResponseMessage response, int maximumBytes, CancellationToken token)
private async Task BufferResponseAsync(HttpResponseMessage response, int maximumBytes, CancellationToken token)
{
if(response.Content.Headers.ContentLength > maximumBytes)
{ throw InvalidResponse(); }
{
_diagnostics.Info($"Response rejected: announced length {response.Content.Headers.ContentLength} bytes exceeds the {maximumBytes} byte bound.");
throw InvalidResponse();
}
await using Stream input = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false);
using var output = new MemoryStream();
byte[] buffer = new byte[8192];
@@ -446,7 +594,10 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
while((count = await input.ReadAsync(buffer, token).ConfigureAwait(false)) != 0)
{
if(output.Length + count > maximumBytes)
{ throw InvalidResponse(); }
{
_diagnostics.Info($"Response rejected: the body exceeded the {maximumBytes} byte bound while reading.");
throw InvalidResponse();
}
output.Write(buffer, 0, count);
}
var buffered = new ByteArrayContent(output.ToArray());
@@ -469,11 +620,23 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
? value.ValueKind == JsonValueKind.String ? value.GetString() : throw InvalidResponse() : null;
string? cookie = ReadSessionCookie(response);
if(body is not null && cookie is not null && !string.Equals(body, cookie, StringComparison.Ordinal))
{ ClearSession(); throw InvalidResponse(); }
{
_diagnostics.Info("Session response rejected: the session cookie and the body's session id disagree.");
ClearSession();
throw InvalidResponse();
}
if(cookie is null && body is null && _sessionToken is null)
{ _diagnostics.Info("Session response rejected: no session cookie, no session id in the body and no session already held."); }
string token = cookie ?? body ?? _sessionToken ?? throw InvalidResponse();
try
{ SetSession(new QqqSessionCredential(Origin, token).SessionToken); }
catch(ArgumentException) { ClearSession(); throw InvalidResponse(); }
catch(ArgumentException)
{
_diagnostics.Info("Session response rejected: the session id is not well formed.");
ClearSession();
throw InvalidResponse();
}
_diagnostics.Debug(() => $"Session token adopted from {(cookie is not null && body is not null ? "cookie and body (agreeing)" : cookie is not null ? "the Set-Cookie header" : body is not null ? "the response body" : "the existing session")}; {token.Length} characters; session generation {_generation}.");
}
@@ -499,6 +662,7 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
string name = header.Split('=', 2)[0].Trim();
if((name is "sessionUUID" or "sessionId") && parsed.GetAllCookies().Count == 0)
{
_diagnostics.Info($"The server deleted the {name} cookie; the session has ended and sign-in is required.");
ClearSession();
throw AuthenticationRequired();
}
@@ -512,7 +676,11 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
throw InvalidResponse();
}
if(cookie.Expired || string.IsNullOrEmpty(cookie.Value))
{ ClearSession(); throw AuthenticationRequired(); }
{
_diagnostics.Info($"The server returned an {(cookie.Expired ? "expired" : "empty")} {cookie.Name} cookie; the session has ended and sign-in is required.");
ClearSession();
throw AuthenticationRequired();
}
token = cookie.Value;
}
}
@@ -577,9 +745,14 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
private void CheckAuthorization(HttpResponseMessage response)
{
if(response.StatusCode == HttpStatusCode.Unauthorized)
{ ClearSession(); throw AuthenticationRequired(); }
{
_diagnostics.Info("The server answered 401 Unauthorized: the session is no longer accepted. Local session cleared; sign-in is required.");
ClearSession();
throw AuthenticationRequired();
}
if(response.StatusCode == HttpStatusCode.Forbidden)
{
_diagnostics.Info("The server answered 403 Forbidden: the signed-in user or device is not permitted to perform this operation. Signing in again will not help; check permissions and device approval in QQQ.");
throw new QqqSessionException(QqqSessionException.FailureKind.PermissionDenied, "The current QQQ user is not permitted to perform this operation.");
}
}
@@ -592,6 +765,7 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
CheckAuthorization(response);
if(!response.IsSuccessStatusCode)
{
_diagnostics.Info($"The server answered {(int)response.StatusCode} {response.ReasonPhrase}; the request did not succeed.");
throw new QqqSessionException(QqqSessionException.FailureKind.TransportRefused, "The QQQ request did not succeed.");
}
}
@@ -631,12 +805,16 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
/// <summary>Deserializes a response into a type, reporting a missing or malformed body as the generic invalid-response failure.</summary>
private static T Parse<T>(HttpResponseMessage response)
private T Parse<T>(HttpResponseMessage response)
{
using JsonDocument document = ParseDocument(response);
try
{ return document.RootElement.Deserialize<T>(JsonOptions) ?? throw InvalidResponse(); }
catch(JsonException) { throw InvalidResponse(); }
catch(JsonException exception)
{
_diagnostics.Info($"Response rejected: the JSON did not match the expected {typeof(T).Name} shape ({exception.Message}).");
throw InvalidResponse();
}
}
@@ -645,7 +823,7 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
/// Parses a response as a JSON object with a depth limit and no duplicate property names (a common smuggling trick for
/// disagreeing parsers). Anything else is an invalid response; the caller owns the returned document.
/// </summary>
private static JsonDocument ParseDocument(HttpResponseMessage response)
private JsonDocument ParseDocument(HttpResponseMessage response)
{
try
{
@@ -654,13 +832,20 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
try
{
if(document.RootElement.ValueKind != JsonValueKind.Object)
{ throw InvalidResponse(); }
{
_diagnostics.Info($"Response rejected: expected a JSON object but received {document.RootElement.ValueKind} (content type {response.Content.Headers.ContentType?.MediaType ?? "none"}). A sign-in page or proxy error page in place of the API is a common cause.");
throw InvalidResponse();
}
RejectDuplicateProperties(document.RootElement);
return document;
}
catch { document.Dispose(); throw; }
}
catch(JsonException) { throw InvalidResponse(); }
catch(JsonException exception)
{
_diagnostics.Info($"Response rejected: the body is not valid JSON ({exception.Message}; content type {response.Content.Headers.ContentType?.MediaType ?? "none"}). A sign-in page or proxy error page in place of the API is a common cause.");
throw InvalidResponse();
}
}
@@ -688,11 +873,12 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
/// <summary>Reads a required identity string that must be non-blank, at most 1024 characters and free of control characters.</summary>
private static string IdentityValue(JsonElement root, string name)
private string IdentityValue(JsonElement root, string name)
{
if(!root.TryGetProperty(name, out JsonElement value) || value.ValueKind != JsonValueKind.String
|| string.IsNullOrWhiteSpace(value.GetString()) || value.GetString()!.Length > 1024 || value.GetString()!.Any(char.IsControl))
{
_diagnostics.Info($"Identity response rejected: '{name}' is missing, blank, not a string, too long or contains control characters.");
throw InvalidResponse();
}
return value.GetString()!;
@@ -700,6 +886,27 @@ public sealed partial class QqqSessionClient : IQqqSessionClient
/// <summary>
/// Records why an operation failed (type and message only) and returns false, so it can sit in an exception filter and
/// leave the exception, its stack and the catch order untouched. Cancellation requested by the caller is noted, not reported as a failure.
/// </summary>
private bool ReportFailure(string operation, Exception exception)
{
_diagnostics.Info(exception is OperationCanceledException
? operation + " was cancelled."
: exception is QqqSessionException session
? $"{operation} failed ({session.Failure}): {session.Message}"
: $"{operation} failed: {QqqSessionDiagnostics.Describe(exception)}");
return false;
}
/// <summary>Milliseconds since a Stopwatch timestamp, for request timing in the log.</summary>
private static string Elapsed(long started) => System.Diagnostics.Stopwatch.GetElapsedTime(started).TotalMilliseconds.ToString("0", System.Globalization.CultureInfo.InvariantCulture) + " ms";
/// <summary>Clears the session, so a late response is refused, and releases the HTTP client and gate.</summary>
public void Dispose()
{
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Core.Authentication;
/// <summary>
/// Credential-free progress reporting for the session client. Info entries describe each step of discovery, sign-in,
/// session restore, identity checks and the realtime upgrade so an operator can see where a new server or account
/// stops working; debug entries add request-level detail (route, status, timing, header names) and are produced only
/// while the host says debug logging is on. Callers never receive tokens, cookies, passwords, codes or device proof.
/// </summary>
public sealed class QqqSessionDiagnostics(Action<string>? info = null, Action<string>? debug = null, Func<bool>? debugEnabled = null)
{
/// <summary>A sink that discards everything, for callers that have no log.</summary>
public static QqqSessionDiagnostics None { get; } = new();
/// <summary>True when a debug sink exists and the host currently wants debug detail.</summary>
public bool DebugEnabled => debug is not null && (debugEnabled?.Invoke() ?? true);
/// <summary>Records one step of normal operation.</summary>
public void Info(string message) => info?.Invoke(message);
/// <summary>Records request-level detail when debug logging is on; the message is built only in that case.</summary>
public void Debug(Func<string> message)
{
if(DebugEnabled)
{ debug!.Invoke(message()); }
}
/// <summary>Describes an address without its query, fragment or user information, so a logged route can never carry a code, state or credential.</summary>
public static string Describe(Uri uri) => uri.IsAbsoluteUri ? uri.Scheme + "://" + uri.Authority + uri.AbsolutePath : uri.ToString();
/// <summary>Describes a failure by type and message, followed by each inner cause the same way, so "see inner exception" never hides the real reason; stack traces belong to the host's error sink.</summary>
public static string Describe(Exception exception)
{
ArgumentNullException.ThrowIfNull(exception);
var parts = new List<string>();
for(Exception? current = exception; current is not null && parts.Count < 4; current = current.InnerException)
{ parts.Add(current.GetType().Name + ": " + current.Message); }
return string.Join(" <- ", parts);
}
/// <summary>The human-readable reason for a failure: the message and each inner cause, without type names, for an on-screen explanation.</summary>
public static string Explain(Exception exception)
{
ArgumentNullException.ThrowIfNull(exception);
var parts = new List<string>();
for(Exception? current = exception; current is not null && parts.Count < 4; current = current.InnerException)
{
string message = current.Message.Trim();
if(message.Length != 0 && !parts.Contains(message, StringComparer.Ordinal))
{ parts.Add(message); }
}
return string.Join(" ", parts);
}
}
@@ -12,9 +12,10 @@ using Qqq.PrintAgent.Core.Printing;
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// Retention, daily size cap and clearing for the shared activity log. The service and the companion write the same files,
/// Retention, size rollover and clearing for the shared activity log. The service and the companion write the same files,
/// so every change here happens under one cross-process lock file, and every path is checked for links so a planted
/// link cannot redirect a write or a delete outside the log directory.
/// link cannot redirect a write or a delete outside the log directory. A file that reaches its size limit is renamed to a
/// numbered segment of the same day and a fresh file starts; logging never stops because of size.
/// </summary>
public sealed partial class AgentActivityLog
{
@@ -23,13 +24,17 @@ public sealed partial class AgentActivityLog
/// <summary>Returns the saved retention and size limits together with today's file size and whether today's cap was reached, for the Settings page.</summary>
/// <summary>Returns the saved retention, size and debug settings together with the current file's size and how many segments rolled over today, for the Settings page.</summary>
public static LogSettings GetSettings(string directory)
{
AgentActivityLog log = new(directory);
using FileStream ownership = log.AcquireOwnership();
LogSettings settings = log.ReadSettings();
return settings with { CurrentDayBytes = File.Exists(log.CurrentLogFilePath) ? new FileInfo(log.CurrentLogFilePath).Length : 0, DailyLimitReached = log.IsCapped(settings) };
return settings with
{
CurrentFileBytes = File.Exists(log.CurrentLogFilePath) ? new FileInfo(log.CurrentLogFilePath).Length : 0,
SegmentsToday = log.Segments(DateOnly.FromDateTime(DateTime.Now)).Count,
};
}
@@ -51,11 +56,13 @@ public sealed partial class AgentActivityLog
{
using(FileStream stream = new(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None))
{
JsonSerializer.Serialize(stream, new LogSettings(settings.RetentionDays, settings.MaxDailyMegabytes), PrintApiClient.JsonOptions);
JsonSerializer.Serialize(stream, new LogSettings(settings.RetentionDays, settings.MaxFileMegabytes, settings.SegmentsPerDay, settings.DebugLogging), PrintApiClient.JsonOptions);
stream.Flush(flushToDisk: true);
}
File.Move(temporary, path, overwrite: true);
log._cachedSettings = null;
log.PruneOwnedLogs(settings.RetentionDays);
log.TrimSegments(DateOnly.FromDateTime(DateTime.Now), settings.SegmentsPerDay);
}
finally
{
@@ -68,7 +75,7 @@ public sealed partial class AgentActivityLog
/// <summary>Deletes only this application's dated log files, under the same cross-process append lock.</summary>
/// <summary>Deletes only this application's dated log files and rolled segments (and any cap marker left by an earlier version), under the same cross-process append lock.</summary>
public static int ClearLogs(string directory)
{
AgentActivityLog log = new(directory);
@@ -79,9 +86,10 @@ public sealed partial class AgentActivityLog
File.Delete(path);
count++;
}
foreach(string path in log.OwnedCapMarkers())
foreach(string path in Directory.EnumerateFiles(log.LogDirectory, "qqq-print-agent-*.log.capped", SearchOption.TopDirectoryOnly))
{
File.Delete(path);
if(!File.GetAttributes(path).HasFlag(FileAttributes.ReparsePoint))
{ File.Delete(path); }
}
return count;
}
@@ -114,13 +122,15 @@ public sealed partial class AgentActivityLog
/// <summary>Accepts only retention of 1 to 365 days and a daily size of 1 to 100 MiB, the ranges the Settings page offers.</summary>
/// <summary>Accepts only retention of 1 to 365 days, a file size of 1 to 1024 MiB and 1 to 100 kept segments per day, the ranges the Settings page offers.</summary>
private static void ValidateSettings(LogSettings settings)
{
ArgumentOutOfRangeException.ThrowIfLessThan(settings.RetentionDays, 1, nameof(settings));
ArgumentOutOfRangeException.ThrowIfGreaterThan(settings.RetentionDays, 365, nameof(settings));
ArgumentOutOfRangeException.ThrowIfLessThan(settings.MaxDailyMegabytes, 1, nameof(settings));
ArgumentOutOfRangeException.ThrowIfGreaterThan(settings.MaxDailyMegabytes, 100, nameof(settings));
ArgumentOutOfRangeException.ThrowIfLessThan(settings.MaxFileMegabytes, 1, nameof(settings));
ArgumentOutOfRangeException.ThrowIfGreaterThan(settings.MaxFileMegabytes, 1024, nameof(settings));
ArgumentOutOfRangeException.ThrowIfLessThan(settings.SegmentsPerDay, 1, nameof(settings));
ArgumentOutOfRangeException.ThrowIfGreaterThan(settings.SegmentsPerDay, 100, nameof(settings));
}
@@ -151,13 +161,12 @@ public sealed partial class AgentActivityLog
/// <summary>Lists only files named like this application's dated logs and that are not links, so clearing or pruning can never touch an unrelated file.</summary>
/// <summary>Lists only files named like this application's dated logs or their numbered segments, and that are not links, so clearing or pruning can never touch an unrelated file.</summary>
private IEnumerable<string> OwnedLogs()
{
foreach(string path in Directory.EnumerateFiles(LogDirectory, "qqq-print-agent-*.log", SearchOption.TopDirectoryOnly))
{
string stamp = Path.GetFileNameWithoutExtension(path)[16..];
if(DateOnly.TryParseExact(stamp, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out _) && !File.GetAttributes(path).HasFlag(FileAttributes.ReparsePoint))
if(TryParseStamp(path, out _, out _) && !File.GetAttributes(path).HasFlag(FileAttributes.ReparsePoint))
{
yield return path;
}
@@ -166,6 +175,44 @@ public sealed partial class AgentActivityLog
/// <summary>
/// Reads the day and segment number from an owned log name: qqq-print-agent-yyyy-MM-dd.log is segment 0 (the live file) and
/// qqq-print-agent-yyyy-MM-dd.N.log (N from 1) is a rolled segment of that day. Anything else is not ours.
/// </summary>
private static bool TryParseStamp(string path, out DateOnly day, out int segment)
{
day = default;
segment = 0;
string name = Path.GetFileNameWithoutExtension(path);
if(name.Length < 26)
{ return false; }
string stamp = name[16..];
string date = stamp.Length > 10 ? stamp[..10] : stamp;
if(!DateOnly.TryParseExact(date, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out day))
{ return false; }
if(stamp.Length == 10)
{ return true; }
return stamp[10] == '.' && int.TryParse(stamp[11..], NumberStyles.None, CultureInfo.InvariantCulture, out segment) && segment > 0
&& stamp[11..].Length <= 9 && stamp[11] != '0';
}
/// <summary>The rolled segments of one day, oldest first.</summary>
private List<(int Segment, string Path)> Segments(DateOnly day)
{
var segments = new List<(int Segment, string Path)>();
foreach(string path in OwnedLogs())
{
if(TryParseStamp(path, out DateOnly fileDay, out int segment) && fileDay == day && segment > 0)
{ segments.Add((segment, path)); }
}
segments.Sort((left, right) => left.Segment.CompareTo(right.Segment));
return segments;
}
/// <summary>
/// Deletes dated logs and cap markers older than the retention window. It runs at most once per local day for an unchanged
/// retention value, so frequent appends do not rescan the folder, and it counts today as one of the retained days.
@@ -180,16 +227,7 @@ public sealed partial class AgentActivityLog
DateOnly cutoff = today.AddDays(-(retentionDays - 1));
foreach(string path in OwnedLogs())
{
var day = DateOnly.ParseExact(Path.GetFileNameWithoutExtension(path)[16..], "yyyy-MM-dd", CultureInfo.InvariantCulture);
if(day < cutoff)
{
File.Delete(path);
}
}
foreach(string path in OwnedCapMarkers())
{
string stamp = Path.GetFileName(path)[16..^11];
if(DateOnly.ParseExact(stamp, "yyyy-MM-dd", CultureInfo.InvariantCulture) < cutoff)
if(TryParseStamp(path, out DateOnly day, out _) && day < cutoff)
{
File.Delete(path);
}
@@ -212,56 +250,49 @@ public sealed partial class AgentActivityLog
/// <summary>
/// Decides whether today's file has room for one more entry. It allows a margin for the prefix AppKit adds, writes one
/// visible warning when the limit is first reached (so the cap is never silent), and leaves a marker so later entries
/// are suppressed cheaply until the next local day, a higher limit or clearing. Printing is never affected.
/// Rolls today's file over when the next entry would push it past the size limit: the live file is renamed to the next
/// numbered segment of the day, the oldest segments beyond the kept count are deleted, and the entry then lands in a
/// fresh file. The caller holds the cross-process lock and the AppKit writer opens the file per append, so the rename is
/// safe; if another reader (such as the log viewer) holds the file, the rename is skipped and retried on the next entry
/// rather than losing the entry. Printing is never affected.
/// </summary>
private bool CanAppend(string message, LogSettings settings)
private void RotateIfNeeded(string message, LogSettings settings)
{
RejectLink(CurrentLogFilePath);
if(IsCapped(settings))
{
return false;
}
long length = File.Exists(CurrentLogFilePath) ? new FileInfo(CurrentLogFilePath).Length : 0;
if(!File.Exists(CurrentLogFilePath))
{ return; }
long length = new FileInfo(CurrentLogFilePath).Length;
// AppKit adds a short ASCII timestamp/severity prefix and a newline.
long limit = settings.MaxDailyMegabytes * 1024L * 1024L;
long limit = settings.MaxFileMegabytes * 1024L * 1024L;
if(length + Encoding.UTF8.GetByteCount(message) + 640 <= limit)
{
return true;
}
if(length + 512 <= limit)
{
_log.Warning("Daily diagnostic limit reached; further entries are suppressed. Increase the limit or clear logs in Settings, or wait for the next local day. Printing continues.");
}
string marker = CurrentLogFilePath + ".capped";
RejectLink(marker);
File.WriteAllText(marker, settings.MaxDailyMegabytes.ToString(CultureInfo.InvariantCulture));
return false;
{ return; }
var today = DateOnly.FromDateTime(DateTime.Now);
List<(int Segment, string Path)> segments = Segments(today);
int next = segments.Count == 0 ? 1 : segments[^1].Segment + 1;
string archive = Path.Combine(LogDirectory, "qqq-print-agent-" + today.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + "." + next.ToString(CultureInfo.InvariantCulture) + ".log");
RejectLink(archive);
try
{ File.Move(CurrentLogFilePath, archive); }
catch(IOException)
{ return; }
TrimSegments(today, settings.SegmentsPerDay);
_log.Info("[Log] Rolled over: the previous " + (length / (1024d * 1024d)).ToString("0.0", CultureInfo.InvariantCulture) + " MiB segment is now "
+ Path.GetFileName(archive) + "; up to " + settings.SegmentsPerDay.ToString(CultureInfo.InvariantCulture) + " segment(s) per day are kept.");
}
/// <summary>True when today's cap marker exists and records the limit now in force; a marker written under a different limit is stale, so raising the limit resumes logging at once.</summary>
private bool IsCapped(LogSettings settings)
/// <summary>Deletes the oldest rolled segments of a day beyond the kept count. A segment another process holds open is skipped and retried on the next rollover.</summary>
private void TrimSegments(DateOnly day, int keep)
{
string marker = CurrentLogFilePath + ".capped";
RejectLink(marker);
return File.Exists(marker) && new FileInfo(marker).Length <= 64
&& string.Equals(File.ReadAllText(marker), settings.MaxDailyMegabytes.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal);
}
/// <summary>Lists only cap markers named like this application's dated logs and that are not links, mirroring the log listing.</summary>
private IEnumerable<string> OwnedCapMarkers()
{
foreach(string path in Directory.EnumerateFiles(LogDirectory, "qqq-print-agent-*.log.capped", SearchOption.TopDirectoryOnly))
List<(int Segment, string Path)> segments = Segments(day);
for(int index = 0; index < segments.Count - keep; index++)
{
string stamp = Path.GetFileName(path)[16..^11];
if(DateOnly.TryParseExact(stamp, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out _) && !File.GetAttributes(path).HasFlag(FileAttributes.ReparsePoint))
try
{ File.Delete(segments[index].Path); }
catch(IOException)
{
yield return path;
// Held open by a reader; it is retried on the next rollover.
}
}
}
+37 -5
View File
@@ -3,6 +3,7 @@
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics;
using System.Text.RegularExpressions;
using KofTwentyTwo.AppKit.Logging;
@@ -13,6 +14,8 @@ namespace Qqq.PrintAgent.Windows;
public sealed partial class AgentActivityLog : IActivityLog
{
private readonly FileActivityLog _log;
private LogSettings? _cachedSettings;
private long _cachedSettingsAt;
@@ -26,7 +29,38 @@ public sealed partial class AgentActivityLog : IActivityLog
/// <summary>Uses the protected service directory; child files inherit its installed access controls.</summary>
public static IActivityLog Create(string directory) => new AgentActivityLog(directory);
public static AgentActivityLog Create(string directory) => new(directory);
/// <summary>
/// True while the saved settings ask for debug detail. The settings file is re-read at most every two seconds, so a toggle
/// in the companion reaches both processes quickly without a file read on every candidate debug line.
/// </summary>
public bool IsDebugEnabled
{
get
{
if(_cachedSettings is null || Stopwatch.GetElapsedTime(_cachedSettingsAt) > TimeSpan.FromSeconds(2))
{
try
{ _cachedSettings = ReadSettings(); }
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
{ _cachedSettings ??= new LogSettings(); }
_cachedSettingsAt = Stopwatch.GetTimestamp();
}
return _cachedSettings.DebugLogging;
}
}
/// <summary>Writes a debug entry (request-level detail) only while debug logging is enabled; the entry goes through the same locked, redacting path.</summary>
public void Debug(string message)
{
if(IsDebugEnabled)
{ Write(_log.Info, "[DEBUG] " + message); }
}
@@ -103,10 +137,8 @@ public sealed partial class AgentActivityLog : IActivityLog
LogSettings settings = ReadSettings();
PruneOwnedLogs(settings.RetentionDays);
string redacted = Redact(message);
if(CanAppend(redacted, settings))
{
append(redacted);
}
RotateIfNeeded(redacted, settings);
append(redacted);
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
{
@@ -22,6 +22,7 @@ public sealed partial class AgentRuntime
private bool _authorizationReady;
private bool _authorizationBlocked;
private string _authenticationMessage = "Sign in to QQQ to enable printing. Existing pairing and history are preserved.";
private string? _lastAuthorizationNote;
internal TimeSpan SessionResumeInterval { get; init; } = TimeSpan.FromMinutes(5);
@@ -30,8 +31,34 @@ public sealed partial class AgentRuntime
/// Creates the session client for a server origin, using the injected factory when one is supplied (tests) and
/// otherwise a real client that permits plain HTTP only for a loopback origin.
/// </summary>
private IQqqSessionClient NewSession(Uri origin) => sessionFactory?.Invoke(origin)
?? new QqqSessionClient(origin, allowLoopbackHttp: origin.IsLoopback, traffic: _traffic);
private IQqqSessionClient NewSession(Uri origin)
{
if(sessionFactory is not null)
{ return sessionFactory(origin); }
ServerTrust trust;
using(var store = new AgentStore(directory, recoverUnreadableConfiguration: true))
{ trust = store.LoadServerTrust(); }
bool allowUntrusted = trust.AppliesTo(origin);
if(allowUntrusted)
{ WriteLog("WARN", $"Creating a session for {origin.Authority} with untrusted server certificates ALLOWED for this server (administrator setting; development only)."); }
else if(trust.AllowUntrustedCertificate)
{ WriteLog("INFO", $"The untrusted-certificate exception is saved for {trust.Origin}, not {ServerTrust.Normalize(origin)}; normal certificate validation applies to this session."); }
return new QqqSessionClient(origin, allowLoopbackHttp: origin.IsLoopback, traffic: _traffic, diagnostics: SessionDiagnostics(), allowUntrustedCertificate: allowUntrusted);
}
/// <summary>Logs an authorization observation only when it differs from the last one, because the reconnect loop re-evaluates authorization on every attempt.</summary>
private void NoteAuthorization(string level, string message)
{
lock(_stateLock)
{
if(string.Equals(_lastAuthorizationNote, message, StringComparison.Ordinal))
{ return; }
_lastAuthorizationNote = message;
}
WriteLog(level, message);
}
@@ -60,25 +87,18 @@ public sealed partial class AgentRuntime
if(saved?.Credential is null || (saved.Binding.HasApprovedDevice
&& (configuration is null || !SameOrigin(saved.Binding.Origin, configuration.ServerUrl))))
{
NoteAuthorization("INFO", DescribeMissingAuthorization(saved, configuration));
lock(_stateLock)
{ _transportNeedsSignIn = true; }
return false;
}
if(_sessionClient is null)
{
IQqqSessionClient restored = NewSession(saved.Credential.Origin);
try
{
await restored.RestoreSessionAsync(saved.Credential, token).ConfigureAwait(false);
QqqSessionBinding binding = configuration is null || !saved.Binding.HasApprovedDevice ? await restored.GetSessionIdentityAsync(token).ConfigureAwait(false)
: await restored.BindDeviceAsync(new QqqDeviceCredential(configuration.DeviceId, configuration.DeviceToken), cancellationToken: token).ConfigureAwait(false);
RequireSameIdentity(saved.Binding, binding);
ActivateSession(restored, binding, store);
}
catch { restored.Dispose(); throw; }
await RestoreSavedSessionAsync(saved, configuration, store, token).ConfigureAwait(false);
}
else if(!_sessionClient.IsAuthenticated)
{
WriteLog("WARN", "The held QQQ session is no longer authenticated (the server ended it or an identity check failed).");
BlockAuthorization();
return false;
}
@@ -90,6 +110,7 @@ public sealed partial class AgentRuntime
}
catch(QqqSessionException exception) when(exception.Failure is QqqSessionException.FailureKind.AuthenticationRequired or QqqSessionException.FailureKind.PermissionDenied)
{
WriteLog("WARN", $"Authorization withdrawn ({exception.Failure}): {exception.Message}");
BlockAuthorization();
return false;
}
@@ -98,6 +119,43 @@ public sealed partial class AgentRuntime
/// <summary>Explains, for the log, why the service cannot talk to QQQ yet: no session, signed out, no pairing, or a session for a server other than the paired one.</summary>
private static string DescribeMissingAuthorization(AgentAuthentication? saved, AgentConfiguration? configuration) => saved is null
? "No saved QQQ session: sign in from the companion to enable printing."
: saved.Credential is null ? $"Signed out of {saved.Binding.Origin.Authority}: sign in again to enable printing; pairing and history are preserved."
: configuration is null ? $"The saved session for {saved.Binding.Origin.Authority} names device {saved.Binding.DeviceId}, but this computer has no pairing configuration; sign in and enroll again."
: $"The saved session is for {saved.Binding.Origin.Authority} but the pairing is for {configuration.ServerUrl}; sign in to the paired server, or start a new pairing.";
/// <summary>
/// Restores the protected saved session on a fresh client, binds the paired device when there is one, checks the identity
/// against what was approved and makes the session current. The client is disposed and the failure logged on any error.
/// </summary>
private async Task RestoreSavedSessionAsync(AgentAuthentication saved, AgentConfiguration? configuration, AgentStore store, CancellationToken token)
{
QqqSessionCredential credential = saved.Credential ?? throw new InvalidOperationException("Sign in is required.");
WriteLog("INFO", $"Restoring the saved QQQ session for {credential.Origin.Authority} (saved identity: principal {saved.Binding.PrincipalId}, tenant {saved.Binding.TenantId}, device {saved.Binding.DeviceId ?? "(none)"}).");
IQqqSessionClient restored = NewSession(credential.Origin);
try
{
await restored.RestoreSessionAsync(credential, token).ConfigureAwait(false);
QqqSessionBinding binding = configuration is null || !saved.Binding.HasApprovedDevice ? await restored.GetSessionIdentityAsync(token).ConfigureAwait(false)
: await restored.BindDeviceAsync(new QqqDeviceCredential(configuration.DeviceId, configuration.DeviceToken), cancellationToken: token).ConfigureAwait(false);
RequireSameIdentity(saved.Binding, binding);
ActivateSession(restored, binding, store);
WriteLog("INFO", "Saved QQQ session restored. " + _authenticationMessage);
}
catch(Exception exception)
{
WriteFailure("Saved QQQ session could not be restored", exception);
restored.Dispose();
throw;
}
}
/// <summary>
/// Renews the live session, re-verifies the identity and saves the new credential. When the session token rotated, the
/// realtime socket is cancelled and a reconnect requested, because the socket was authenticated with the old token.
@@ -106,12 +164,14 @@ public sealed partial class AgentRuntime
{
IQqqSessionClient session = _sessionClient ?? throw new InvalidOperationException("Sign in is required.");
string previousToken = session.ExportCredential().SessionToken;
WriteLog("INFO", "Scheduled session renewal: renewing with the server and re-checking the identity.");
await session.ResumeSessionAsync(token).ConfigureAwait(false);
QqqSessionBinding binding = await session.GetSessionIdentityAsync(token).ConfigureAwait(false);
RequireSameIdentity(saved.Binding, binding);
ActivateSession(session, binding, store);
if(!string.Equals(previousToken, session.ExportCredential().SessionToken, StringComparison.Ordinal))
{
WriteLog("INFO", "The session token was rotated during renewal; the realtime socket will reconnect with the new token.");
lock(_stateLock)
{
_realtimeReady = false;
@@ -133,6 +193,7 @@ public sealed partial class AgentRuntime
{
store.SaveAuthentication(new AgentAuthentication(binding, client.ExportCredential()));
AgentConfiguration? configuration = store.LoadConfiguration();
bool ready;
lock(_stateLock)
{
_sessionClient = client;
@@ -144,7 +205,14 @@ public sealed partial class AgentRuntime
_authorizationBlocked = false;
_nextSessionResume = DateTimeOffset.UtcNow + SessionResumeInterval;
_authenticationMessage = _authorizationReady ? "Signed in to QQQ." : "Signed in. Enroll this computer to enable printing.";
ready = _authorizationReady;
_lastAuthorizationNote = null;
}
WriteLog("INFO", $"QQQ session active for {binding.Origin.Authority}: principal {binding.PrincipalId}, tenant {binding.TenantId}, device {binding.DeviceId ?? "(none)"}; "
+ (ready ? "authorized to print." : configuration is null ? "this computer is not enrolled on any server; enroll to print."
: !SameOrigin(binding.Origin, configuration.ServerUrl) ? $"the pairing is for {configuration.ServerUrl}, so printing stays blocked until this computer is enrolled on the signed-in server."
: $"the session device is {binding.DeviceId ?? "(none)"} but the pairing is device {configuration.DeviceId}, so printing stays blocked until the pairing is re-associated.")
+ $" Next renewal in {SessionResumeInterval.TotalMinutes.ToString("0", System.Globalization.CultureInfo.InvariantCulture)} min.");
}
@@ -155,8 +223,10 @@ public sealed partial class AgentRuntime
/// </summary>
private void BlockAuthorization()
{
bool changed;
lock(_stateLock)
{
changed = !_authorizationBlocked;
_authorizationReady = false;
_authorizationBlocked = true;
_controlReady = false;
@@ -164,6 +234,8 @@ public sealed partial class AgentRuntime
_realtimeLifetime?.Cancel();
_authenticationMessage = "QQQ authorization is required. Sign in again; pairing and history are preserved.";
}
if(changed)
{ WriteLog("WARN", "Printing blocked: QQQ authorization is required. Sign in again from the companion; pairing and history are preserved."); }
SignalPipeline();
}
@@ -182,10 +254,12 @@ public sealed partial class AgentRuntime
/// Requires the identity key (user, tenant and device) to be unchanged since setup. A different identity is reported as
/// permission denied, because silently adopting it would let another account print through this pairing.
/// </summary>
private static void RequireSameIdentity(QqqSessionBinding expected, QqqSessionBinding actual)
private void RequireSameIdentity(QqqSessionBinding expected, QqqSessionBinding actual)
{
if(!string.Equals(expected.IdentityKey, actual.IdentityKey, StringComparison.Ordinal))
{
WriteLog("WARN", $"Identity check failed: the approved identity is principal {expected.PrincipalId}, tenant {expected.TenantId}, device {expected.DeviceId ?? "(none)"} on {expected.Origin.Authority}; "
+ $"the session now reports principal {actual.PrincipalId}, tenant {actual.TenantId}, device {actual.DeviceId ?? "(none)"} on {actual.Origin.Authority}. Sign in as the approved user, or start a new pairing.");
throw new QqqSessionException(QqqSessionException.FailureKind.PermissionDenied, "The QQQ user, tenant or device changed. Explicit connection setup is required.");
}
}
@@ -20,21 +20,28 @@ public sealed partial class AgentRuntime
await _authenticationGate.WaitAsync(token).ConfigureAwait(false);
try
{
IQqqSessionClient session = _sessionClient ?? throw new InvalidOperationException("Sign in to QQQ before enrolling this computer.");
WriteLog("INFO", $"Enrollment requested on {request.ServerUrl ?? "(no server)"} with a {(request.EnrollmentCode ?? "").Length}-character enrollment code (never logged), computer name {Environment.MachineName}, agent {PrintApiClient.AgentVersion}.");
IQqqSessionClient session = _sessionClient ?? throw Refused("no QQQ session is held; sign in first.", new InvalidOperationException("Sign in to QQQ before enrolling this computer."));
if(!session.IsAuthenticated || request.ServerUrl is null || !SameOrigin(session.Origin, request.ServerUrl))
{
throw new InvalidOperationException("Sign in to the selected QQQ server before enrollment.");
throw Refused(!session.IsAuthenticated ? "the held session is not authenticated." : request.ServerUrl is null ? "no server address was given."
: $"the session is for {session.Origin.Authority} but enrollment was requested on {request.ServerUrl}.", new InvalidOperationException("Sign in to the selected QQQ server before enrollment."));
}
WriteLog("INFO", "Step 1 of 4: reading the signed-in identity.");
QqqSessionBinding identity = await session.GetSessionIdentityAsync(token).ConfigureAwait(false);
using var api = PrintApiClient.ForSession(session);
WriteLog("INFO", "Step 2 of 4: exchanging the enrollment code for a device credential.");
EnrollmentResponse response = await api.EnrollAsync(new EnrollmentRequest(request.EnrollmentCode ?? "", Environment.MachineName, PrintApiClient.AgentVersion), token).ConfigureAwait(false);
WriteLog("INFO", $"Enrollment accepted: device {response.DeviceId}, poll interval {response.PollIntervalSeconds} s. Saving the pairing to protected storage.");
using AgentStore store = new(directory, recoverUnreadableConfiguration: true);
store.SaveConfiguration(new AgentConfiguration(request.ServerUrl, response.DeviceId, response.DeviceToken, response.PollIntervalSeconds));
QqqSessionBinding expected = identity with { DeviceId = response.DeviceId };
store.SaveAuthentication(new AgentAuthentication(expected, session.ExportCredential()));
ResetConnectionState();
WriteLog("INFO", "Step 3 of 4: binding the new device to the session and confirming the identity.");
QqqSessionBinding actual = await session.BindDeviceAsync(new QqqDeviceCredential(response.DeviceId, response.DeviceToken), cancellationToken: token).ConfigureAwait(false);
RequireSameIdentity(expected, actual);
WriteLog("INFO", "Step 4 of 4: activating the session for printing.");
ActivateSession(session, actual, store);
lock(_stateLock)
{
@@ -44,6 +51,20 @@ public sealed partial class AgentRuntime
WakePoll();
return $"Enrolled device {response.DeviceId}";
}
catch(Exception exception) when(exception is not InvalidOperationException)
{
WriteFailure("Enrollment failed; any existing pairing is unchanged", exception);
throw;
}
finally { _authenticationGate.Release(); }
}
/// <summary>Logs why an enrollment request was refused before it reached the server, and returns the exception to throw.</summary>
private InvalidOperationException Refused(string reason, InvalidOperationException exception)
{
WriteLog("WARN", "Enrollment refused: " + reason);
return exception;
}
}
@@ -117,7 +117,7 @@ public sealed partial class AgentRuntime
if(exception is QqqSessionException session && session.Failure is QqqSessionException.FailureKind.AuthenticationRequired or QqqSessionException.FailureKind.PermissionDenied)
{ BlockAuthorization(); }
}
WriteLog("ERROR", "Realtime connection stopped: " + exception.Message);
WriteFailure("Realtime connection stopped", exception);
}
finally
{
@@ -126,7 +126,12 @@ public sealed partial class AgentRuntime
SignalPipeline();
}
RealtimeSettings settings = _realtimeSettings;
await _reconnectRequested.WaitAsync(ReconnectDelay(settings, _reconnectAttempts++, Random.Shared.NextDouble()), token).ConfigureAwait(false);
TimeSpan delay = ReconnectDelay(settings, _reconnectAttempts++, Random.Shared.NextDouble());
if(!token.IsCancellationRequested)
{
WriteLog("DEBUG", $"Next realtime connection attempt ({_reconnectAttempts}) in {delay.TotalSeconds.ToString("0.0", System.Globalization.CultureInfo.InvariantCulture)} s, or sooner on an explicit reconnect request.");
}
await _reconnectRequested.WaitAsync(delay, token).ConfigureAwait(false);
}
}
@@ -188,10 +193,12 @@ public sealed partial class AgentRuntime
Task handshakeWatch = AwaitRealtimeHandshakeAsync(handshake.Token, lifetime.Token);
try
{
string[] capabilities = supportsMedia ? [.. RealtimeCapabilities, "printer-media-v1"] : RealtimeCapabilities;
WriteLog("INFO", $"Realtime socket open; sending HELLO (agent {PrintApiClient.AgentVersion}, capabilities [{string.Join(", ", capabilities)}], applied desired-state revision {_controlState.DesiredState.Revision}, settings revision {settings.Revision}); waiting up to {settings.WebSocketHandshakeTimeoutMillis} ms for WELCOME.");
await channel.SendAsync("HELLO", new
{
agentVersion = PrintApiClient.AgentVersion,
capabilities = supportsMedia ? [.. RealtimeCapabilities, "printer-media-v1"] : RealtimeCapabilities,
capabilities,
lastAppliedRevision = _controlState.DesiredState.Revision,
lastSettingsRevision = settings.Revision,
limits = RealtimeSettings.ClientLimits,
@@ -214,6 +221,44 @@ public sealed partial class AgentRuntime
/// <summary>Parses, validates and persists the settings carried by WELCOME or SETTINGS, refusing a revision older than the one in force, and hands them to the channel.</summary>
private void ApplyRealtimeSettings(RealtimeEnvelope envelope, AgentStore store, PrintingRealtimeChannel channel)
{
JsonElement value = string.Equals(envelope.Type, "WELCOME", StringComparison.Ordinal) ? envelope.Payload.GetProperty("settings") : envelope.Payload;
var settings = RealtimeSettings.Parse(value);
settings.Validate();
if(settings.Revision < _realtimeSettings.Revision)
{ throw new InvalidDataException("Settings revision regressed."); }
store.SaveRealtimeSettings(settings);
_realtimeSettings = settings;
channel.UpdateSettings(settings);
}
/// <summary>
/// Marks the runtime ready when the acknowledged update is the newest one on the current connection and the desired
/// state did not change meanwhile; returns true only on the transition from not ready to ready, so it can be logged once.
/// </summary>
private bool MarkRealtimeReady(long generation, RealtimeStateUpdate update, AgentControlState saved)
{
lock(_stateLock)
{
if(generation != _connectionGeneration || update.Sequence != _stateSequence || saved.DesiredState.Revision != _controlState.DesiredState.Revision)
{ return false; }
bool becameReady = !_realtimeReady;
_realtimeReady = true;
_transportConnectedBefore = true;
_controlReady = true;
_controlFailure = null;
_failure = null;
_reconnectAttempts = 0;
return becameReady;
}
}
/// <summary>Reads the discovery refreshed by this socket establishment, so server upgrades and downgrades change capabilities on reconnect without polling.</summary>
private async Task<(WebSocket Socket, bool SupportsMedia)> ConnectRealtimeSocketAsync(RealtimeSettings settings, CancellationTokenSource lifetime, CancellationToken token)
{
@@ -272,6 +317,7 @@ public sealed partial class AgentRuntime
{
lock(_stateLock)
{ _lastSuccessfulPoll = ObservationTime; _heartbeatFailure = null; }
WriteLog("DEBUG", $"Realtime frame received: {envelope.Type}.");
if(envelope.Type is "WELCOME" or "PONG")
{
string? serverTime = envelope.Payload.GetProperty("serverTime").GetString();
@@ -304,6 +350,7 @@ public sealed partial class AgentRuntime
break;
case "ERROR":
string code = envelope.Payload.GetProperty("code").GetString() ?? "UNKNOWN";
WriteLog("WARN", $"The server sent a realtime ERROR frame with code {code}{(envelope.Payload.TryGetProperty("message", out JsonElement detail) && detail.ValueKind == JsonValueKind.String ? ": " + detail.GetString() : "")}.");
if(code is "AUTHENTICATION_REQUIRED" or "PERMISSION_DENIED" or "AUTHORIZATION_REQUIRED")
{ BlockAuthorization(); }
throw new IOException("Realtime server response: " + code);
@@ -364,34 +411,18 @@ public sealed partial class AgentRuntime
{ if(generation != _connectionGeneration) { throw new OperationCanceledException(token); } }
using var store = new AgentStore(directory);
if(envelope.Type is "WELCOME" or "SETTINGS")
{
JsonElement value = string.Equals(envelope.Type, "WELCOME", StringComparison.Ordinal) ? envelope.Payload.GetProperty("settings") : envelope.Payload;
var settings = RealtimeSettings.Parse(value);
settings.Validate();
if(settings.Revision < _realtimeSettings.Revision)
{ throw new InvalidDataException("Settings revision regressed."); }
store.SaveRealtimeSettings(settings);
_realtimeSettings = settings;
channel.UpdateSettings(settings);
}
{ ApplyRealtimeSettings(envelope, store, channel); }
lock(_stateLock)
{ saved = _controlState; }
store.SaveControlState(saved);
}
finally { _connectionGate.Release(); }
await channel.SendAsync("STATE_APPLIED", new { lastAppliedRevision = saved.DesiredState.Revision, lastSettingsRevision = _realtimeSettings.Revision }, acknowledged: true, token: token).ConfigureAwait(false);
lock(_stateLock)
{
if(generation == _connectionGeneration && update.Sequence == _stateSequence && saved.DesiredState.Revision == _controlState.DesiredState.Revision)
{
_realtimeReady = true;
_transportConnectedBefore = true;
_controlReady = true;
_controlFailure = null;
_failure = null;
_reconnectAttempts = 0;
}
}
bool becameReady = MarkRealtimeReady(generation, update, saved);
if(becameReady)
{ WriteLog("INFO", $"Realtime channel ready after {envelope.Type}: settings revision {_realtimeSettings.Revision}, desired-state revision {saved.DesiredState.Revision}. Connected to the print server."); }
else
{ WriteLog("DEBUG", $"Realtime {envelope.Type} applied and acknowledged (settings revision {_realtimeSettings.Revision}, desired-state revision {saved.DesiredState.Revision})."); }
SignalPipeline();
RequestStatusSynchronization();
RequestQueueRefresh();
@@ -24,6 +24,26 @@ public sealed partial class AgentRuntime
case "set-log-settings":
AgentActivityLog.SaveSettings(directory, request.LogSettings ?? throw new InvalidOperationException("Log settings are required."));
return JsonSerializer.Serialize(AgentActivityLog.GetSettings(directory), PrintApiClient.JsonOptions);
case "get-server-trust":
using(var store = new AgentStore(directory, recoverUnreadableConfiguration: true))
{
return JsonSerializer.Serialize(store.LoadServerTrust(), PrintApiClient.JsonOptions);
}
case "set-server-trust":
ServerTrust trust = request.ServerTrust ?? throw new InvalidOperationException("Server trust settings are required.");
if(trust.AllowUntrustedCertificate && (trust.Origin is null || !Uri.TryCreate(trust.Origin, UriKind.Absolute, out Uri? trusted) || !string.Equals(ServerTrust.Normalize(trusted), trust.Origin, StringComparison.Ordinal)))
{
throw new InvalidOperationException("Enter the server address before allowing its untrusted certificate; the exception is saved for that one server only.");
}
trust = trust.AllowUntrustedCertificate ? trust : new ServerTrust();
using(var store = new AgentStore(directory, recoverUnreadableConfiguration: true))
{
store.SaveServerTrust(trust);
}
WriteLog(trust.AllowUntrustedCertificate ? "WARN" : "INFO", trust.AllowUntrustedCertificate
? $"An administrator ALLOWED untrusted server certificates for {trust.Origin}. The service and companion will accept any certificate, including a forged one, from that server only; use this with a development server and turn it off afterwards."
: "Untrusted server certificates are refused again for every server (default). Only certificates Windows trusts for the LocalSystem account are accepted.");
return JsonSerializer.Serialize(trust, PrintApiClient.JsonOptions);
case "clear-logs":
int deleted = AgentActivityLog.ClearLogs(directory);
lock(_stateLock)
@@ -29,43 +29,55 @@ public sealed partial class AgentRuntime
/// </summary>
private async Task<string> SignInCoreAsync(PipeRequest request, CancellationToken token)
{
QqqSessionCredential credential = request.SessionCredential ?? throw new InvalidOperationException("A QQQ sign-in session is required.");
if(request.StartNewPairing && request.ClaimLegacyDevice)
{
throw new InvalidOperationException("Choose either a new pairing or association with the existing pairing.");
}
QqqSessionCredential credential = BeginSignIn(request);
await _authenticationGate.WaitAsync(token).ConfigureAwait(false);
IQqqSessionClient? candidate = null;
try
{
candidate = NewSession(credential.Origin);
WriteLog("INFO", "Step 1 of 4: verifying the companion's session with the server.");
await candidate.RestoreSessionAsync(credential, token).ConfigureAwait(false);
using AgentStore store = new(directory, recoverUnreadableConfiguration: true);
AgentConfiguration? configuration = store.LoadConfiguration();
WriteLog("INFO", "Step 2 of 4: reading the session identity. Saved pairing: "
+ (configuration is null ? "none (this computer has never been enrolled)." : $"server {configuration.ServerUrl}, device {configuration.DeviceId}."));
QqqSessionBinding binding = await candidate.GetSessionIdentityAsync(token).ConfigureAwait(false);
if(request.StartNewPairing && binding.DeviceId is not null)
{
WriteLog("WARN", $"Sign-in refused: a new pairing needs a session without device proof, but the session already names device {binding.DeviceId}.");
throw new QqqSessionException(QqqSessionException.FailureKind.PermissionDenied, "New pairing requires a fresh QQQ session without device proof.");
}
AgentAuthentication? saved = store.LoadAuthenticationForRecovery();
if(!request.StartNewPairing && configuration is not null && SameOrigin(credential.Origin, configuration.ServerUrl))
{
WriteLog("INFO", $"Step 3 of 4: the session is for the paired server; binding device {configuration.DeviceId}{(request.ClaimLegacyDevice ? " and claiming it as a legacy device" : "")}.");
binding = await candidate.BindDeviceAsync(new QqqDeviceCredential(configuration.DeviceId, configuration.DeviceToken), request.ClaimLegacyDevice, token).ConfigureAwait(false);
if(saved?.Binding.DeviceId is not null && SameOrigin(saved.Binding.Origin, configuration.ServerUrl))
{
WriteLog("INFO", "Comparing the new identity with the previously approved identity.");
RequireSameIdentity(saved.Binding, binding);
}
store.SaveControlState(store.LoadControlState() with { AuthorizationRequired = false });
}
else
{
WriteLog("INFO", "Step 3 of 4: no device binding. "
+ (request.StartNewPairing ? "A new pairing was requested; enroll this computer next."
: configuration is null ? "This computer is not enrolled; enroll it next to enable printing."
: $"The session is for {credential.Origin.Authority} but the pairing is for {configuration.ServerUrl}; enroll on this server, or sign in to the paired one."));
}
IQqqSessionClient? previous = _sessionClient;
ResetConnectionState();
WriteLog("INFO", "Step 4 of 4: making the verified session current and saving it to protected storage.");
ActivateSession(candidate, binding, store);
previous?.Dispose();
WakePoll();
WriteLog("INFO", "Sign-in complete. " + _authenticationMessage);
return "Signed in to QQQ. " + _authenticationMessage;
}
catch
catch(Exception exception)
{
WriteFailure("Sign-in failed; the previous session, pairing and history are unchanged", exception);
candidate?.Dispose();
throw;
}
@@ -74,6 +86,22 @@ public sealed partial class AgentRuntime
/// <summary>Checks the request shape before any network work (a credential is present and the pairing choice is not contradictory) and logs what was asked.</summary>
private QqqSessionCredential BeginSignIn(PipeRequest request)
{
QqqSessionCredential credential = request.SessionCredential ?? throw new InvalidOperationException("A QQQ sign-in session is required.");
if(request.StartNewPairing && request.ClaimLegacyDevice)
{
WriteLog("WARN", "Sign-in refused: the companion asked for both a new pairing and association with the existing pairing.");
throw new InvalidOperationException("Choose either a new pairing or association with the existing pairing.");
}
WriteLog("INFO", $"Sign-in requested for {credential.Origin.Authority}; pairing choice: "
+ (request.StartNewPairing ? "start a new pairing." : request.ClaimLegacyDevice ? "associate with the existing pairing (claim legacy device)." : "keep the current pairing."));
return credential;
}
/// <summary>Runs sign-out as a connection transition, so it cannot overlap a claim or a printing job.</summary>
internal async Task<string> SignOutAsync(CancellationToken token)
=> await ConnectionTransitionAsync(() => SignOutCoreAsync(token), token).ConfigureAwait(false);
@@ -90,10 +118,13 @@ public sealed partial class AgentRuntime
await _authenticationGate.WaitAsync(token).ConfigureAwait(false);
try
{
WriteLog("INFO", "Sign-out requested: blocking printing, removing the saved session credential and ending the server session. Pairing and history are kept.");
BlockAuthorization();
using AgentStore store = new(directory, recoverUnreadableConfiguration: true);
AgentAuthentication? saved = store.LoadAuthenticationForRecovery();
bool unreadableSession = store.QuarantineUnreadableAuthentication();
if(unreadableSession)
{ WriteLog("WARN", "The saved session data could not be read and was moved aside for recovery; revoke the previous session in QQQ."); }
if(saved is not null)
{
store.SaveAuthentication(new AgentAuthentication(saved.Binding, null));
@@ -102,6 +133,7 @@ public sealed partial class AgentRuntime
{
if(_sessionClient is null && saved?.Credential is not null)
{
WriteLog("INFO", $"No live session is held; restoring the saved one for {saved.Credential.Origin.Authority} so the server can end it.");
_sessionClient = NewSession(saved.Credential.Origin);
await _sessionClient.RestoreSessionAsync(saved.Credential, token).ConfigureAwait(false);
}
@@ -109,12 +141,20 @@ public sealed partial class AgentRuntime
{
await _sessionClient.SignOutAsync(token).ConfigureAwait(false);
}
else
{ WriteLog("INFO", "No session to end on the server; local state cleared."); }
}
catch(Exception exception)
{
WriteFailure("The server session could not be ended; local sign-out still completes", exception);
throw;
}
finally
{
_sessionClient?.Dispose();
_sessionClient = null;
ResetConnectionState();
WriteLog("INFO", "Signed out locally. Device pairing and print history were preserved.");
}
return unreadableSession
? "Signed out locally. Unreadable session data was preserved for recovery; revoke the previous session in QQQ. Device pairing and print history were preserved."
+45 -10
View File
@@ -7,7 +7,6 @@ using System.IO.Pipes;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Text.Json;
using KofTwentyTwo.AppKit.Logging;
using Qqq.PrintAgent.Core.Authentication;
using Qqq.PrintAgent.Core.Printing;
@@ -23,7 +22,7 @@ public sealed partial class AgentRuntime(string directory, Action<string> log, I
private readonly Queue<AgentLogEntry> _logs = new();
private readonly SemaphoreSlim _pollRequested = new(0, 1);
private readonly SemaphoreSlim _connectionGate = new(1, 1);
private readonly IActivityLog _activityLog = AgentActivityLog.Create(directory);
private readonly AgentActivityLog _activityLog = AgentActivityLog.Create(directory);
private DateTimeOffset? _lastSuccessfulPoll;
private string? _failure;
private int _pollIntervalSeconds = 5;
@@ -275,12 +274,23 @@ public sealed partial class AgentRuntime(string directory, Action<string> log, I
/// <summary>
/// Records a service message in the in-memory ring shown to the companion (newest 100) and in the shared activity log.
/// The text is redacted first and capped at 1000 characters, so credentials and document content cannot reach either sink.
/// The text is redacted first and capped at 1000 characters (4000 for debug detail), so credentials and document content
/// cannot reach either sink. DEBUG entries go only to the file, only while debug logging is on, and never displace the
/// ring's operational entries.
/// </summary>
private void WriteLog(string level, string message)
{
bool debug = string.Equals(level, "DEBUG", StringComparison.Ordinal);
if(debug && !_activityLog.IsDebugEnabled)
{ return; }
string sanitized = AgentActivityLog.Redact(message);
string bounded = sanitized.Length <= 1000 ? sanitized : sanitized[..1000];
int cap = debug ? 4000 : 1000;
string bounded = sanitized.Length <= cap ? sanitized : sanitized[..cap];
if(debug)
{
_activityLog.Debug("[Service] " + bounded);
return;
}
lock(_stateLock)
{
_logs.Enqueue(new AgentLogEntry(DateTimeOffset.UtcNow.ToString("O", System.Globalization.CultureInfo.InvariantCulture), level, bounded));
@@ -289,15 +299,38 @@ public sealed partial class AgentRuntime(string directory, Action<string> log, I
_logs.Dequeue();
}
}
if(string.Equals(level, "ERROR", StringComparison.Ordinal))
{ _activityLog.Error("[Service] " + bounded); }
else
{ _activityLog.Info("[Service] " + bounded); }
switch(level)
{
case "ERROR":
_activityLog.Error("[Service] " + bounded);
break;
case "WARN":
_activityLog.Warning("[Service] " + bounded);
break;
default:
_activityLog.Info("[Service] " + bounded);
break;
}
log(bounded);
}
/// <summary>Records a failure with its type and message at ERROR, and the full exception (stack and inner causes) as debug detail.</summary>
private void WriteFailure(string context, Exception exception)
{
WriteLog("ERROR", context + ": " + QqqSessionDiagnostics.Describe(exception));
WriteLog("DEBUG", context + " detail: " + exception);
}
/// <summary>The session client's progress sink: step entries at INFO, request detail at DEBUG, both through the redacting service log.</summary>
private QqqSessionDiagnostics SessionDiagnostics() => new(message => WriteLog("INFO", "[Session] " + message),
message => WriteLog("DEBUG", "[Session] " + message), () => _activityLog.IsDebugEnabled);
/// <summary>
/// Serves the local administrator pipe one client at a time, each command under a timeout (longer for sign-in and
/// enrollment). A failing or vanished client gets a short, redacted error and never stops the pipe; an unreadable saved
@@ -322,10 +355,10 @@ public sealed partial class AgentRuntime(string directory, Action<string> log, I
}
catch(Exception exception) when(exception is not OperationCanceledException || !token.IsCancellationRequested)
{
WriteLog("ERROR", "Local command failed: " + exception.Message);
WriteLog("ERROR", "Local command failed: " + QqqSessionDiagnostics.Describe(exception));
string message = exception is System.Security.Cryptography.CryptographicException
? "The saved connection cannot be opened. Open Settings, choose Connect this device or Sign in again, and use a fresh server enrollment code. Existing job history will be preserved."
: AgentActivityLog.Redact(exception.Message);
: AgentActivityLog.Redact(QqqSessionDiagnostics.Explain(exception));
try
{
using var errorTimeout = CancellationTokenSource.CreateLinkedTokenSource(token);
@@ -372,6 +405,8 @@ public sealed partial class AgentRuntime(string directory, Action<string> log, I
case "get-device-profile":
case "get-log-settings":
case "set-log-settings":
case "get-server-trust":
case "set-server-trust":
case "clear-logs":
return HandleSettingsCommand(request);
case "prepare-update":
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Text.Json;
using Microsoft.Data.Sqlite;
using Qqq.PrintAgent.Core.Printing;
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// Transport trust part of the store. The setting is machine-wide rather than connection-scoped because it must apply to
/// the sign-in that happens before any pairing exists, and a damaged saved value falls back to the safe default (refuse).
/// </summary>
public sealed partial class AgentStore
{
/// <summary>Reads the administrator's transport trust choice; a missing or unreadable value means untrusted certificates are refused.</summary>
public ServerTrust LoadServerTrust()
{
using SqliteCommand command = _database.CreateCommand();
command.CommandText = "CREATE TABLE IF NOT EXISTS server_trust(scope TEXT PRIMARY KEY,json TEXT NOT NULL); SELECT json FROM server_trust WHERE scope='machine'";
try
{ return command.ExecuteScalar() is string json ? JsonSerializer.Deserialize<ServerTrust>(json, PrintApiClient.JsonOptions) ?? new() : new(); }
catch(JsonException)
{ return new ServerTrust(); }
}
/// <summary>Saves the administrator's transport trust choice for both the service and the companion.</summary>
public void SaveServerTrust(ServerTrust trust)
{
ArgumentNullException.ThrowIfNull(trust);
using SqliteCommand command = _database.CreateCommand();
command.CommandText = "CREATE TABLE IF NOT EXISTS server_trust(scope TEXT PRIMARY KEY,json TEXT NOT NULL); INSERT INTO server_trust(scope,json) VALUES('machine',$json) ON CONFLICT(scope) DO UPDATE SET json=$json";
command.Parameters.AddWithValue("$json", JsonSerializer.Serialize(trust, PrintApiClient.JsonOptions));
command.ExecuteNonQuery();
}
}
+9 -2
View File
@@ -5,5 +5,12 @@
namespace Qqq.PrintAgent.Windows;
/// <summary>Machine-wide diagnostic limits. Daily rotation is always enabled.</summary>
public sealed record LogSettings(int RetentionDays = 30, int MaxDailyMegabytes = 20, long CurrentDayBytes = 0, bool DailyLimitReached = false);
/// <summary>
/// Machine-wide diagnostic limits. Daily rotation is always on. A day's file that reaches <see cref="MaxFileMegabytes"/> rolls
/// over to a numbered segment and logging continues in a fresh file; only the newest <see cref="SegmentsPerDay"/> rolled
/// segments of a day are kept, and whole days older than <see cref="RetentionDays"/> are removed. <see cref="DebugLogging"/>
/// adds request-level detail to the shared log. <see cref="CurrentFileBytes"/> and <see cref="SegmentsToday"/> are read-only
/// observations for the Settings page.
/// </summary>
public sealed record LogSettings(int RetentionDays = 30, int MaxFileMegabytes = 100, int SegmentsPerDay = 10, bool DebugLogging = false,
long CurrentFileBytes = 0, int SegmentsToday = 0);
@@ -7,7 +7,6 @@ using System.Diagnostics;
using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json;
using KofTwentyTwo.AppKit.Logging;
using Qqq.PrintAgent.Core.Printing;
namespace Qqq.PrintAgent.Windows;
@@ -217,7 +216,7 @@ public static partial class MsiUpdateInstaller
try
{
WriteOutcome(new InstallOutcome(DateTimeOffset.UtcNow, version, code, message));
IActivityLog log = AgentActivityLog.Create(AgentStore.DefaultDirectory);
var log = AgentActivityLog.Create(AgentStore.DefaultDirectory);
if(code is 0 or 3010)
{
log.Info("[Update] " + message);
+2 -1
View File
@@ -12,7 +12,8 @@ namespace Qqq.PrintAgent.Windows;
public sealed record PipeRequest(string Operation, string? ServerUrl = null, string? EnrollmentCode = null, string? PrinterId = null, bool? Exposed = null,
string? JobId = null, string? Format = null, bool ConfirmDuplicateRisk = false, JobQuery? JobQuery = null, LogSettings? LogSettings = null,
Qqq.PrintAgent.Core.Printing.DeviceProfile? DeviceProfile = null, PrinterProfile? PrinterProfile = null, string? TestProfile = null,
Qqq.PrintAgent.Core.Authentication.QqqSessionCredential? SessionCredential = null, bool ClaimLegacyDevice = false, bool StartNewPairing = false)
Qqq.PrintAgent.Core.Authentication.QqqSessionCredential? SessionCredential = null, bool ClaimLegacyDevice = false, bool StartNewPairing = false,
ServerTrust? ServerTrust = null)
{
/// <summary>Returns a fixed label instead of the generated record text, so logging a request can never reveal a session credential or enrollment code.</summary>
public override string ToString() => "Print agent management request (credentials redacted)";
+34
View File
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// Transport trust chosen by an administrator for one server. <see cref="AllowUntrustedCertificate"/> makes the service and
/// companion accept a certificate Windows does not trust (self-signed or a private authority such as a local Caddy root),
/// but only for the server whose scheme, host and port equal <see cref="Origin"/>; a session for any other server keeps
/// normal validation, so a development exception cannot carry over to a production pairing. It is for development servers
/// only: with it on, a forged certificate for that server is also accepted. Every session created under it is logged with
/// a warning, and the setting stays until it is turned off or saved for a different server.
/// </summary>
public sealed record ServerTrust(bool AllowUntrustedCertificate = false, string? Origin = null)
{
/// <summary>True when untrusted certificates are allowed and the given server is exactly the one the exception was saved for.</summary>
public bool AppliesTo(Uri origin)
{
ArgumentNullException.ThrowIfNull(origin);
return AllowUntrustedCertificate && Origin is not null && origin.IsAbsoluteUri
&& string.Equals(Normalize(origin), Origin, StringComparison.OrdinalIgnoreCase);
}
/// <summary>The scheme, host and port of a server address in the form the exception is saved as.</summary>
public static string Normalize(Uri origin)
{
ArgumentNullException.ThrowIfNull(origin);
return origin.GetLeftPart(UriPartial.Authority).ToLowerInvariant();
}
}
+1 -2
View File
@@ -3,7 +3,6 @@
* SPDX-License-Identifier: MIT
*/
using KofTwentyTwo.AppKit.Logging;
using KofTwentyTwo.AppKit.WinUI;
using Microsoft.UI.Xaml;
using Qqq.PrintAgent.Windows;
@@ -30,7 +29,7 @@ public partial class App : Application
/// <summary>The app-wide activity log.</summary>
internal static IActivityLog Log { get; } = AgentActivityLog.Create(AgentStore.DefaultDirectory);
internal static AgentActivityLog Log { get; } = AgentActivityLog.Create(AgentStore.DefaultDirectory);
+144 -7
View File
@@ -4,6 +4,7 @@
*/
using System.Diagnostics;
using System.Text.Json;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Qqq.PrintAgent.Core.Authentication;
@@ -21,6 +22,106 @@ public sealed partial class MainWindow
private QqqSessionClient? _signInClient;
private QqqAuthenticationMetadata? _signInMetadata;
private CancellationTokenSource? _browserSignInCancellation;
private ServerTrust _serverTrust = new();
/// <summary>The companion's progress sink for the sign-in client: steps at INFO, request detail at DEBUG, in the shared log the service also writes.</summary>
private static QqqSessionDiagnostics CompanionDiagnostics() => new(message => App.Log.Info("[Companion] " + message),
message => App.Log.Debug("[Companion] " + message), () => App.Log.IsDebugEnabled);
/// <summary>Records a companion-side failure: type and message at ERROR, the full exception as debug detail.</summary>
private static void LogFailure(string context, Exception exception)
{
App.Log.Error("[Companion] " + context + ": " + QqqSessionDiagnostics.Describe(exception));
App.Log.Debug("[Companion] " + context + " detail: " + exception);
}
/// <summary>Reads the service's transport trust setting into the connection dialog's checkbox; when the service cannot be reached the box stays unchecked and the reason is logged.</summary>
private async Task LoadServerTrustAsync()
{
try
{
string response = await _sendRequest(new PipeRequest("get-server-trust"));
if(!response.StartsWith("Error:", StringComparison.Ordinal))
{
_serverTrust = JsonSerializer.Deserialize<ServerTrust>(response, Qqq.PrintAgent.Core.Printing.PrintApiClient.JsonOptions) ?? new();
ShowServerTrust();
return;
}
App.Log.Warning("[Companion] Could not read the server trust setting: " + response);
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException or OperationCanceledException or JsonException)
{
LogFailure("Could not read the server trust setting from the service", exception);
}
_serverTrust = new ServerTrust();
ShowServerTrust();
}
/// <summary>Reflects the saved exception in the checkbox and names the server it is bound to, so an exception saved for another server is visibly not in force for the one being entered.</summary>
private void ShowServerTrust()
{
AllowUntrustedCertificateBox.IsChecked = _serverTrust.AllowUntrustedCertificate;
AllowUntrustedCertificateBox.Content = _serverTrust.AllowUntrustedCertificate
? "Allow untrusted server certificate for " + _serverTrust.Origin + " (development only)"
: "Allow untrusted server certificate for this server (development only)";
}
/// <summary>True when the saved exception is on and bound to exactly the server being signed in to.</summary>
private bool AllowsUntrustedCertificate(Uri origin) => _serverTrust.AppliesTo(origin);
/// <summary>Saves the checkbox to the service so the service's own sessions honor it, and logs the change loudly; a failed save reverts the box and shows why.</summary>
private async void AllowUntrustedCertificate_Click(object sender, RoutedEventArgs e)
{
bool allow = AllowUntrustedCertificateBox.IsChecked == true;
Uri? origin = null;
if(allow && !Uri.TryCreate(ServerBox.Text.Trim(), UriKind.Absolute, out origin))
{
AllowUntrustedCertificateBox.IsChecked = false;
Feedback("Enter the server address first", "The certificate exception is saved for one server only, so the server address must be entered before it can be allowed.", InfoBarSeverity.Warning);
return;
}
ServerTrust requested = allow ? new ServerTrust(true, ServerTrust.Normalize(origin!)) : new ServerTrust();
try
{
string response = await _sendRequest(new PipeRequest("set-server-trust", ServerTrust: requested));
if(response.StartsWith("Error:", StringComparison.Ordinal))
{
ShowServerTrust();
Feedback("Server trust not saved", response, InfoBarSeverity.Error);
return;
}
_serverTrust = requested;
ShowServerTrust();
if(allow)
{
App.Log.Warning("[Companion] Untrusted server certificates ALLOWED by the operator for " + requested.Origin + " (development only).");
Feedback("Untrusted certificate allowed for " + requested.Origin, "The service and companion will accept any certificate from this one server until this is turned off. Other servers keep normal validation. Use only with a development server.", InfoBarSeverity.Warning);
}
else
{
App.Log.Info("[Companion] Untrusted server certificates refused again for every server (default).");
Feedback("Untrusted certificates refused", "Only certificates Windows trusts for the service account are accepted again.", InfoBarSeverity.Informational);
}
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException or OperationCanceledException)
{
ShowServerTrust();
LogFailure("Server trust setting could not be saved", exception);
ServiceUnavailable();
}
}
@@ -50,24 +151,31 @@ public sealed partial class MainWindow
{
_signInClient?.Dispose();
_signInClient = null;
if(!Uri.TryCreate(ServerBox.Text.Trim(), UriKind.Absolute, out Uri? origin))
string entered = ServerBox.Text.Trim();
App.Log.Info("[Companion] Discover sign-in options requested for '" + entered + "'.");
if(!Uri.TryCreate(entered, UriKind.Absolute, out Uri? origin))
{
App.Log.Warning("[Companion] The server address is not an absolute URL (expected https://host[:port]).");
throw new InvalidOperationException("Enter a valid server origin.");
}
QqqSessionClient client = new(origin, allowLoopbackHttp: origin.IsLoopback);
QqqSessionClient client = new(origin, allowLoopbackHttp: origin.IsLoopback, diagnostics: CompanionDiagnostics(), allowUntrustedCertificate: AllowsUntrustedCertificate(origin));
_signInClient = client;
_ = await client.DiscoverAsync();
_signInMetadata = await client.GetAuthenticationMetadataAsync();
PasswordSignInPanel.Visibility = string.Equals(_signInMetadata.Type, "TABLE_BASED", StringComparison.Ordinal) ? Visibility.Visible : Visibility.Collapsed;
BrowserSignInButton.Visibility = _signInMetadata.Values?.NativeSessionLogin is not null ? Visibility.Visible : Visibility.Collapsed;
bool supported = PasswordSignInPanel.Visibility == Visibility.Visible || BrowserSignInButton.Visibility == Visibility.Visible;
App.Log.Info("[Companion] Sign-in options for " + origin.Authority + ": password sign-in "
+ (PasswordSignInPanel.Visibility == Visibility.Visible ? "offered" : "not offered") + ", browser sign-in "
+ (BrowserSignInButton.Visibility == Visibility.Visible ? "offered" : "not offered") + ".");
Feedback(supported ? "Sign-in options ready" : "Server configuration required",
supported ? "Use the sign-in method configured by this server." : "This server has not enabled a supported native sign-in grant. Ask its administrator to configure it.",
supported ? InfoBarSeverity.Informational : InfoBarSeverity.Warning);
}
catch(Exception exception) when(exception is IOException or ArgumentException or InvalidOperationException or HttpRequestException or OperationCanceledException)
{
Feedback("Discovery failed", "The server did not provide compatible authentication and printing discovery. Check its address and configuration.", InfoBarSeverity.Error);
LogFailure("Discovery failed", exception);
Feedback("Discovery failed", "The server did not provide compatible authentication and printing discovery. Reason: " + QqqSessionDiagnostics.Explain(exception) + " The activity log has the full detail.", InfoBarSeverity.Error);
}
finally { SetBusy(false); }
}
@@ -86,12 +194,14 @@ public sealed partial class MainWindow
SetBusy(true);
try
{
App.Log.Info("[Companion] Password sign-in submitted for " + _signInClient.Origin.Authority + ".");
await _signInClient.SignInWithPasswordAsync(UsernameBox.Text.Trim(), QqqPasswordBox.Password);
await TransferSignInAsync();
}
catch(Exception exception) when(exception is IOException or ArgumentException or HttpRequestException or OperationCanceledException or UnauthorizedAccessException)
{
Feedback("Sign-in not completed", "The server did not authorize this connection, or the service could not confirm it. Check your sign-in, device ownership and current permissions.", InfoBarSeverity.Error);
LogFailure("Password sign-in not completed", exception);
Feedback("Sign-in not completed", "The server did not authorize this connection, or the service could not confirm it. Reason: " + QqqSessionDiagnostics.Explain(exception) + " The activity log has the full detail.", InfoBarSeverity.Error);
}
finally
{
@@ -109,9 +219,15 @@ public sealed partial class MainWindow
/// </summary>
private async Task TransferSignInAsync()
{
string choice = PairingChoice.SelectedIndex == 2 ? "start a new pairing" : PairingChoice.SelectedIndex == 1 ? "associate with the existing pairing" : "keep the current pairing";
App.Log.Info("[Companion] Sign-in accepted by the server; handing the session to the service (" + choice + ").");
string result = await _sendRequest(new PipeRequest("sign-in", SessionCredential: _signInClient!.ExportCredential(),
ClaimLegacyDevice: PairingChoice.SelectedIndex == 1, StartNewPairing: PairingChoice.SelectedIndex == 2));
bool success = result.StartsWith("Signed in to QQQ.", StringComparison.Ordinal);
if(success)
{ App.Log.Info("[Companion] Service sign-in result: " + result); }
else
{ App.Log.Error("[Companion] Service sign-in result: " + result); }
Feedback(success ? "Signed in" : "Service authorization not completed", result, success ? InfoBarSeverity.Success : InfoBarSeverity.Error);
}
@@ -125,13 +241,18 @@ public sealed partial class MainWindow
SetBusy(true);
try
{
App.Log.Info("[Companion] Sign-out requested; asking the service to end the session.");
string result = await _sendRequest(new PipeRequest("sign-out"));
_signInClient?.Dispose();
_signInClient = null;
App.Log.Info("[Companion] Service sign-out result: " + result);
Feedback("Account sign-out", result, result.StartsWith("Error:", StringComparison.Ordinal) ? InfoBarSeverity.Warning : InfoBarSeverity.Informational);
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException or OperationCanceledException)
{ ServiceUnavailable(); }
{
LogFailure("Sign-out could not reach the service", exception);
ServiceUnavailable();
}
finally { SetBusy(false); }
}
@@ -150,9 +271,10 @@ public sealed partial class MainWindow
CancelBrowserSignInButton.Visibility = Visibility.Visible;
try
{
App.Log.Info("[Companion] Browser sign-in requested for " + _signInClient.Origin.Authority + "; this application's callback is " + QqqBrowserCallback.RedirectUri + ".");
string redirect = _signInMetadata?.Values?.NativeSessionLogin?.RedirectUris.FirstOrDefault(value =>
string.Equals(value, QqqBrowserCallback.RedirectUri, StringComparison.Ordinal))
?? throw new InvalidOperationException("No approved native browser callback is configured.");
?? throw Unlisted();
using CancellationTokenSource timeout = new(TimeSpan.FromMinutes(3));
_browserSignInCancellation = timeout;
await CompleteBrowserSignInAsync(new Uri(redirect), timeout.Token);
@@ -160,6 +282,7 @@ public sealed partial class MainWindow
}
catch(OperationCanceledException)
{
App.Log.Info("[Companion] Browser sign-in cancelled or timed out (three minute limit) before a callback arrived.");
_signInClient?.Dispose();
_signInClient = null;
BrowserSignInButton.Visibility = Visibility.Collapsed;
@@ -167,7 +290,8 @@ public sealed partial class MainWindow
}
catch(Exception exception) when(exception is IOException or InvalidOperationException or HttpRequestException or UnauthorizedAccessException or System.ComponentModel.Win32Exception)
{
Feedback("Browser sign-in not completed", "No validated account session was received. Check the server's advertised native sign-in grant and installed callback configuration, then try again explicitly.", InfoBarSeverity.Warning);
LogFailure("Browser sign-in not completed", exception);
Feedback("Browser sign-in not completed", "No validated account session was received. Reason: " + QqqSessionDiagnostics.Explain(exception) + " Check the server's advertised native sign-in grant and installed callback configuration, then try again explicitly. The activity log has the full detail.", InfoBarSeverity.Warning);
}
finally
{
@@ -187,9 +311,22 @@ public sealed partial class MainWindow
private async Task CompleteBrowserSignInAsync(Uri redirect, CancellationToken token)
{
using QqqBrowserCallback inbox = new();
App.Log.Info("[Companion] Browser callback inbox open; starting the sign-in attempt.");
QqqBrowserLogin attempt = await _signInClient!.BeginBrowserLoginAsync(redirect, token);
App.Log.Info("[Companion] Opening the identity provider's sign-in page in the default browser (" + attempt.AuthorizationUri.Authority + ").");
_ = Process.Start(new ProcessStartInfo(attempt.AuthorizationUri.AbsoluteUri) { UseShellExecute = true });
Uri callbackUri = await inbox.ReceiveAsync(token);
App.Log.Info("[Companion] Browser callback received; completing the sign-in.");
await _signInClient.CompleteBrowserLoginAsync(attempt, callbackUri, token);
}
/// <summary>Logs which callbacks the server does list before reporting that this application's callback is not among them.</summary>
private InvalidOperationException Unlisted()
{
App.Log.Warning("[Companion] The server's native grant does not list this application's callback " + QqqBrowserCallback.RedirectUri + "; it lists ["
+ string.Join(", ", _signInMetadata?.Values?.NativeSessionLogin?.RedirectUris ?? []) + "]. Add the callback to the QQQ native session grant.");
return new InvalidOperationException("No approved native browser callback is configured.");
}
}
+18 -11
View File
@@ -13,14 +13,15 @@ using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent;
/// <summary>
/// Log settings part of the main window: retention and daily size limits for the shared diagnostic log, and clearing it. The values are
/// applied by the service, which owns the log files, so the service and companion always share one policy.
/// Log settings part of the main window: retention, file size rollover, kept segments and debug detail for the shared diagnostic log, and
/// clearing it. The values are applied by the service, which owns the log files, so the service and companion always share one policy.
/// </summary>
public sealed partial class MainWindow
{
/// <summary>
/// Shows the current retention and daily size limits (with how much of today's limit is used and whether it was reached) in a dialog and,
/// if the operator saves valid numbers, sends them to the service. Rotation happens at the start of the next local day.
/// Shows the current retention, file size limit, kept segments and debug switch (with the current file's size and how many times it rolled
/// over today) in a dialog and, if the operator saves valid values, sends them to the service. Files rotate at the start of the next local
/// day and whenever one reaches the size limit.
/// </summary>
private async void LogSettings_Click(object sender, RoutedEventArgs e)
{
@@ -30,22 +31,28 @@ public sealed partial class MainWindow
return;
}
NumberBox retention = new() { Header = "Retention (days)", Minimum = 1, Maximum = 365, Value = settings.RetentionDays, SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Inline };
NumberBox limit = new() { Header = "Daily size limit (MiB)", Minimum = 1, Maximum = 100, Value = settings.MaxDailyMegabytes, SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Inline };
NumberBox limit = new() { Header = "File size limit before rollover (MiB)", Minimum = 1, Maximum = 1024, Value = settings.MaxFileMegabytes, SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Inline };
NumberBox segments = new() { Header = "Rolled-over segments kept per day", Minimum = 1, Maximum = 100, Value = settings.SegmentsPerDay, SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Inline };
ToggleSwitch debug = new() { Header = "Debug logging", OnContent = "On: every request, response and frame is logged", OffContent = "Off: steps, warnings and errors only", IsOn = settings.DebugLogging };
StackPanel content = new() { Spacing = 12 };
content.Children.Add(new TextBlock { Text = "Rotation: daily, at the start of the next local calendar day. These limits apply to both service and companion logs.", TextWrapping = TextWrapping.Wrap });
content.Children.Add(new TextBlock { Text = "Logs start a new file each local day and roll over to a numbered segment when a file reaches the size limit; logging never stops because of size. These settings apply to both service and companion logs.", TextWrapping = TextWrapping.Wrap });
content.Children.Add(retention);
content.Children.Add(limit);
string usage = $"Current daily log: {settings.CurrentDayBytes / (1024d * 1024d):0.0} MiB.";
content.Children.Add(new TextBlock { Text = usage + (settings.DailyLimitReached ? " The daily limit has been reached. Increase the limit, clear local logs, or wait until the next local day to resume diagnostic logging." : ""), TextWrapping = TextWrapping.Wrap });
content.Children.Add(segments);
content.Children.Add(debug);
content.Children.Add(new TextBlock { Text = "Debug logging adds request routes, status codes, timings and realtime frames. Credentials, documents and enrollment codes are never logged at any level.", TextWrapping = TextWrapping.Wrap, Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["TextFillColorSecondaryBrush"] });
string usage = $"Current log file: {settings.CurrentFileBytes / (1024d * 1024d):0.0} MiB; rolled over {settings.SegmentsToday} time(s) today.";
content.Children.Add(new TextBlock { Text = usage, TextWrapping = TextWrapping.Wrap });
var dialog = new ContentDialog { Title = "Log settings", Content = content, PrimaryButtonText = "Save", CloseButtonText = "Cancel", DefaultButton = ContentDialogButton.Primary, XamlRoot = Content.XamlRoot };
if(await DialogGuard.ShowAsync(dialog) == ContentDialogResult.Primary)
{
if(double.IsNaN(retention.Value) || double.IsNaN(limit.Value))
if(double.IsNaN(retention.Value) || double.IsNaN(limit.Value) || double.IsNaN(segments.Value))
{
Feedback("Log settings not saved", "Enter valid retention and daily size values.", InfoBarSeverity.Warning);
Feedback("Log settings not saved", "Enter valid retention, file size and segment values.", InfoBarSeverity.Warning);
return;
}
await RunActionAsync(new PipeRequest("set-log-settings", LogSettings: new LogSettings((int)retention.Value, (int)limit.Value)));
App.Log.Info($"[Companion] Log settings saved: retention {(int)retention.Value} days, file size {(int)limit.Value} MiB, {(int)segments.Value} segments per day, debug logging {(debug.IsOn ? "on" : "off")}.");
await RunActionAsync(new PipeRequest("set-log-settings", LogSettings: new LogSettings((int)retention.Value, (int)limit.Value, (int)segments.Value, debug.IsOn)));
}
}
+3 -3
View File
@@ -75,7 +75,7 @@
<Button Content="Open activity log" Click="ActivityLog_Click" AutomationProperties.AutomationId="ActivityLogMenuItem"><Button.Style><StaticResource ResourceKey="AccentButtonStyle" /></Button.Style></Button>
<TextBlock Text="Follow live updates, show errors only, copy entries or open the logs folder using the viewer’s toolbar." TextWrapping="Wrap" Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
<StackPanel Orientation="Horizontal" Spacing="8"><Button Content="Log settings" Click="LogSettings_Click" AutomationProperties.AutomationId="LogSettings" /><Button Content="Clear local logs" Click="ClearLogs_Click" AutomationProperties.AutomationId="ClearLogs" /></StackPanel>
<TextBlock Text="Logs rotate daily with bounded size and retention. Clearing diagnostic logs does not remove job history." TextWrapping="Wrap" />
<TextBlock Text="Logs rotate daily and roll over to numbered segments at the size limit, with bounded retention. Turn on debug logging in Log settings to record every request and realtime frame. Clearing diagnostic logs does not remove job history." TextWrapping="Wrap" />
</StackPanel>
<ScrollViewer x:Name="SettingsPage" Visibility="Collapsed" VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="24" MaxWidth="680" HorizontalAlignment="Left">
@@ -88,7 +88,7 @@
<HyperlinkButton Content="Get the installer" NavigateUri="https://github.com/QRun-IO/qqq-print-agent/releases" Padding="0" />
</StackPanel>
<StackPanel Spacing="10"><TextBlock Text="Server connection" Style="{ThemeResource SubtitleTextBlockStyle}" /><TextBlock x:Name="StatusText" Text="Waiting for service status" TextWrapping="Wrap" AutomationProperties.AutomationId="ServiceStatus" /><TextBlock x:Name="ConnectionText" Text="Connect this computer to your print server." TextWrapping="Wrap" IsTextSelectionEnabled="True" Foreground="{ThemeResource TextFillColorSecondaryBrush}" /><TextBlock x:Name="AuthenticationText" Text="Sign in to your account to enable printing." TextWrapping="Wrap" AutomationProperties.AutomationId="QqqAuthenticationStatus" /><StackPanel Orientation="Horizontal" Spacing="8"><Button x:Name="ConnectionButton" Content="Sign in / Connection settings" Click="Connection_Click" AutomationProperties.AutomationId="ConnectionSettings" /><Button Content="Sign out" Click="SignOut_Click" AutomationProperties.AutomationId="QqqSignOut" /></StackPanel></StackPanel>
<StackPanel Spacing="10"><TextBlock Text="Diagnostic logs" Style="{ThemeResource SubtitleTextBlockStyle}" /><TextBlock Text="Service and companion logs rotate daily. Set retention and the daily size limit, inspect usage, or clear retained diagnostic logs." TextWrapping="Wrap" /><StackPanel Orientation="Horizontal" Spacing="8"><Button Content="Log settings" Click="LogSettings_Click" AutomationProperties.AutomationId="SettingsLogSettings" /><Button Content="Clear local logs" Click="ClearLogs_Click" AutomationProperties.AutomationId="SettingsClearLogs" /></StackPanel></StackPanel>
<StackPanel Spacing="10"><TextBlock Text="Diagnostic logs" Style="{ThemeResource SubtitleTextBlockStyle}" /><TextBlock Text="Service and companion logs rotate daily and roll over at a size limit. Set retention, the file size limit, kept segments and debug logging, inspect usage, or clear retained diagnostic logs." TextWrapping="Wrap" /><StackPanel Orientation="Horizontal" Spacing="8"><Button Content="Log settings" Click="LogSettings_Click" AutomationProperties.AutomationId="SettingsLogSettings" /><Button Content="Clear local logs" Click="ClearLogs_Click" AutomationProperties.AutomationId="SettingsClearLogs" /></StackPanel></StackPanel>
<StackPanel Spacing="12"><TextBlock Text="Appearance" Style="{ThemeResource SubtitleTextBlockStyle}" /><ComboBox x:Name="ThemeChoice" Header="App theme" MinWidth="220" SelectionChanged="Theme_SelectionChanged"><ComboBoxItem Content="Use Windows setting" Tag="System" /><ComboBoxItem Content="Light" Tag="Light" /><ComboBoxItem Content="Dark" Tag="Dark" AutomationProperties.AutomationId="ThemeDark" /></ComboBox><ToggleSwitch x:Name="SplashToggle" Header="Show splash screen at startup" Toggled="Splash_Toggled" AutomationProperties.AutomationId="SplashAtStartup" /><NumberBox x:Name="SplashDuration" Header="Splash duration (milliseconds)" SpinButtonPlacementMode="Inline" SmallChange="500" LargeChange="1000" ValueChanged="SplashDuration_ValueChanged" AutomationProperties.AutomationId="SplashDuration" MinWidth="220" HorizontalAlignment="Left" /></StackPanel>
<StackPanel Spacing="10">
<TextBlock Text="Updates" Style="{ThemeResource SubtitleTextBlockStyle}" />
@@ -126,7 +126,7 @@
</Grid>
</Border>
<ContentDialog x:Name="ConnectionDialog" Title="Connection settings" CloseButtonText="Close" Closing="ConnectionDialog_Closing">
<ScrollViewer VerticalScrollBarVisibility="Auto" MaxHeight="650"><StackPanel Spacing="12"><TextBlock Text="Sign in using this server's configured authentication. Printing uses your current permissions. Existing pairing and history are preserved." TextWrapping="Wrap" /><TextBox x:Name="ServerBox" Header="Server address" PlaceholderText="https://print.example.com" AutomationProperties.AutomationId="ServerAddress" /><ComboBox x:Name="PairingChoice" Header="Device pairing for this sign-in" SelectedIndex="0" HorizontalAlignment="Stretch" AutomationProperties.AutomationId="PairingChoice"><ComboBoxItem Content="Reuse the saved pairing" /><ComboBoxItem Content="Associate the existing pairing with my account" /><ComboBoxItem Content="Set up a new pairing" /></ComboBox><TextBlock Text="For revoked or replaced devices, choose Set up a new pairing before signing in. Printing stays paused until a new enrollment succeeds; existing history and printer choices are preserved." TextWrapping="Wrap" /><Button Content="Discover sign-in options" Click="DiscoverSignIn_Click" AutomationProperties.AutomationId="DiscoverQqqSignIn" /><StackPanel x:Name="PasswordSignInPanel" Spacing="8" Visibility="Collapsed"><TextBox x:Name="UsernameBox" Header="Username" AutomationProperties.AutomationId="QqqUsername" /><PasswordBox x:Name="QqqPasswordBox" Header="Password" AutomationProperties.AutomationId="QqqPassword" /><Button Content="Sign in" Click="PasswordSignIn_Click" AutomationProperties.AutomationId="QqqPasswordSignIn" /></StackPanel><Button x:Name="BrowserSignInButton" Content="Sign in with your browser" Visibility="Collapsed" Click="BrowserSignIn_Click" AutomationProperties.AutomationId="QqqBrowserSignIn" /><Button x:Name="CancelBrowserSignInButton" Content="Cancel browser sign-in" Visibility="Collapsed" Click="CancelBrowserSignIn_Click" AutomationProperties.AutomationId="CancelQqqSignIn" /><TextBlock Text="For a new connection, after signing in, create a single-use enrollment code on the server: Printing → Print devices → Actions → Enroll a print device." TextWrapping="Wrap" /><PasswordBox x:Name="EnrollmentBox" Header="Enrollment code (new pairing only)" PlaceholderText="Paste the code from the server" AutomationProperties.AutomationId="EnrollmentCode" /><Button x:Name="EnrollButton" Content="Connect this device" Click="Enroll_Click" Style="{ThemeResource AccentButtonStyle}" AutomationProperties.AutomationId="EnrollDevice" /><InfoBar x:Name="ConnectionFeedback" IsOpen="False" IsClosable="False" /></StackPanel></ScrollViewer>
<ScrollViewer VerticalScrollBarVisibility="Auto" MaxHeight="650"><StackPanel Spacing="12"><TextBlock Text="Sign in using this server's configured authentication. Printing uses your current permissions. Existing pairing and history are preserved." TextWrapping="Wrap" /><TextBox x:Name="ServerBox" Header="Server address" PlaceholderText="https://print.example.com" AutomationProperties.AutomationId="ServerAddress" /><CheckBox x:Name="AllowUntrustedCertificateBox" Content="Allow untrusted server certificate for this server (development only)" Click="AllowUntrustedCertificate_Click" AutomationProperties.AutomationId="AllowUntrustedCertificate" /><TextBlock Text="Enter the server address first; the exception is saved for that one server only and never applies to another. Use it for a development server with a self-signed or private-authority certificate. The service runs as LocalSystem and trusts only the machine certificate store, so a certificate your user account trusts can still be refused by the service. With this on, any certificate from that server is accepted and every session is logged with a warning." TextWrapping="Wrap" Foreground="{ThemeResource TextFillColorSecondaryBrush}" /><ComboBox x:Name="PairingChoice" Header="Device pairing for this sign-in" SelectedIndex="0" HorizontalAlignment="Stretch" AutomationProperties.AutomationId="PairingChoice"><ComboBoxItem Content="Reuse the saved pairing" /><ComboBoxItem Content="Associate the existing pairing with my account" /><ComboBoxItem Content="Set up a new pairing" /></ComboBox><TextBlock Text="For revoked or replaced devices, choose Set up a new pairing before signing in. Printing stays paused until a new enrollment succeeds; existing history and printer choices are preserved." TextWrapping="Wrap" /><Button Content="Discover sign-in options" Click="DiscoverSignIn_Click" AutomationProperties.AutomationId="DiscoverQqqSignIn" /><StackPanel x:Name="PasswordSignInPanel" Spacing="8" Visibility="Collapsed"><TextBox x:Name="UsernameBox" Header="Username" AutomationProperties.AutomationId="QqqUsername" /><PasswordBox x:Name="QqqPasswordBox" Header="Password" AutomationProperties.AutomationId="QqqPassword" /><Button Content="Sign in" Click="PasswordSignIn_Click" AutomationProperties.AutomationId="QqqPasswordSignIn" /></StackPanel><Button x:Name="BrowserSignInButton" Content="Sign in with your browser" Visibility="Collapsed" Click="BrowserSignIn_Click" AutomationProperties.AutomationId="QqqBrowserSignIn" /><Button x:Name="CancelBrowserSignInButton" Content="Cancel browser sign-in" Visibility="Collapsed" Click="CancelBrowserSignIn_Click" AutomationProperties.AutomationId="CancelQqqSignIn" /><TextBlock Text="For a new connection, after signing in, create a single-use enrollment code on the server: Printing → Print devices → Actions → Enroll a print device." TextWrapping="Wrap" /><PasswordBox x:Name="EnrollmentBox" Header="Enrollment code (new pairing only)" PlaceholderText="Paste the code from the server" AutomationProperties.AutomationId="EnrollmentCode" /><Button x:Name="EnrollButton" Content="Connect this device" Click="Enroll_Click" Style="{ThemeResource AccentButtonStyle}" AutomationProperties.AutomationId="EnrollDevice" /><InfoBar x:Name="ConnectionFeedback" IsOpen="False" IsClosable="False" /></StackPanel></ScrollViewer>
</ContentDialog>
</Grid>
</Window>
+1
View File
@@ -229,6 +229,7 @@ public sealed partial class MainWindow : Window, IDisposable
private async Task ShowConnectionAsync()
{
ConnectionDialog.XamlRoot = Content.XamlRoot;
await LoadServerTrustAsync();
await DialogGuard.ShowAsync(ConnectionDialog);
}
@@ -19,7 +19,7 @@ public sealed class AgentActivityLogTests
string directory = Path.Combine(Path.GetTempPath(), "qqq-print-log-tests", Guid.NewGuid().ToString("N"));
try
{
IActivityLog log = AgentActivityLog.Create(directory);
var log = AgentActivityLog.Create(directory);
log.Info("[Companion] Authorization: Bearer synthetic-bearer deviceToken=synthetic-device enrollmentCode=\"synthetic-code\" https://operator:synthetic-password@example.invalid/");
log.Error("[Service] Enrollment failed", new InvalidOperationException("password='synthetic-secret' contentBase64=synthetic-document"));
string text = File.ReadAllText(log.CurrentLogFilePath);
@@ -50,8 +50,8 @@ public sealed class AgentActivityLogTests
string directory = Path.Combine(Path.GetTempPath(), "qqq-print-log-tests", Guid.NewGuid().ToString("N"));
try
{
IActivityLog service = AgentActivityLog.Create(directory);
IActivityLog companion = AgentActivityLog.Create(directory);
var service = AgentActivityLog.Create(directory);
var companion = AgentActivityLog.Create(directory);
Assert.Equal(service.CurrentLogFilePath, companion.CurrentLogFilePath);
await Task.WhenAll(
Task.Run(() =>
@@ -0,0 +1,165 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Globalization;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// The activity log must never go quiet because of size: a file that reaches its limit rolls over to a numbered segment of the
/// same day and logging continues, only the newest segments of a day are kept, debug entries appear only while the saved
/// setting asks for them, and the debug switch round-trips through the settings file both processes share.
/// </summary>
public sealed class AgentLogRotationTests
{
/// <summary>At a 1 MiB limit, writing past the limit renames the live file to segment 1 and the next entry lands in a fresh live file with a rollover notice; nothing is dropped.</summary>
[Fact]
public void ReachingTheFileLimit_RollsOverToANumberedSegmentAndKeepsLogging()
{
string directory = Path.Combine(Path.GetTempPath(), "qqq-log-rotate-" + Guid.NewGuid().ToString("N"));
try
{
AgentActivityLog.SaveSettings(directory, new LogSettings(30, 1, 10));
var log = AgentActivityLog.Create(directory);
string payload = new('x', 8192);
for(int index = 0; index < 140; index++)
{
log.Info("entry " + index.ToString(CultureInfo.InvariantCulture) + " " + payload);
}
log.Info("after rollover");
string today = DateTime.Today.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
string segment = Path.Combine(log.LogDirectory, "qqq-print-agent-" + today + ".1.log");
Assert.True(File.Exists(segment), "The full file should have been renamed to segment 1.");
Assert.InRange(new FileInfo(segment).Length, 900_000, 1024 * 1024);
string live = File.ReadAllText(log.CurrentLogFilePath);
Assert.Contains("Rolled over", live, StringComparison.Ordinal);
Assert.Contains("after rollover", live, StringComparison.Ordinal);
LogSettings settings = AgentActivityLog.GetSettings(directory);
Assert.Equal(1, settings.SegmentsToday);
Assert.InRange(settings.CurrentFileBytes, 1, 1024 * 1024);
int lines = File.ReadLines(segment).Count() + File.ReadLines(log.CurrentLogFilePath).Count();
Assert.Equal(142, lines);
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
/// <summary>With two segments kept per day, a third rollover deletes segment 1; segments 2 and 3 and the live file remain, and Clear removes them all.</summary>
[Fact]
public void SegmentsPerDay_BoundsHowManyRolledSegmentsSurviveAndClearRemovesThem()
{
string directory = Path.Combine(Path.GetTempPath(), "qqq-log-segments-" + Guid.NewGuid().ToString("N"));
try
{
AgentActivityLog.SaveSettings(directory, new LogSettings(30, 1, 2));
var log = AgentActivityLog.Create(directory);
string payload = new('y', 8192);
for(int index = 0; index < 3 * 135; index++)
{
log.Info(payload);
}
string today = DateTime.Today.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
Assert.False(File.Exists(Path.Combine(log.LogDirectory, "qqq-print-agent-" + today + ".1.log")));
Assert.True(File.Exists(Path.Combine(log.LogDirectory, "qqq-print-agent-" + today + ".2.log")));
Assert.True(File.Exists(Path.Combine(log.LogDirectory, "qqq-print-agent-" + today + ".3.log")));
Assert.Equal(2, AgentActivityLog.GetSettings(directory).SegmentsToday);
Assert.Equal(3, AgentActivityLog.ClearLogs(directory));
Assert.Empty(Directory.EnumerateFiles(log.LogDirectory, "qqq-print-agent-*.log"));
log.Info("fresh");
Assert.Contains("fresh", File.ReadAllText(log.CurrentLogFilePath), StringComparison.Ordinal);
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
/// <summary>Retention removes a whole old day including its rolled segments, and leaves a file whose name merely resembles a segment alone.</summary>
[Fact]
public void Retention_RemovesOldDaysWithTheirSegmentsOnly()
{
string directory = Path.Combine(Path.GetTempPath(), "qqq-log-segment-retention-" + Guid.NewGuid().ToString("N"));
try
{
var log = AgentActivityLog.Create(directory);
log.Info("current");
string oldDate = DateTime.Today.AddDays(-10).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
string oldLive = Path.Combine(log.LogDirectory, "qqq-print-agent-" + oldDate + ".log");
string oldSegment = Path.Combine(log.LogDirectory, "qqq-print-agent-" + oldDate + ".4.log");
string lookalike = Path.Combine(log.LogDirectory, "qqq-print-agent-" + oldDate + ".x.log");
foreach(string path in new[] { oldLive, oldSegment, lookalike })
{
File.WriteAllText(path, "evidence");
}
AgentActivityLog.SaveSettings(directory, new LogSettings(7, 100, 10));
Assert.False(File.Exists(oldLive));
Assert.False(File.Exists(oldSegment));
Assert.Equal("evidence", File.ReadAllText(lookalike));
Assert.True(File.Exists(log.CurrentLogFilePath));
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
/// <summary>Debug entries are dropped while the switch is off and written, tagged, once it is on; the switch is saved with the other settings and read back by a second log instance.</summary>
[Fact]
public void DebugEntries_AreWrittenOnlyWhileTheSavedSwitchIsOn()
{
string directory = Path.Combine(Path.GetTempPath(), "qqq-log-debug-" + Guid.NewGuid().ToString("N"));
try
{
var log = AgentActivityLog.Create(directory);
Assert.False(log.IsDebugEnabled);
log.Debug("hidden detail");
log.Info("visible step");
Assert.DoesNotContain("hidden detail", File.ReadAllText(log.CurrentLogFilePath), StringComparison.Ordinal);
AgentActivityLog.SaveSettings(directory, new LogSettings(30, 100, 10, DebugLogging: true));
Assert.True(AgentActivityLog.GetSettings(directory).DebugLogging);
var second = AgentActivityLog.Create(directory);
Assert.True(second.IsDebugEnabled);
second.Debug("shown detail token=secret-value");
string text = File.ReadAllText(second.CurrentLogFilePath);
Assert.Contains("[DEBUG] shown detail token=[REDACTED]", text, StringComparison.Ordinal);
Assert.DoesNotContain("secret-value", text, StringComparison.Ordinal);
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
/// <summary>A file size above 1024 MiB or a segment count outside 1 to 100 is refused and the saved policy is unchanged.</summary>
[Theory]
[InlineData(30, 1025, 10)]
[InlineData(30, 100, 0)]
[InlineData(30, 100, 101)]
public void InvalidRolloverSettings_AreRefused(int days, int megabytes, int segments)
{
string directory = Path.Combine(Path.GetTempPath(), "qqq-log-rollover-validation-" + Guid.NewGuid().ToString("N"));
try
{
AgentActivityLog.SaveSettings(directory, new LogSettings());
Assert.Throws<ArgumentOutOfRangeException>(() => AgentActivityLog.SaveSettings(directory, new LogSettings(days, megabytes, segments)));
Assert.Equal(new LogSettings(), AgentActivityLog.GetSettings(directory));
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
}
@@ -4,7 +4,6 @@
*/
using System.Globalization;
using KofTwentyTwo.AppKit.Logging;
using Qqq.PrintAgent.Windows;
using Xunit.Abstractions;
@@ -13,14 +12,14 @@ namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>The activity log must be bounded and safe to manage: a normal day of heavy printing fits the default budget, retention and clearing touch only the agent's own daily logs, concurrent writers share one size cap, and invalid settings are refused.</summary>
public sealed class AgentLogSettingsTests(ITestOutputHelper output)
{
/// <summary>Ten thousand jobs with three status lines each (30,000 entries) stay under the default 20 MiB daily limit without tripping it, so a very busy day is not silently cut off from the log.</summary>
/// <summary>Ten thousand jobs with three status lines each (30,000 entries) fit in one file under the default 100 MiB limit, so a very busy day does not even roll over.</summary>
[Fact]
public void TenThousandJobs_ThreeStatusEntriesEachFitDefaultDailyBudget()
{
string directory = Path.Combine(Path.GetTempPath(), "qqq-log-day-" + Guid.NewGuid().ToString("N"));
try
{
IActivityLog log = AgentActivityLog.Create(directory);
var log = AgentActivityLog.Create(directory);
long started = System.Diagnostics.Stopwatch.GetTimestamp();
for(int index = 0; index < 10000; index++)
{
@@ -30,10 +29,10 @@ public sealed class AgentLogSettingsTests(ITestOutputHelper output)
log.Info("[Service] job-" + id + ": SPOOLED");
}
LogSettings settings = AgentActivityLog.GetSettings(directory);
Assert.False(settings.DailyLimitReached);
Assert.Equal(0, settings.SegmentsToday);
Assert.Equal(30000, File.ReadLines(log.CurrentLogFilePath).Count());
Assert.InRange(settings.CurrentDayBytes, 1, 20 * 1024 * 1024);
output.WriteLine($"10k jobs / 30k diagnostic entries: {settings.CurrentDayBytes} bytes, {System.Diagnostics.Stopwatch.GetElapsedTime(started).TotalMilliseconds:F0} ms.");
Assert.InRange(settings.CurrentFileBytes, 1, 100 * 1024 * 1024);
output.WriteLine($"10k jobs / 30k diagnostic entries: {settings.CurrentFileBytes} bytes, {System.Diagnostics.Stopwatch.GetElapsedTime(started).TotalMilliseconds:F0} ms.");
}
finally
{
@@ -54,7 +53,7 @@ public sealed class AgentLogSettingsTests(ITestOutputHelper output)
string directory = Path.Combine(Path.GetTempPath(), "qqq-log-settings-" + Guid.NewGuid().ToString("N"));
try
{
IActivityLog log = AgentActivityLog.Create(directory);
var log = AgentActivityLog.Create(directory);
log.Info("Current diagnostic");
string oldDate = DateTime.Today.AddDays(-10).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
string oldLog = Path.Combine(log.LogDirectory, "qqq-print-agent-" + oldDate + ".log");
@@ -66,14 +65,14 @@ public sealed class AgentLogSettingsTests(ITestOutputHelper output)
File.WriteAllText(path, "preserved evidence");
}
AgentActivityLog.SaveSettings(directory, new LogSettings(7, 1));
Assert.Equal(new LogSettings(7, 1), AgentActivityLog.GetSettings(directory) with { CurrentDayBytes = 0 });
Assert.Equal(new LogSettings(7, 1), AgentActivityLog.GetSettings(directory) with { CurrentFileBytes = 0 });
Assert.False(File.Exists(oldLog));
Assert.Equal(1, AgentActivityLog.ClearLogs(directory));
foreach(string path in new[] { unrelated, misleading, journal })
{
Assert.Equal("preserved evidence", File.ReadAllText(path));
}
Assert.Equal(new LogSettings(7, 1), AgentActivityLog.GetSettings(directory) with { CurrentDayBytes = 0 });
Assert.Equal(new LogSettings(7, 1), AgentActivityLog.GetSettings(directory) with { CurrentFileBytes = 0 });
log.Info("Logging resumes after explicit clear.");
Assert.Contains("Logging resumes", File.ReadAllText(log.CurrentLogFilePath), StringComparison.Ordinal);
}
@@ -85,26 +84,25 @@ public sealed class AgentLogSettingsTests(ITestOutputHelper output)
/// <summary>Two writers sharing one directory together stop at the 1 MiB daily cap (with a limit-reached notice in the file) and further writes are dropped; clearing the logs lifts the cap and both writers can log again.</summary>
/// <summary>Two writers sharing one directory and a 1 MiB limit roll the file over instead of stopping: every entry from both writers is kept across the live file and its segments, and nothing is ever suppressed.</summary>
[Fact]
public async Task SharedWriters_RespectDailySizeLimitAndContinueAfterClear()
public async Task SharedWriters_RollOverAtTheSizeLimitAndLoseNothing()
{
string directory = Path.Combine(Path.GetTempPath(), "qqq-log-cap-" + Guid.NewGuid().ToString("N"));
try
{
AgentActivityLog.SaveSettings(directory, new LogSettings(30, 1));
IActivityLog first = AgentActivityLog.Create(directory);
IActivityLog second = AgentActivityLog.Create(directory);
AgentActivityLog.SaveSettings(directory, new LogSettings(30, 1, 10));
var first = AgentActivityLog.Create(directory);
var second = AgentActivityLog.Create(directory);
string payload = new('x', 8192);
await Task.WhenAll(Task.Run(() => WriteMany(first, payload)), Task.Run(() => WriteMany(second, payload)));
long length = new FileInfo(first.CurrentLogFilePath).Length;
Assert.InRange(length, 900000, 1024 * 1024);
Assert.True(AgentActivityLog.GetSettings(directory).DailyLimitReached);
Assert.Contains("Daily diagnostic limit reached", File.ReadAllText(first.CurrentLogFilePath), StringComparison.Ordinal);
first.Info(payload);
Assert.Equal(length, new FileInfo(first.CurrentLogFilePath).Length);
Assert.Equal(1, AgentActivityLog.ClearLogs(directory));
Assert.False(AgentActivityLog.GetSettings(directory).DailyLimitReached);
LogSettings settings = AgentActivityLog.GetSettings(directory);
Assert.InRange(settings.SegmentsToday, 1, 3);
Assert.InRange(new FileInfo(first.CurrentLogFilePath).Length, 1, 1024 * 1024);
int entries = Directory.EnumerateFiles(first.LogDirectory, "qqq-print-agent-*.log").Sum(path => File.ReadLines(path).Count(line => line.Contains("xxxx", StringComparison.Ordinal)));
Assert.Equal(200, entries);
Assert.InRange(AgentActivityLog.ClearLogs(directory), 2, 4);
Assert.Equal(0, AgentActivityLog.GetSettings(directory).SegmentsToday);
second.Error("After clear");
Assert.Contains("[ERROR] After clear", File.ReadAllText(second.CurrentLogFilePath), StringComparison.Ordinal);
}
@@ -116,12 +114,12 @@ public sealed class AgentLogSettingsTests(ITestOutputHelper output)
/// <summary>Retention outside 1 to 365 days or a size cap outside 1 to 100 MiB is rejected, and the settings already saved remain in force.</summary>
/// <summary>Retention outside 1 to 365 days or a file size outside 1 to 1024 MiB is rejected, and the settings already saved remain in force.</summary>
[Theory]
[InlineData(0, 20)]
[InlineData(366, 20)]
[InlineData(30, 0)]
[InlineData(30, 101)]
[InlineData(30, 1025)]
public void InvalidSettings_DoNotChangePersistedPolicy(int days, int megabytes)
{
string directory = Path.Combine(Path.GetTempPath(), "qqq-log-validation-" + Guid.NewGuid().ToString("N"));
@@ -139,8 +137,8 @@ public sealed class AgentLogSettingsTests(ITestOutputHelper output)
/// <summary>Writes the same 8 KiB line 100 times, enough from two writers to pass the daily cap.</summary>
private static void WriteMany(IActivityLog log, string payload)
/// <summary>Writes the same 8 KiB line 100 times, enough from two writers to pass a 1 MiB limit.</summary>
private static void WriteMany(AgentActivityLog log, string payload)
{
for(int index = 0; index < 100; index++)
{
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>The administrator's certificate exception is refused by default, is bound to one server, persists across store instances, and can be turned off again.</summary>
public sealed class AgentServerTrustTests
{
/// <summary>A fresh store refuses untrusted certificates; a saved exception is read back by a new store instance and reverts when cleared.</summary>
[Fact]
public void ServerTrust_DefaultsToRefuseAndRoundTrips()
{
string directory = Path.Combine(Path.GetTempPath(), "qqq-trust-" + Guid.NewGuid().ToString("N"));
try
{
using(AgentStore store = new(directory))
{
Assert.False(store.LoadServerTrust().AllowUntrustedCertificate);
store.SaveServerTrust(new ServerTrust(AllowUntrustedCertificate: true, Origin: "https://127.0.0.1:18581"));
}
using(AgentStore store = new(directory, recoverUnreadableConfiguration: true))
{
ServerTrust trust = store.LoadServerTrust();
Assert.True(trust.AllowUntrustedCertificate);
Assert.Equal("https://127.0.0.1:18581", trust.Origin);
store.SaveServerTrust(new ServerTrust());
Assert.False(store.LoadServerTrust().AllowUntrustedCertificate);
}
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
/// <summary>The exception applies only to the exact scheme, host and port it was saved for (case-insensitively), never to another server, another port or a plain HTTP variant.</summary>
[Theory]
[InlineData("https://127.0.0.1:18581/", true)]
[InlineData("HTTPS://127.0.0.1:18581/qqq/v1/version", true)]
[InlineData("https://127.0.0.1:18590/", false)]
[InlineData("http://127.0.0.1:18581/", false)]
[InlineData("https://print.example.invalid/", false)]
public void ServerTrust_AppliesOnlyToTheSavedServer(string candidate, bool expected)
{
var trust = new ServerTrust(true, ServerTrust.Normalize(new Uri("https://127.0.0.1:18581/anything")));
Assert.Equal("https://127.0.0.1:18581", trust.Origin);
Assert.Equal(expected, trust.AppliesTo(new Uri(candidate)));
Assert.False(new ServerTrust().AppliesTo(new Uri(candidate)));
}
}
@@ -0,0 +1,93 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Net.Security;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Qqq.PrintAgent.Core.Authentication;
namespace Qqq.PrintAgent.Core.Tests;
/// <summary>
/// Certificate policy of the session client: a certificate Windows does not trust is refused by default with a logged reason and
/// remedy, accepted only when untrusted certificates were explicitly allowed (still logged, as a warning naming the certificate), and a
/// trusted one is accepted with its identity at debug level only. Failures are never silent.
/// </summary>
public sealed class QqqSessionCertificateTests
{
private static readonly Uri Origin = new("https://127.0.0.1:18581");
/// <summary>A self-signed certificate with chain errors is refused and the log names the error, the issuer, the LocalSystem store caveat and both remedies.</summary>
[Fact]
public void UntrustedCertificateIsRefusedByDefaultWithRemedy()
{
var info = new List<string>();
using X509Certificate2 certificate = SelfSigned("CN=Caddy Local Authority - Test");
using var client = new QqqSessionClient(Origin, diagnostics: new QqqSessionDiagnostics(info.Add));
Assert.False(client.AllowsUntrustedCertificate);
Assert.False(client.EvaluateCertificate("127.0.0.1:18581", certificate, SslPolicyErrors.RemoteCertificateChainErrors));
string rejection = Assert.Single(info, line => line.StartsWith("TLS certificate for 127.0.0.1:18581 rejected: RemoteCertificateChainErrors", StringComparison.Ordinal));
Assert.Contains("Caddy Local Authority - Test", rejection, StringComparison.Ordinal);
Assert.Contains("LocalSystem", rejection, StringComparison.Ordinal);
Assert.Contains("Local Machine Trusted Root", rejection, StringComparison.Ordinal);
Assert.Contains("Allow untrusted server certificate", rejection, StringComparison.Ordinal);
}
/// <summary>With untrusted certificates allowed, the same certificate is accepted and the acceptance is logged as an exception, never silently.</summary>
[Fact]
public void UntrustedCertificateIsAcceptedOnlyWhenAllowedAndLoudly()
{
var info = new List<string>();
using X509Certificate2 certificate = SelfSigned("CN=dev-server");
using var client = new QqqSessionClient(Origin, diagnostics: new QqqSessionDiagnostics(info.Add), allowUntrustedCertificate: true);
Assert.True(client.AllowsUntrustedCertificate);
Assert.Contains(info, line => line.StartsWith("WARNING: certificate validation is relaxed", StringComparison.Ordinal));
Assert.True(client.EvaluateCertificate("127.0.0.1:18581", certificate, SslPolicyErrors.RemoteCertificateChainErrors));
Assert.Contains(info, line => line.Contains("NOT trusted by Windows (RemoteCertificateChainErrors) but was accepted", StringComparison.Ordinal) && line.Contains("dev-server", StringComparison.Ordinal));
}
/// <summary>A certificate without policy errors is accepted; its identity is recorded only at debug level, so a healthy production server does not fill the log.</summary>
[Fact]
public void TrustedCertificateIsAcceptedQuietly()
{
var info = new List<string>();
var debug = new List<string>();
using X509Certificate2 certificate = SelfSigned("CN=print.example.invalid");
using var client = new QqqSessionClient(new Uri("https://print.example.invalid"), diagnostics: new QqqSessionDiagnostics(info.Add, debug.Add));
Assert.True(client.EvaluateCertificate("print.example.invalid", certificate, SslPolicyErrors.None));
Assert.DoesNotContain(info, line => line.Contains("TLS certificate", StringComparison.Ordinal));
Assert.Contains(debug, line => line.StartsWith("TLS certificate for print.example.invalid accepted by Windows", StringComparison.Ordinal));
}
/// <summary>Describe and Explain walk inner exceptions, so the real cause behind "see inner exception" reaches the log and the screen.</summary>
[Fact]
public void FailureDescriptionsIncludeInnerCauses()
{
var failure = new HttpRequestException("The SSL connection could not be established, see inner exception.",
new System.Security.Authentication.AuthenticationException("The remote certificate is invalid according to the validation procedure: RemoteCertificateChainErrors"));
Assert.Equal("HttpRequestException: The SSL connection could not be established, see inner exception. <- AuthenticationException: The remote certificate is invalid according to the validation procedure: RemoteCertificateChainErrors",
QqqSessionDiagnostics.Describe(failure));
Assert.Equal("The SSL connection could not be established, see inner exception. The remote certificate is invalid according to the validation procedure: RemoteCertificateChainErrors",
QqqSessionDiagnostics.Explain(failure));
}
/// <summary>A throwaway self-signed certificate for the policy tests; it is never installed anywhere.</summary>
private static X509Certificate2 SelfSigned(string subject)
{
using var key = ECDsa.Create();
var request = new CertificateRequest(subject, key, HashAlgorithmName.SHA256);
return request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1));
}
}
@@ -0,0 +1,147 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Net;
using System.Text;
using Qqq.PrintAgent.Core.Authentication;
namespace Qqq.PrintAgent.Core.Tests;
/// <summary>
/// The session client's progress reporting: every step of a sign-in is narrated, failures say which server-side requirement is
/// missing, request detail appears only while debug is on, and no entry at any level carries the password, the session token,
/// a device token or a browser code.
/// </summary>
public sealed class QqqSessionDiagnosticsTests
{
private static readonly Uri Origin = new("https://printing.example.invalid");
private const string Discovery = """{"apiVersion":"1.9.0","features":["version","metaData","processes","processCancel","extensions","widgets","nativeSessionLogin"],"clientChannels":["api"],"extensions":[{"name":"printing","version":"2.3.0","pathPrefix":"/qbit/printing"}]}""";
private const string Table = """{"name":"local","type":"TABLE_BASED"}""";
private const string Identity = """{"principalId":"user-1","tenantId":"tenant-1","deviceId":"device-1"}""";
/// <summary>A password sign-in followed by a device bind produces the step narrative at INFO, and request routes with status codes at DEBUG, without the password, session token or device token appearing anywhere.</summary>
[Fact]
public async Task SignInNarratesEachStepAndNeverLogsSecrets()
{
var info = new List<string>();
var debug = new List<string>();
bool debugOn = true;
using var handler = new Handler();
handler.Add(Discovery);
handler.Add(Table);
handler.Add("{}", cookie: "sessionUUID=session-secret-token; Path=/; Secure; HttpOnly");
handler.Add(Identity);
using var client = new QqqSessionClient(Origin, handler, diagnostics: new QqqSessionDiagnostics(info.Add, debug.Add, () => debugOn));
await client.SignInWithPasswordAsync("operator", "hunter2-password");
await client.BindDeviceAsync(new QqqDeviceCredential("device-1", "device-secret-token"));
Assert.Contains(info, line => line.StartsWith("Discovering QQQ at printing.example.invalid", StringComparison.Ordinal));
Assert.Contains(info, line => line.Contains("printing 2.3.0 at /qbit/printing", StringComparison.Ordinal));
Assert.Contains(info, line => line.StartsWith("Authentication metadata: type TABLE_BASED", StringComparison.Ordinal));
Assert.Contains("Password sign-in requested.", info);
Assert.Contains("Password sign-in accepted; a server-managed session is now held.", info);
Assert.Contains("Session identity: principal user-1, tenant tenant-1, device device-1.", info);
Assert.Contains("Device device-1 bound to principal user-1 in tenant tenant-1.", info);
Assert.Contains(debug, line => line.StartsWith("HTTP GET https://printing.example.invalid/qqq/v1/version", StringComparison.Ordinal));
Assert.Contains(debug, line => line.Contains("/qqq/v1/manageSession -> 200", StringComparison.Ordinal));
Assert.Contains(debug, line => line.Contains("basic credential yes", StringComparison.Ordinal));
foreach(string line in info.Concat(debug))
{
Assert.DoesNotContain("hunter2", line, StringComparison.Ordinal);
Assert.DoesNotContain("session-secret-token", line, StringComparison.Ordinal);
Assert.DoesNotContain("device-secret-token", line, StringComparison.Ordinal);
Assert.DoesNotContain("Basic ", line, StringComparison.Ordinal);
}
}
/// <summary>Debug messages are not even built while the host says debug is off, so a chatty client costs nothing in normal operation.</summary>
[Fact]
public async Task DebugDetailIsSkippedWhileDisabled()
{
var debug = new List<string>();
using var handler = new Handler();
handler.Add(Discovery);
using var client = new QqqSessionClient(Origin, handler, diagnostics: new QqqSessionDiagnostics(null, debug.Add, () => false));
_ = await client.DiscoverAsync();
Assert.Empty(debug);
}
/// <summary>A server without the printing extension is refused with the generic configuration failure, while the log names the missing extension, so the operator learns what to fix without the UI revealing server detail.</summary>
[Fact]
public async Task DiscoveryRejectionExplainsWhichRequirementIsMissing()
{
var info = new List<string>();
using var handler = new Handler();
handler.Add(Discovery.Replace("\"printing\"", "\"other\"", StringComparison.Ordinal).Replace("1.9.0", "1.7.0", StringComparison.Ordinal));
using var client = new QqqSessionClient(Origin, handler, diagnostics: new QqqSessionDiagnostics(info.Add));
QqqSessionException exception = await Assert.ThrowsAsync<QqqSessionException>(() => client.DiscoverAsync());
Assert.Equal(QqqSessionException.FailureKind.ConfigurationRequired, exception.Failure);
string rejection = Assert.Single(info, line => line.StartsWith("Discovery rejected:", StringComparison.Ordinal));
Assert.Contains("API version '1.7.0' is not a plain 1.8+ release", rejection, StringComparison.Ordinal);
Assert.Contains("no printing extension is advertised", rejection, StringComparison.Ordinal);
}
/// <summary>A 401 on an authenticated request is logged as the session being withdrawn, and a non-JSON body is logged as such, each before the generic failure is thrown.</summary>
[Fact]
public async Task ServerRefusalsAreExplainedInTheLog()
{
var info = new List<string>();
using var handler = new Handler();
handler.Add(Discovery);
handler.Add(Table);
handler.Add("{}", cookie: "sessionUUID=token-1; Path=/; Secure; HttpOnly");
handler.Add("", HttpStatusCode.Unauthorized);
using var client = new QqqSessionClient(Origin, handler, diagnostics: new QqqSessionDiagnostics(info.Add));
await client.SignInWithPasswordAsync("operator", "secret");
QqqSessionException withdrawn = await Assert.ThrowsAsync<QqqSessionException>(() => client.GetSessionIdentityAsync());
Assert.Equal(QqqSessionException.FailureKind.AuthenticationRequired, withdrawn.Failure);
Assert.Contains(info, line => line.StartsWith("The server answered 401 Unauthorized", StringComparison.Ordinal));
Assert.False(client.IsAuthenticated);
using var pageHandler = new Handler();
pageHandler.AddResponse(() => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("<html>sign in</html>", Encoding.UTF8, "text/html") });
using var pageClient = new QqqSessionClient(Origin, pageHandler, diagnostics: new QqqSessionDiagnostics(info.Add));
QqqSessionException invalid = await Assert.ThrowsAsync<QqqSessionException>(() => pageClient.DiscoverAsync());
Assert.Equal(QqqSessionException.FailureKind.InvalidResponse, invalid.Failure);
Assert.Contains(info, line => line.StartsWith("Response rejected: the body is not valid JSON", StringComparison.Ordinal) && line.Contains("text/html", StringComparison.Ordinal));
}
/// <summary>Scripted fake server answering queued replies in order.</summary>
private sealed class Handler : HttpMessageHandler
{
private readonly Queue<Func<HttpResponseMessage>> _responses = new();
/// <summary>Queues a JSON reply with the given status and optional cookie.</summary>
internal void Add(string body, HttpStatusCode status = HttpStatusCode.OK, string? cookie = null) => AddResponse(() =>
{
var response = new HttpResponseMessage(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
if(cookie is not null)
{ response.Headers.Add("Set-Cookie", cookie); }
return response;
});
/// <summary>Queues a reply built on demand.</summary>
internal void AddResponse(Func<HttpResponseMessage> response) => _responses.Enqueue(response);
/// <summary>Returns the next queued reply.</summary>
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => Task.FromResult(_responses.Dequeue()());
}
}