Compare commits

...

4 Commits

Author SHA1 Message Date
seb
6312eaec48 u 2026-07-30 15:52:55 +02:00
seb
3e8ea1ef76 u 2026-07-29 18:40:19 +02:00
seb
e9f29dc569 u 2026-07-27 02:18:07 +02:00
seb
10c4269da0 u 2026-07-27 02:16:26 +02:00
11 changed files with 179 additions and 37 deletions

View File

@@ -8,11 +8,6 @@ PAIRING_CODE=307018
LOG_FILE=logs/requests.log LOG_FILE=logs/requests.log
ORDER_LOG_FILE=logs/orders.log ORDER_LOG_FILE=logs/orders.log
# TLS certificate metadata returned during pairing
CERTIFICATE_FINGERPRINT=BC2114CF407A42724BEEF417960F76DCBF9DE879
CERTIFICATE_SERIAL_NUMBER=00BFC8BEACDB981B165210EF111CB9D3
SERVER_FINGERPRINT=39-6D-BD-DE-F3-5C-5A-EA-C2-19-CF-EB-A7-A9-58-2F-20-3F-20-F7-3D-E6-CA-8E-AE-FD-28-30-37-A6-45-AE
# Mandant # Mandant
MANDANT_ID=1 MANDANT_ID=1
MANDANT_NAME=eB-Standard MANDANT_NAME=eB-Standard

View File

@@ -1,5 +1,6 @@
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -12,10 +13,50 @@ const certPath = path.join(certsDir, 'cert.pem');
fs.mkdirSync(certsDir, { recursive: true }); fs.mkdirSync(certsDir, { recursive: true });
// ECDSA P-256 keeps pairing QR codes much smaller than RSA-2048 function isIp(value) {
const subject = '/CN=localhost/O=JTL POS Sync/C=DE'; return /^(?:\d{1,3}\.){3}\d{1,3}$/.test(value) || value.includes(':');
const san = 'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:0.0.0.0'; }
function localIpv4s() {
const ips = [];
for (const entries of Object.values(os.networkInterfaces())) {
for (const entry of entries || []) {
if (entry.family !== 'IPv4' || entry.internal) continue;
// Skip link-local / docker / libvirt bridge noise by default — keep LAN + extras via args
if (entry.address.startsWith('169.254.')) continue;
if (entry.address.startsWith('172.17.')) continue;
if (entry.address.startsWith('192.168.122.')) continue;
ips.push(entry.address);
}
}
return ips;
}
const dnsNames = new Set(['localhost']);
const ipAddrs = new Set(['127.0.0.1', '0.0.0.0']);
for (const ip of localIpv4s()) {
ipAddrs.add(ip);
}
const extras = [
...(process.env.CERT_SAN || '').split(/[,\s]+/).filter(Boolean),
...process.argv.slice(2),
];
for (const value of extras) {
if (isIp(value)) ipAddrs.add(value);
else dnsNames.add(value);
}
const sanParts = [
...[...dnsNames].map((name) => `DNS:${name}`),
...[...ipAddrs].map((ip) => `IP:${ip}`),
];
const san = `subjectAltName=${sanParts.join(',')}`;
const cn = [...dnsNames][0] || 'localhost';
const subject = `/CN=${cn}/O=JTL POS Sync/C=DE`;
// ECDSA P-256 keeps pairing QR codes much smaller than RSA-2048
execSync( execSync(
`openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -keyout "${keyPath}" -out "${certPath}" -days 3650 -subj "${subject}" -addext "${san}"`, `openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -keyout "${keyPath}" -out "${certPath}" -days 3650 -subj "${subject}" -addext "${san}"`,
{ stdio: 'inherit' } { stdio: 'inherit' }
@@ -34,6 +75,6 @@ const serial = execSync(`openssl x509 -in "${certPath}" -noout -serial`, {
logger.success(`Wrote ${keyPath}`); logger.success(`Wrote ${keyPath}`);
logger.success(`Wrote ${certPath}`); logger.success(`Wrote ${certPath}`);
logger.info(`CERTIFICATE_FINGERPRINT=${sha1.replace(/:/g, '')}`); logger.info(`SAN: ${sanParts.join(', ')}`);
logger.info(`CERTIFICATE_SERIAL_NUMBER=${serial}`); logger.info(`Fingerprint: ${sha1.replace(/:/g, '')}`);
logger.info(`SERVER_FINGERPRINT=${sha1.replace(/:/g, '-')}`); logger.info(`Serial: ${serial}`);

View File

@@ -79,17 +79,25 @@ The server starts without MSSQL if `MSSQL_USER` is unset or the connection fails
## TLS certificates ## TLS certificates
Place a certificate and key at `certs/cert.pem` and `certs/key.pem` (relative to the working directory when you run the binary). From the repo root (preferred — picks up LAN IPs automatically):
```bash
npm run cert
# optional extras:
npm run cert -- 192.168.188.22 sync.quixpos.com
```
Or manually:
```bash ```bash
mkdir -p certs mkdir -p certs
openssl req -x509 -newkey rsa:2048 -nodes \ openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
-keyout certs/key.pem -out certs/cert.pem -days 3650 \ -keyout certs/key.pem -out certs/cert.pem -days 3650 \
-subj '/CN=localhost/O=JTL POS Sync/C=DE' \ -subj '/CN=localhost/O=JTL POS Sync/C=DE' \
-addext 'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:0.0.0.0' -addext 'subjectAltName=DNS:localhost,DNS:sync.quixpos.com,IP:127.0.0.1,IP:0.0.0.0,IP:192.168.188.22'
``` ```
On startup the server prints the pairing code and whether MSSQL connected. Place `certs/cert.pem` and `certs/key.pem` relative to the working directory when you run the binary. The browser hostname check requires the address you open (`192.168.x.x` or a DNS name) to appear in the certificate SAN — trusting a CA alone is not enough.
## API endpoints ## API endpoints

View File

@@ -70,6 +70,12 @@ int64_t HttpRequest::get_query_int64(const std::string& key, int64_t def) const
// HttpResponse // HttpResponse
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
static const char* CORS_HEADERS =
"Access-Control-Allow-Origin: *\r\n"
"Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n"
"Access-Control-Allow-Headers: Content-Type, Authorization\r\n"
"Access-Control-Max-Age: 86400\r\n";
void HttpResponse::send_json(int code, const json& body) { void HttpResponse::send_json(int code, const json& body) {
if (headers_sent) return; if (headers_sent) return;
status_code = code; status_code = code;
@@ -80,6 +86,7 @@ void HttpResponse::send_json(int code, const json& body) {
"Content-Type: application/json; charset=utf-8\r\n" "Content-Type: application/json; charset=utf-8\r\n"
"Content-Length: " + std::to_string(body_str.size()) + "\r\n" "Content-Length: " + std::to_string(body_str.size()) + "\r\n"
"Connection: keep-alive\r\n" "Connection: keep-alive\r\n"
+ std::string(CORS_HEADERS) +
"\r\n" "\r\n"
+ body_str; + body_str;
@@ -95,6 +102,7 @@ void HttpResponse::send_binary(int code, const std::vector<uint8_t>& data, const
"Content-Type: " + content_type + "\r\n" "Content-Type: " + content_type + "\r\n"
"Content-Length: " + std::to_string(data.size()) + "\r\n" "Content-Length: " + std::to_string(data.size()) + "\r\n"
"Connection: keep-alive\r\n" "Connection: keep-alive\r\n"
+ std::string(CORS_HEADERS) +
"\r\n"; "\r\n";
session_write_binary(session, header, data); session_write_binary(session, header, data);
@@ -108,6 +116,7 @@ void HttpResponse::send_empty(int code) {
std::string resp = "HTTP/1.1 " + std::to_string(code) + " " + reason_phrase(code) + "\r\n" std::string resp = "HTTP/1.1 " + std::to_string(code) + " " + reason_phrase(code) + "\r\n"
"Content-Length: 0\r\n" "Content-Length: 0\r\n"
"Connection: keep-alive\r\n" "Connection: keep-alive\r\n"
+ std::string(CORS_HEADERS) +
"\r\n"; "\r\n";
session_write(session, resp); session_write(session, resp);

View File

@@ -3,8 +3,13 @@
#include <cstdio> #include <cstdio>
#include <string> #include <string>
#include <chrono> #include <chrono>
#include <cctype>
#include <uv.h> #include <uv.h>
#include <openssl/pem.h>
#include <openssl/x509.h>
#include <openssl/evp.h>
#include <openssl/bn.h>
#include "config.hpp" #include "config.hpp"
#include "log.hpp" #include "log.hpp"
@@ -29,12 +34,60 @@ static Router router;
static PairingStore pairing_store; static PairingStore pairing_store;
static RequestLog request_log; static RequestLog request_log;
static json build_config() { static bool read_cert_metadata(const char* cert_path,
std::string& fingerprint,
std::string& serial,
std::string& server_fingerprint) {
FILE* fp = std::fopen(cert_path, "r");
if (!fp) return false;
X509* cert = PEM_read_X509(fp, nullptr, nullptr, nullptr);
std::fclose(fp);
if (!cert) return false;
unsigned char md[EVP_MAX_MD_SIZE];
unsigned int md_len = 0;
if (X509_digest(cert, EVP_sha1(), md, &md_len) != 1) {
X509_free(cert);
return false;
}
static const char* hex = "0123456789ABCDEF";
fingerprint.clear();
fingerprint.reserve(md_len * 2);
server_fingerprint.clear();
server_fingerprint.reserve(md_len * 3 - 1);
for (unsigned int i = 0; i < md_len; ++i) {
fingerprint.push_back(hex[(md[i] >> 4) & 0xF]);
fingerprint.push_back(hex[md[i] & 0xF]);
if (i) server_fingerprint.push_back('-');
server_fingerprint.push_back(hex[(md[i] >> 4) & 0xF]);
server_fingerprint.push_back(hex[md[i] & 0xF]);
}
const ASN1_INTEGER* asn1_serial = X509_get0_serialNumber(cert);
BIGNUM* bn = ASN1_INTEGER_to_BN(asn1_serial, nullptr);
char* hex_serial = bn ? BN_bn2hex(bn) : nullptr;
bool ok = hex_serial != nullptr;
if (ok) {
serial = hex_serial;
for (char& c : serial) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
OPENSSL_free(hex_serial);
}
BN_free(bn);
X509_free(cert);
return ok;
}
static json build_config(const char* cert_path) {
std::string fingerprint, serial, server_fingerprint;
if (!read_cert_metadata(cert_path, fingerprint, serial, server_fingerprint)) {
logc::error("failed to read certificate metadata from %s", cert_path);
}
return { return {
{"authToken", config::get("AUTH_TOKEN", "df40ad2067954646abb0499548a52241")}, {"authToken", config::get("AUTH_TOKEN", "df40ad2067954646abb0499548a52241")},
{"certificateFingerprint", config::get("CERTIFICATE_FINGERPRINT", "BC2114CF407A42724BEEF417960F76DCBF9DE879")}, {"certificateFingerprint", fingerprint},
{"certificateSerialNumber", config::get("CERTIFICATE_SERIAL_NUMBER", "00BFC8BEACDB981B165210EF111CB9D3")}, {"certificateSerialNumber", serial},
{"serverFingerprint", config::get("SERVER_FINGERPRINT", "39-6D-BD-DE-F3-5C-5A-EA-C2-19-CF-EB-A7-A9-58-2F-20-3F-20-F7-3D-E6-CA-8E-AE-FD-28-30-37-A6-45-AE")}, {"serverFingerprint", server_fingerprint},
{"mandantId", config::get("MANDANT_ID", "1")}, {"mandantId", config::get("MANDANT_ID", "1")},
{"mandantName", config::get("MANDANT_NAME", "eB-Standard")}, {"mandantName", config::get("MANDANT_NAME", "eB-Standard")},
{"mandantDatabase", config::get("MANDANT_DATABASE", "eazybusiness")}, {"mandantDatabase", config::get("MANDANT_DATABASE", "eazybusiness")},
@@ -125,7 +178,7 @@ int main(int /*argc*/, char* argv[]) {
loop = uv_default_loop(); loop = uv_default_loop();
server_config = build_config(); server_config = build_config(cert_path.c_str());
// Register routes // Register routes
router.add_route("GET", "/v1/client", handle_client); router.add_route("GET", "/v1/client", handle_client);

View File

@@ -18,6 +18,11 @@ void Router::dispatch(tls_session* sess, PairingStore& pairing, const json& conf
full_url += "?" + req.query_string; full_url += "?" + req.query_string;
} }
if (req.method == "OPTIONS") {
resp.send_empty(204);
return;
}
std::string route_key = req.method + " " + req.path; std::string route_key = req.method + " " + req.path;
auto it = routes_.find(route_key); auto it = routes_.find(route_key);
if (it != routes_.end()) { if (it != routes_.end()) {

View File

@@ -7,6 +7,7 @@ import 'dotenv/config';
import { connectDb, closeDb } from './src/db.js'; import { connectDb, closeDb } from './src/db.js';
import { isDemoMode } from './src/demo/mode.js'; import { isDemoMode } from './src/demo/mode.js';
import { loadDemoCatalog } from './src/demo/store.js'; import { loadDemoCatalog } from './src/demo/store.js';
import { readCertMetadata } from './src/cert-meta.js';
import { createJtlPosServer } from './src/jtl-server.js'; import { createJtlPosServer } from './src/jtl-server.js';
import { createPairingStore } from './src/pairing.js'; import { createPairingStore } from './src/pairing.js';
import { closeOrderLog } from './src/order-log.js'; import { closeOrderLog } from './src/order-log.js';
@@ -59,11 +60,18 @@ function formatBody(buffer) {
return `[binary ${buffer.length} bytes]`; return `[binary ${buffer.length} bytes]`;
} }
const certPem = fs.readFileSync(certPath);
const keyPem = fs.readFileSync(keyPath);
const certMeta = readCertMetadata(certPem);
const pairingStore = createPairingStore(); const pairingStore = createPairingStore();
pairingStore.setPairingCode(PAIRING_CODE, 'JTL-POS'); pairingStore.setPairingCode(PAIRING_CODE, 'JTL-POS');
pairingStore.registerDevice(AUTH_TOKEN, 'JTL-POS'); pairingStore.registerDevice(AUTH_TOKEN, 'JTL-POS');
const jtlHandler = createJtlPosServer(pairingStore, { authToken: AUTH_TOKEN }); const jtlHandler = createJtlPosServer(pairingStore, {
authToken: AUTH_TOKEN,
...certMeta,
});
const loggedJtlHandler = async (req, res) => { const loggedJtlHandler = async (req, res) => {
const started = Date.now(); const started = Date.now();
@@ -108,8 +116,8 @@ const loggedJtlHandler = async (req, res) => {
const httpsServer = https.createServer( const httpsServer = https.createServer(
{ {
key: fs.readFileSync(keyPath), key: keyPem,
cert: fs.readFileSync(certPath), cert: certPem,
}, },
loggedJtlHandler loggedJtlHandler
); );

12
src/cert-meta.js Normal file
View File

@@ -0,0 +1,12 @@
import { X509Certificate } from 'node:crypto';
/** Derive pairing metadata from a PEM-encoded TLS certificate. */
export function readCertMetadata(certPem) {
const x509 = new X509Certificate(certPem);
const sha1 = x509.fingerprint; // colon-separated uppercase hex
return {
certificateFingerprint: sha1.replace(/:/g, ''),
certificateSerialNumber: x509.serialNumber,
serverFingerprint: sha1.replace(/:/g, '-'),
};
}

View File

@@ -60,7 +60,6 @@ export function handle(req, res, { url, pairingStore, config }) {
if (authCode.length === 6) { if (authCode.length === 6) {
if (pairingStore.hasPairingCode(authCode)) { if (pairingStore.hasPairingCode(authCode)) {
pairingStore.revokePairingCode(authCode);
pairingStore.registerDevice(config.authToken, name); pairingStore.registerDevice(config.authToken, name);
return sendJson(res, 200, buildClientStep2(authCode, config)); return sendJson(res, 200, buildClientStep2(authCode, config));
} }

View File

@@ -13,11 +13,19 @@ export function readBody(req) {
}); });
} }
const CORS_HEADERS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
};
export function sendJson(res, statusCode, body) { export function sendJson(res, statusCode, body) {
const responseBody = JSON.stringify(body); const responseBody = JSON.stringify(body);
res.writeHead(statusCode, { res.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8', 'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(responseBody), 'Content-Length': Buffer.byteLength(responseBody),
...CORS_HEADERS,
}); });
res.end(responseBody); res.end(responseBody);
} }
@@ -26,10 +34,19 @@ export function sendBinary(res, statusCode, buffer, contentType) {
res.writeHead(statusCode, { res.writeHead(statusCode, {
'Content-Type': contentType, 'Content-Type': contentType,
'Content-Length': buffer.length, 'Content-Length': buffer.length,
...CORS_HEADERS,
}); });
res.end(buffer); res.end(buffer);
} }
export function sendCorsPreflight(res) {
res.writeHead(204, {
'Content-Length': 0,
...CORS_HEADERS,
});
res.end();
}
export function normalizePath(pathname) { export function normalizePath(pathname) {
return pathname.replace(/^\/api(?=\/v1\/)/, ''); return pathname.replace(/^\/api(?=\/v1\/)/, '');
} }

View File

@@ -1,21 +1,12 @@
import { endpoints } from './endpoints/index.js'; import { endpoints } from './endpoints/index.js';
import { normalizePath, readBody, sendJson } from './http.js'; import { normalizePath, readBody, sendCorsPreflight, sendJson } from './http.js';
function buildConfig(config = {}) { function buildConfig(config = {}) {
return { return {
authToken: config.authToken || process.env.AUTH_TOKEN || 'df40ad2067954646abb0499548a52241', authToken: config.authToken || process.env.AUTH_TOKEN || 'df40ad2067954646abb0499548a52241',
certificateFingerprint: certificateFingerprint: config.certificateFingerprint || '',
config.certificateFingerprint || certificateSerialNumber: config.certificateSerialNumber || '',
process.env.CERTIFICATE_FINGERPRINT || serverFingerprint: config.serverFingerprint || '',
'BC2114CF407A42724BEEF417960F76DCBF9DE879',
certificateSerialNumber:
config.certificateSerialNumber ||
process.env.CERTIFICATE_SERIAL_NUMBER ||
'00BFC8BEACDB981B165210EF111CB9D3',
serverFingerprint:
config.serverFingerprint ||
process.env.SERVER_FINGERPRINT ||
'39-6D-BD-DE-F3-5C-5A-EA-C2-19-CF-EB-A7-A9-58-2F-20-3F-20-F7-3D-E6-CA-8E-AE-FD-28-30-37-A6-45-AE',
mandantId: config.mandantId || process.env.MANDANT_ID || '1', mandantId: config.mandantId || process.env.MANDANT_ID || '1',
mandantName: config.mandantName || process.env.MANDANT_NAME || 'eB-Standard', mandantName: config.mandantName || process.env.MANDANT_NAME || 'eB-Standard',
mandantDatabase: config.mandantDatabase || process.env.MANDANT_DATABASE || 'eazybusiness', mandantDatabase: config.mandantDatabase || process.env.MANDANT_DATABASE || 'eazybusiness',
@@ -27,6 +18,10 @@ export function createJtlPosServer(pairingStore, config = {}) {
const routes = new Map(endpoints.map((endpoint) => [`${endpoint.method} ${endpoint.path}`, endpoint])); const routes = new Map(endpoints.map((endpoint) => [`${endpoint.method} ${endpoint.path}`, endpoint]));
async function handle(req, res) { async function handle(req, res) {
if (req.method === 'OPTIONS') {
return sendCorsPreflight(res);
}
const url = new URL(req.url, 'https://localhost'); const url = new URL(req.url, 'https://localhost');
const pathname = normalizePath(url.pathname); const pathname = normalizePath(url.pathname);
const routeKey = `${req.method} ${pathname}`; const routeKey = `${req.method} ${pathname}`;