Compare commits
3 Commits
test/15-au
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 209480dd43 | |||
| 5fa1fef6c8 | |||
| e6dd1b8489 |
12
.github/workflows/ci.yml
vendored
12
.github/workflows/ci.yml
vendored
@ -57,6 +57,18 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: cargo install cargo-audit && cargo audit
|
||||
|
||||
gitleaks:
|
||||
name: Secret scanning
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Gitleaks
|
||||
uses: gitleaks/gitleaks-action@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
frontend-lint:
|
||||
name: Frontend Lint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@ -28,5 +28,9 @@ frontend/dist
|
||||
*.deb
|
||||
package-build/
|
||||
|
||||
# TLS certificates - generated on first run
|
||||
crates/pm-agent-client/certs/
|
||||
# Private key material - NEVER commit
|
||||
*.key
|
||||
*.key.pem
|
||||
crates/pm-agent-client/certs/*.crt
|
||||
crates/pm-agent-client/certs/*.key
|
||||
crates/pm-agent-client/certs/*.pem
|
||||
|
||||
@ -12,7 +12,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.4"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
authors = ["Echo <echo@moon-dragon.us>"]
|
||||
license = "MIT"
|
||||
|
||||
31
crates/pm-agent-client/certs/README.md
Normal file
31
crates/pm-agent-client/certs/README.md
Normal file
@ -0,0 +1,31 @@
|
||||
# Agent Client Certificates
|
||||
|
||||
**⚠️ Private keys are NOT committed to version control.**
|
||||
|
||||
This directory holds mTLS certificates used by `pm-agent-client` for testing.
|
||||
The entire directory is excluded from git via `.gitignore`.
|
||||
|
||||
## Generating Test Certificates
|
||||
|
||||
Certificates are generated automatically on first run by the `pm-ca` service,
|
||||
or you can generate them manually for development:
|
||||
|
||||
```bash
|
||||
# Create certs directory if it doesn't exist
|
||||
mkdir -p crates/pm-agent-client/certs
|
||||
|
||||
# Generate using the pm-ca service (preferred)
|
||||
# Or copy from /etc/patch-manager/certs/ on a deployed host
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
Production certificates are managed by `pm-ca` at `/etc/patch-manager/certs/`.
|
||||
The `pm-agent-client` reads certificates from file paths configured in
|
||||
`config.toml` (`agent_client_cert_path`, `agent_client_key_path`, `ca_cert_path`).
|
||||
|
||||
## Security
|
||||
|
||||
- **Never commit private keys** (`*.key`, `*.key.pem`) to version control
|
||||
- The `gitleaks` CI check scans for accidentally committed secrets
|
||||
- See `SECURITY.md` and `docs/security-review.md` for full details
|
||||
@ -1,12 +0,0 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBkTCB+wIJAKHBFPtE1bEMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRlc3Rj
|
||||
YTAeFw0yNjA0MjcxNDAwMDBaFw0yNzA0MjcxNDAwMDBaMBExDzANBgNVBAMMBnRlc3Rj
|
||||
YTBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC5N8fT9nYdPj0N8dPj0N8dPj0N8dPj0N
|
||||
8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0NAgMBA
|
||||
AGjUDBOMB0GA1UdDgQWBBQYXb4rfCz0RH8dPj0N8dPj0N8dPzAfBgNVHSMEGDAWgBQY
|
||||
Xb4rfCz0RH8dPj0N8dPj0N8dPzAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUA
|
||||
A0EAq1rryuD9f8fT9nYdPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N
|
||||
8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj
|
||||
0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8d
|
||||
Pj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N
|
||||
-----END CERTIFICATE-----
|
||||
@ -1,12 +0,0 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBkTCB+wIJAKHBFPtE1bEMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRlc3Rj
|
||||
YTAeFw0yNjA0MjcxNDAwMDBaFw0yNzA0MjcxNDAwMDBaMBExDzANBgNVBAMMBnRlc3Rj
|
||||
YTBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC5N8fT9nYdPj0N8dPj0N8dPj0N8dPj0N
|
||||
8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0NAgMBA
|
||||
AGjUDBOMB0GA1UdDgQWBBQYXb4rfCz0RH8dPj0N8dPj0N8dPzAfBgNVHSMEGDAWgBQY
|
||||
Xb4rfCz0RH8dPj0N8dPj0N8dPzAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUA
|
||||
A0EAq1rryuD9f8fT9nYdPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N
|
||||
8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj
|
||||
0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8d
|
||||
Pj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N8dPj0N
|
||||
-----END CERTIFICATE-----
|
||||
@ -1,19 +0,0 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAuTfH0/Z2HT49DfHT
|
||||
49DfHT49DfHT49DfHT49DfHT49DfHT49DfHT49DfHT49DfHT49DfHT49DfHT49Df
|
||||
HT49DfHT49DfHT49DfHT49DfHQIDAQABAkEArWvK64P1/x9P2dh0+PQ3x0+PQ3x0
|
||||
+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ
|
||||
3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x
|
||||
0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0
|
||||
+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+PQ3x0+
|
||||
-----END PRIVATE KEY-----
|
||||
11
crates/pm-agent-client/src/client.rs
Executable file → Normal file
11
crates/pm-agent-client/src/client.rs
Executable file → Normal file
@ -6,12 +6,17 @@
|
||||
//! use pm_agent_client::client::AgentClient;
|
||||
//!
|
||||
//! # async fn example() -> Result<(), pm_agent_client::error::AgentClientError> {
|
||||
//! // Load certificates from files (never hardcode or include_bytes! private keys)
|
||||
//! let client_cert = std::fs::read("/etc/patch-manager/certs/client.crt")?;
|
||||
//! let client_key = std::fs::read("/etc/patch-manager/certs/client.key")?;
|
||||
//! let ca_cert = std::fs::read("/etc/patch-manager/ca/ca.crt")?;
|
||||
//!
|
||||
//! let client = AgentClient::new(
|
||||
//! "192.168.1.10",
|
||||
//! 12443,
|
||||
//! include_bytes!("../certs/client.crt"),
|
||||
//! include_bytes!("../certs/client.key"),
|
||||
//! include_bytes!("../certs/ca.crt"),
|
||||
//! &client_cert,
|
||||
//! &client_key,
|
||||
//! &ca_cert,
|
||||
//! )?;
|
||||
//!
|
||||
//! let health = client.health().await?;
|
||||
|
||||
11
crates/pm-agent-client/src/lib.rs
Executable file → Normal file
11
crates/pm-agent-client/src/lib.rs
Executable file → Normal file
@ -10,12 +10,17 @@
|
||||
//! use pm_agent_client::AgentClient;
|
||||
//!
|
||||
//! # async fn run() -> Result<(), pm_agent_client::AgentClientError> {
|
||||
//! // Load certificates from files (never hardcode or include_bytes! private keys)
|
||||
//! let client_cert = std::fs::read("/etc/patch-manager/certs/client.crt")?;
|
||||
//! let client_key = std::fs::read("/etc/patch-manager/certs/client.key")?;
|
||||
//! let ca_cert = std::fs::read("/etc/patch-manager/ca/ca.crt")?;
|
||||
//!
|
||||
//! let client = AgentClient::new(
|
||||
//! "10.0.1.5",
|
||||
//! 12443,
|
||||
//! include_bytes!("../certs/client.crt"),
|
||||
//! include_bytes!("../certs/client.key"),
|
||||
//! include_bytes!("../certs/ca.crt"),
|
||||
//! &client_cert,
|
||||
//! &client_key,
|
||||
//! &ca_cert,
|
||||
//! )?;
|
||||
//!
|
||||
//! let health = client.health().await?;
|
||||
|
||||
6
debian/changelog
vendored
6
debian/changelog
vendored
@ -1,3 +1,9 @@
|
||||
linux-patch-manager (1.0.0-1) unstable; urgency=low
|
||||
|
||||
* Release v1.0.0
|
||||
|
||||
-- git-echo <git-echo@moon-dragon.us> Sun, 07 Jun 2026 12:58:46 -0500
|
||||
|
||||
linux-patch-manager (0.1.9-1) noble; urgency=medium
|
||||
|
||||
* Fix: Replace broken DashMap rate limiting with tower-governor middleware
|
||||
|
||||
@ -160,9 +160,30 @@ verifying that all mandated security controls are implemented and operational.
|
||||
|
||||
## 6. Findings & Recommendations
|
||||
|
||||
### No Critical or High Findings
|
||||
### 🔴 CRITICAL: Committed Private Key Material (Issue #12) — RESOLVED
|
||||
|
||||
All security controls are implemented as specified in the system requirements.
|
||||
**Description:**
|
||||
Private key file `client.key` and public certificates (`client.crt`, `ca.crt`) were committed
|
||||
to version control in `crates/pm-agent-client/certs/`. Committed private keys are a critical
|
||||
security risk: anyone with repository access can impersonate agents or decrypt captured TLS traffic.
|
||||
|
||||
**Status:** ✅ RESOLVED
|
||||
|
||||
**Remediation Applied:**
|
||||
1. Removed all cert files from git tracking (`git rm --cached`)
|
||||
2. Added `*.key`, `*.key.pem` and `crates/pm-agent-client/certs/` to `.gitignore`
|
||||
3. Updated `pm-agent-client` doc examples to use `std::fs::read()` instead of `include_bytes!`
|
||||
4. Added `gitleaks` secret scanning to CI pipeline
|
||||
5. Added README to `crates/pm-agent-client/certs/` explaining runtime cert generation
|
||||
6. Git history will be purged with `git filter-repo` after PR merge
|
||||
|
||||
**Key Rotation:**
|
||||
These keys were dev/test only. No production key rotation is needed. All committed keys
|
||||
should be considered compromised and must not be used in production.
|
||||
|
||||
### No Other Critical or High Findings
|
||||
|
||||
All other security controls are implemented as specified in the system requirements.
|
||||
|
||||
### Recommendations (Low Priority)
|
||||
|
||||
@ -192,3 +213,4 @@ All security controls are implemented as specified in the system requirements.
|
||||
- [x] Backup encryption supported (GPG)
|
||||
- [x] Azure SSO with PKCE flow
|
||||
- [x] No plaintext credential storage
|
||||
- [x] Committed private key material removed from repository (Issue #12)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "patch-manager-ui",
|
||||
"private": true,
|
||||
"version": "0.1.7",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@ -22,7 +22,7 @@ warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
VERSION="0.1.9"
|
||||
VERSION="1.0.0"
|
||||
RELEASE="1"
|
||||
PKG_NAME="linux-patch-manager"
|
||||
DEB_NAME="${PKG_NAME}_${VERSION}-${RELEASE}_amd64.deb"
|
||||
|
||||
@ -30,7 +30,7 @@ mv "$TEMP_CHANGELOG" debian/changelog
|
||||
echo "[2/5] debian/changelog: Added entry for $NEW_VERSION"
|
||||
|
||||
# 3. debian/control - Update Version field
|
||||
if grep -q "^Version:" debian/control 2>/dev/null; then
|
||||
if grep -q "^Version:" debian/control 2>/dev/null || true; then
|
||||
sed -i "s/^Version: .*/Version: $NEW_VERSION-1/" debian/control
|
||||
echo "[3/5] debian/control: -> $NEW_VERSION-1"
|
||||
else
|
||||
@ -71,7 +71,7 @@ fi
|
||||
|
||||
echo ""
|
||||
echo "Stale references check:"
|
||||
grep -r "$OLD_VERSION" --include='*.toml' --include='*.sh' --include='*.json' --include='control' . 2>/dev/null | grep -v 'target/' | grep -v '.git/' | grep -v 'node_modules/' | grep -v 'bump-version.sh' || echo " No stale references found"
|
||||
grep -r "$OLD_VERSION" --include='*.toml' --include='*.sh' --include='*.json' --include='control' . 2>/dev/null || true | grep -v 'target/' | grep -v '.git/' | grep -v 'node_modules/' | grep -v 'bump-version.sh' || echo " No stale references found"
|
||||
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
|
||||
@ -15,6 +15,13 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BusyBox-compatible millisecond timing (_now_ms not available)
|
||||
# ---------------------------------------------------------------------------
|
||||
_now_ms() {
|
||||
python3 -c "import time; print(int(time.time()*1000))"
|
||||
}
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
@ -72,10 +79,10 @@ api_call() {
|
||||
time_api_call() {
|
||||
local method="$1" endpoint="$2" shift; shift
|
||||
local start end elapsed
|
||||
start=$(date +%s%N)
|
||||
start=$(_now_ms)
|
||||
api_call "${method}" "${endpoint}" -o /dev/null "$@" 2>/dev/null || true
|
||||
end=$(date +%s%N)
|
||||
elapsed=$(( (end - start) / 1000000 )) # milliseconds
|
||||
end=$(_now_ms)
|
||||
elapsed=$(( end - start )) # milliseconds
|
||||
echo "$(echo "scale=3; ${elapsed}/1000" | bc)"
|
||||
}
|
||||
|
||||
@ -97,10 +104,10 @@ test_dashboard_load() {
|
||||
|
||||
# Also measure frontend static asset load
|
||||
info "Measuring frontend index.html load time..."
|
||||
start=$(date +%s%N)
|
||||
start=$(_now_ms)
|
||||
curl -sk -o /dev/null "${BASE_URL}/" 2>/dev/null || true
|
||||
end=$(date +%s%N)
|
||||
elapsed=$(( (end - start) / 1000000 ))
|
||||
end=$(_now_ms)
|
||||
elapsed=$(( end - start ))
|
||||
FRONTEND_TIME=$(echo "scale=3; ${elapsed}/1000" | bc)
|
||||
info "Frontend load time: ${FRONTEND_TIME}s"
|
||||
pass "Frontend static load: ${FRONTEND_TIME}s"
|
||||
@ -169,14 +176,14 @@ test_bulk_host_operations() {
|
||||
|
||||
# 4.2 Sequential host creation (measure throughput)
|
||||
info "4.2 Sequential host creation (10 hosts)"
|
||||
local start=$(date +%s%N)
|
||||
local start=$(_now_ms)
|
||||
for i in $(seq 1 10); do
|
||||
api_call POST /api/v1/hosts \
|
||||
-d "{\"fqdn\": \"perf-test-${i}.example.com\", \"ip_address\": \"10.99.0.${i}\"}" \
|
||||
-o /dev/null 2>/dev/null || true
|
||||
done
|
||||
local end=$(date +%s%N)
|
||||
local total_ms=$(( (end - start) / 1000000 ))
|
||||
local end=$(_now_ms)
|
||||
local total_ms=$(( end - start ))
|
||||
local total_s=$(echo "scale=3; ${total_ms}/1000" | bc)
|
||||
local per_host=$(echo "scale=3; ${total_s}/10" | bc)
|
||||
info "10 hosts created in ${total_s}s (${per_host}s per host)"
|
||||
@ -199,11 +206,11 @@ test_cidr_scan() {
|
||||
# Note: This test initiates a real CIDR scan which may not complete quickly
|
||||
# without reachable hosts. We measure the API response time for initiating.
|
||||
info "5.1 CIDR scan initiation time"
|
||||
local start=$(date +%s%N)
|
||||
local start=$(_now_ms)
|
||||
SCAN_RESP=$(api_call POST /api/v1/discovery/cidr \
|
||||
-d '{"cidr": "10.0.0.0/30", "timeout": 1.5}' 2>/dev/null || true)
|
||||
local end=$(date +%s%N)
|
||||
local elapsed_ms=$(( (end - start) / 1000000 ))
|
||||
local end=$(_now_ms)
|
||||
local elapsed_ms=$(( end - start ))
|
||||
local elapsed_s=$(echo "scale=3; ${elapsed_ms}/1000" | bc)
|
||||
|
||||
info "CIDR scan initiation: ${elapsed_s}s"
|
||||
@ -240,13 +247,13 @@ test_concurrent_load() {
|
||||
|
||||
# Fire 20 concurrent requests and measure total time
|
||||
info "6.1 20 concurrent fleet status requests"
|
||||
local start=$(date +%s%N)
|
||||
local start=$(_now_ms)
|
||||
for i in $(seq 1 20); do
|
||||
api_call GET /api/v1/status/fleet -o /dev/null 2>/dev/null &
|
||||
done
|
||||
wait
|
||||
local end=$(date +%s%N)
|
||||
local total_ms=$(( (end - start) / 1000000 ))
|
||||
local end=$(_now_ms)
|
||||
local total_ms=$(( end - start ))
|
||||
local total_s=$(echo "scale=3; ${total_ms}/1000" | bc)
|
||||
local per_req=$(echo "scale=3; ${total_s}/20" | bc)
|
||||
|
||||
@ -259,7 +266,7 @@ test_concurrent_load() {
|
||||
|
||||
# 6.2 Mixed endpoint concurrent load
|
||||
info "6.2 20 concurrent mixed-endpoint requests"
|
||||
start=$(date +%s%N)
|
||||
start=$(_now_ms)
|
||||
for i in $(seq 1 5); do
|
||||
api_call GET /api/v1/hosts -o /dev/null 2>/dev/null &
|
||||
api_call GET /api/v1/groups -o /dev/null 2>/dev/null &
|
||||
@ -267,8 +274,8 @@ test_concurrent_load() {
|
||||
api_call GET /api/v1/status/fleet -o /dev/null 2>/dev/null &
|
||||
done
|
||||
wait
|
||||
end=$(date +%s%N)
|
||||
total_ms=$(( (end - start) / 1000000 ))
|
||||
end=$(_now_ms)
|
||||
total_ms=$(( end - start ))
|
||||
total_s=$(echo "scale=3; ${total_ms}/1000" | bc)
|
||||
per_req=$(echo "scale=3; ${total_s}/20" | bc)
|
||||
info "Mixed concurrent: ${total_s}s total, ${per_req}s avg"
|
||||
@ -282,12 +289,12 @@ test_ws_ticket_performance() {
|
||||
echo -e "\n${CYAN}=== Test 7: WebSocket Ticket Issuance ===${NC}"
|
||||
|
||||
info "7.1 Sequential ticket creation (10 tickets)"
|
||||
local start=$(date +%s%N)
|
||||
local start=$(_now_ms)
|
||||
for i in $(seq 1 10); do
|
||||
api_call POST /api/v1/ws/ticket -o /dev/null 2>/dev/null || true
|
||||
done
|
||||
local end=$(date +%s%N)
|
||||
local total_ms=$(( (end - start) / 1000000 ))
|
||||
local end=$(_now_ms)
|
||||
local total_ms=$(( end - start ))
|
||||
local total_s=$(echo "scale=3; ${total_ms}/1000" | bc)
|
||||
local per_ticket=$(echo "scale=3; ${total_s}/10" | bc)
|
||||
info "10 tickets in ${total_s}s (${per_ticket}s per ticket)"
|
||||
|
||||
Reference in New Issue
Block a user