Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions .github/workflows/probe.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
name: Probe

on:
push:
branches: [main]
paths: ['tools/probe/**', '.github/workflows/probe.yml']
pull_request:
paths: ['tools/probe/**', '.github/workflows/probe.yml']

permissions:
contents: read

jobs:
test:
strategy:
fail-fast: false
matrix:
include:
# The client's target: Python 3.8, 32-bit, on Windows (DPAPI runs here).
- os: windows-latest
python: '3.8'
arch: x86
- os: ubuntu-22.04
python: '3.8'
arch: x64
- os: ubuntu-latest
python: '3.13'
arch: x64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
architecture: ${{ matrix.arch }}
- run: python -m pip install pytest
- run: python -m pytest -q tools/probe

build:
# The .exe to take to the Windows 7 PC. Running it here proves only that
# it works on the CI's Windows; the real check is on the target (S0-01).
needs: test
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.8'
architecture: x86
- name: Build
working-directory: tools/probe
run: |
python -m pip install "pyinstaller==5.13.2"
pyinstaller --noconfirm probe.spec
- name: Run the built probe against a local server
shell: bash
working-directory: tools/probe
env:
# Git Bash would otherwise turn "/CN=..." into a Windows path.
MSYS_NO_PATHCONV: '1'
run: |
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
-keyout probe-key.pem -out probe-cert.pem -subj "/CN=nestrack-probe"
python probe_server.py --host 127.0.0.1 --port 18443 --interval 0.2 > server.log 2>&1 &
sleep 3
FP=$(python -c "import probe_server; print(probe_server.fingerprint_of_pem('probe-cert.pem'))")
printf "[probe]\nurl = https://127.0.0.1:18443\nfingerprint = %s\nsse_seconds = 5\n" "$FP" > dist/probe.ini
./dist/nestrack-probe.exe
cat dist/probe-report.txt
- uses: actions/upload-artifact@v4
with:
name: nestrack-probe-win32
path: |
tools/probe/dist/nestrack-probe.exe
tools/probe/dist/probe-report.txt
tools/probe/probe.ini.example
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
__pycache__/
*.py[cod]
.pytest_cache/
build/
dist/
*.pem
probe.ini
probe-report.txt
10 changes: 10 additions & 0 deletions docs/FINDINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,13 @@ The code audit of finnpower-counter, which Nestrack grew out of, is in
| PLN-13 | 1C:Enterprise can publish a standard OData v3 interface per infobase; 1C teams also commonly pull from external HTTP APIs with their own scheduled jobs | The integration hub supports both: an OData connector and a generic event feed (S8-04, S8-08) |
| PLN-14 | NC files carry no order or job number (`CUSTOMER` and `Order ID` are empty in the files analysed) | ERP integration needs mapping tables maintained by the administrator (S8-03) |
| PLN-15 | The system needs the same `.nc`/`.fms` parsing that finnpower-counter has verified against 30 real programs and 8,764 setup reports | Nestrack depends on finnpower-counter's parser as a pinned package instead of copying it (X-01) |

---

## Windows 7 probe (S0-01) — 2026-10-03

| ID | Finding | Consequence |
|---|---|---|
| PRB-01 | The probe `.exe` (Python 3.8.10 32-bit, PyInstaller 5.13.2) passes all five checks on the CI's Windows (10.0 build 26100): TLS 1.3 with a pinned certificate, a wrong certificate refused, SSE through three forced reconnects with no gaps, DPAPI in user and machine scope | The approach works in a frozen build. **Not yet confirmed on Windows 7** — S0-01 stays open until a report from a real Windows 7 32-bit PC is recorded here |
| PRB-02 | The OpenSSL bundled with Python 3.8.10 is 1.1.1k from March 2021 — long out of support, as PLN-04 expected | The client talks only to its own server with a pinned certificate; nothing else on the network is trusted. The server's TLS settings must keep TLS 1.2 available for such clients |

4 changes: 4 additions & 0 deletions docs/TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ X-01 (in finnpower-counter) ──► S0-02

### S0-01 Windows 7 compatibility probe · L · gate

**Status.** In progress: the probe is in [`tools/probe/`](../tools/probe/)
— server, client, PyInstaller build, tests in CI on Windows / Python 3.8 x86.
Waiting for a run on a real Windows 7 32-bit PC.

**Goal.** Prove on a real shop-floor PC that the client can do everything the
architecture expects of it.

Expand Down
2 changes: 1 addition & 1 deletion docs/TEST_BENCH.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Not every task needs every node.

| Bench | Nodes | For tasks |
|---|---|---|
| **A — Probe** | DEV, a throw-away probe server, WS-W7 | S0-01 |
| **A — Probe** | DEV, the probe server from [`tools/probe/`](../tools/probe/), WS-W7 | S0-01 |
| **B — Minimal** | DEV, SRV-LIN (server only), WS-W7, BROWSER | Stages 1–4 |
| **C — Network** | B + FILES + NET + WS-W10 | Stage 3 offline and concurrency tests; stage 5 |
| **D — Directory** | B + AD | Stage 7 |
Expand Down
69 changes: 69 additions & 0 deletions tools/probe/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Windows 7 compatibility probe

Task **S0-01** in [docs/TASKS.md](../../docs/TASKS.md): before any server work,
prove that a client built like the real one — Python 3.8.10, 32-bit,
PyInstaller, standard library only — can do on a Windows 7 shop-floor PC what
the architecture relies on.

| Check | What it proves |
|---|---|
| `tls_pinned` | HTTPS with TLS 1.2 or newer to a server with a self-signed certificate, trusted by its pinned SHA-256 fingerprint |
| `tls_pin_rejects` | Any other certificate is refused before a request is sent |
| `sse` | A Server-Sent Events stream is read; after forced drops the client reconnects with `Last-Event-ID` and loses or repeats nothing |
| `dpapi_user`, `dpapi_machine` | Windows DPAPI protects and recovers a secret, user and machine scope |

The result is printed and written to `probe-report.txt` next to the program.

## Files

| File | Runs on | Purpose |
|---|---|---|
| `probe_server.py` | Any machine with Python 3.8+ — a VM, a laptop | HTTPS + SSE test server, standard library only |
| `make_cert.py` | Same machine | Self-signed certificate; needs `cryptography` (or use the OpenSSL one-liner inside) |
| `probe_client.py` | The shop-floor PC, as `nestrack-probe.exe` | The checks |
| `probe.spec` | Build machine | PyInstaller build of the client |
| `probe.ini.example` | Shop-floor PC | Settings template |
| `test_probe.py` | CI, developers | Runs every check against a local server |

## Running it

1. **Get the `.exe`.** Download the `nestrack-probe-win32` artifact from the
latest **Probe** workflow run, or build it on a Windows machine with
Python 3.8.10 32-bit:

```bash
pip install "pyinstaller==5.13.2"
pyinstaller probe.spec # -> dist/nestrack-probe.exe
```

2. **Start the server** on a machine the shop-floor PC can reach, with the
address the PC will use:

```bash
python make_cert.py --name 192.168.56.10
python probe_server.py --port 8443
```

It prints the certificate fingerprint. Allow the port through the firewall.

3. **On the Windows 7 PC**, put `nestrack-probe.exe` and a `probe.ini` (from
`probe.ini.example`, with the URL and fingerprint filled in) into one
folder and run the `.exe`. No Python installation is needed.

4. **Record the result.** Attach `probe-report.txt` to the S0-01 pull request
or issue and add the outcome to [docs/FINDINGS.md](../../docs/FINDINGS.md).
Run it on a current Windows as well, for comparison.

## If something fails

| Failure | Likely cause | Next step |
|---|---|---|
| The `.exe` does not start at all | PyInstaller bootloader or Python runtime not supported on this Windows 7 build (missing updates such as KB2533623 / the Universal C Runtime) | Install the missing updates; try building with the same PyInstaller version that builds finnpower-counter |
| `tls_pinned` fails with a handshake error | TLS settings or a proxy between the PC and the server | Check the server's address and port from the PC; look for an intercepting proxy |
| `sse` shows gaps or no reconnects | Something buffers or cuts long-lived HTTP responses | Repeat without proxies; note it — it affects the live-update design |
| `dpapi_*` fails | DPAPI unavailable for this account | Note the account type (local, domain, kiosk); the client's local secrets depend on it |

Any failure means revisiting [docs/ARCHITECTURE.md](../../docs/ARCHITECTURE.md)
before stage 1 starts.

Set `NESTRACK_PROBE_TRACEBACK=1` to include Python tracebacks in the report.
73 changes: 73 additions & 0 deletions tools/probe/make_cert.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Create a self-signed certificate for the probe server.

python make_cert.py --name 192.168.56.10 --name probe.local

Writes probe-cert.pem and probe-key.pem and prints the SHA-256 fingerprint
to put into the client's probe.ini. Needs the `cryptography` package on the
machine that runs the server (not on the shop-floor PC).

Without `cryptography`, OpenSSL does the same:

openssl req -x509 -newkey rsa:2048 -nodes -days 30 \\
-keyout probe-key.pem -out probe-cert.pem \\
-subj "/CN=nestrack-probe" -addext "subjectAltName=IP:192.168.56.10"

In Git Bash on Windows, prefix it with MSYS_NO_PATHCONV=1 — otherwise
"/CN=..." is rewritten into a Windows path.
"""

import argparse
import datetime
import ipaddress
import sys

from probe_server import fingerprint_of_pem


def make_cert(names, cert_path="probe-cert.pem", key_path="probe-key.pem",
days=30) -> str:
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID

key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "nestrack-probe")])
alt = []
for name in names:
try:
alt.append(x509.IPAddress(ipaddress.ip_address(name)))
except ValueError:
alt.append(x509.DNSName(name))
now = datetime.datetime.now(datetime.timezone.utc)
cert = (x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(subject)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(minutes=5))
.not_valid_after(now + datetime.timedelta(days=days))
.add_extension(x509.SubjectAlternativeName(alt), critical=False)
.sign(key, hashes.SHA256()))
with open(key_path, "wb") as fh:
fh.write(key.private_bytes(serialization.Encoding.PEM,
serialization.PrivateFormat.TraditionalOpenSSL,
serialization.NoEncryption()))
with open(cert_path, "wb") as fh:
fh.write(cert.public_bytes(serialization.Encoding.PEM))
return fingerprint_of_pem(cert_path)


def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--name", action="append", default=[],
help="IP address or host name the client will use; repeatable")
parser.add_argument("--days", type=int, default=30)
args = parser.parse_args(argv)
names = args.name or ["127.0.0.1", "localhost"]
print("Fingerprint (SHA-256): %s" % make_cert(names, days=args.days))
return 0


if __name__ == "__main__":
sys.exit(main())
11 changes: 11 additions & 0 deletions tools/probe/probe.ini.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
; Copy to probe.ini next to nestrack-probe.exe and fill in.

[probe]
; Address of the probe server, as the shop-floor PC sees it.
url = https://192.168.56.10:8443

; SHA-256 fingerprint printed by probe_server.py at start-up.
fingerprint = AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99

; How long to read the event stream, in seconds.
sse_seconds = 30
43 changes: 43 additions & 0 deletions tools/probe/probe.spec
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# -*- mode: python ; coding: utf-8 -*-
"""PyInstaller build of the probe client.

pyinstaller probe.spec

Build with the same Python as the real client: 3.8.10, 32-bit. PyInstaller
packs the interpreter it runs under, so the build environment decides what
ends up on the shop-floor PC.

A console program on purpose: the operator running the probe should see the
results as they happen.
"""

a = Analysis(
['probe_client.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
hookspath=[],
runtime_hooks=[],
# The real client needs these network modules, so the probe keeps them.
excludes=['tkinter', 'unittest', 'pydoc', 'doctest', 'pytest'],
noarchive=False,
)

pyz = PYZ(a.pure, a.zipped_data)

exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='nestrack-probe',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False, # UPX makes antivirus false positives worse
runtime_tmpdir=None,
console=True,
)
Loading
Loading