Skip to content
Merged
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
1 change: 1 addition & 0 deletions FINAL_PRODUCTION_SYSTEM/admin_v2.php
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@
'license_migrate' => ['LicenseController.php', 'handle_license_migrate', true, true],
'license_redetect_hw' => ['LicenseController.php', 'handle_license_redetect_hw', true, true],
'license_rebind' => ['LicenseController.php', 'handle_license_rebind', true, true],
'license_force_validate' => ['LicenseController.php', 'handle_license_force_validate', true, true],

// system upgrade
'upgrade_check_github' => ['UpgradeController.php', 'handle_upgrade_check_github', false, true],
Expand Down
49 changes: 49 additions & 0 deletions FINAL_PRODUCTION_SYSTEM/cli/license-validate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php
/**
* KeyGate — License phone-home CLI shim (P2)
*
* Daily cron entry runs this script to call /api/validate even when no
* admin pages have been hit. Idempotent — phoneHomeValidate() throttles
* itself via license_info.last_validated_at, so running this hourly or
* every minute is safe (only the first call inside the interval fires).
*
* Suggested cron (Linux):
* 0 3 * * * cd /var/www/keygate && /usr/bin/php FINAL_PRODUCTION_SYSTEM/cli/license-validate.php >> /var/log/keygate-phonehome.log 2>&1
*
* The Windows/IIS path goes through firePhoneHomeAsync()'s synchronous
* fallback — the 6-second Worker timeout is tolerable as a once-per-day
* blocking call.
*/

// Run only from CLI — refuse to expose this over HTTP.
if (PHP_SAPI !== 'cli') {
http_response_code(403);
echo "This script must be run from the command line.\n";
exit(1);
}

require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../functions/admin-helpers.php';
require_once __DIR__ . '/../functions/license-helpers.php';
require_once __DIR__ . '/../functions/license-phone-home.php';

$force = in_array('--force', $argv ?? [], true);
echo '[' . date('c') . "] phone-home start (force=" . ($force ? '1' : '0') . ")\n";

try {
$resp = phoneHomeValidate($pdo, $force);
if ($resp === null) {
echo "[" . date('c') . "] no-op (throttled or no license)\n";
exit(0);
}
echo '[' . date('c') . '] OK valid=' . (!empty($resp['valid']) ? '1' : '0')
. ' tier=' . ($resp['tier'] ?? '-')
. ' must_rebind=' . (!empty($resp['must_rebind']) ? '1' : '0')
. ' revoked=' . (!empty($resp['revoked']) ? '1' : '0')
. ' jti=' . substr((string)($resp['jti'] ?? ''), 0, 8)
. "\n";
exit(0);
} catch (Exception $e) {
fwrite(STDERR, '[' . date('c') . '] ERROR: ' . $e->getMessage() . "\n");
exit(2);
}
77 changes: 77 additions & 0 deletions FINAL_PRODUCTION_SYSTEM/controllers/admin/LicenseController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

require_once __DIR__ . '/../../functions/license-helpers.php';
require_once __DIR__ . '/../../functions/license-phone-home.php';

// ── Get License Status (no auth required — needed for registration wall) ──

Expand Down Expand Up @@ -67,6 +68,82 @@ function handle_license_status(PDO $pdo, array $admin_session, $json_input): voi
'rebind_quota_limit' => 3,
'rebind_window_days' => 365,
],
'phonehome' => _phonehomeStatus($pdo, $license),
]);
}

/**
* Build the phone-home status block for the License page UI (P2).
* Reads license_info row + grace band + cached validation response.
*/
function _phonehomeStatus(PDO $pdo, array $effective): array {
try {
$stmt = $pdo->query("SELECT last_validated_at, validation_failure_count,
last_validation_error, server_time_drift_seconds,
clock_drift_strikes, current_jti
FROM `" . t('license_info') . "`
WHERE is_active = 1 ORDER BY id DESC LIMIT 1");
$row = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
} catch (Exception $e) {
return ['available' => false];
}
$grace = function_exists('checkPhoneHomeGrace')
? checkPhoneHomeGrace($row)
: ['band' => 'ok', 'days_since' => 0, 'banner' => null];

return [
'available' => true,
'last_validated_at' => $row['last_validated_at'] ?? null,
'failure_count' => (int)($row['validation_failure_count'] ?? 0),
'last_error' => $row['last_validation_error'] ?? null,
'server_time_drift_seconds' => (int)($row['server_time_drift_seconds'] ?? 0),
'clock_drift_strikes' => (int)($row['clock_drift_strikes'] ?? 0),
'current_jti' => $row['current_jti'] ?? null,
'grace_band' => $grace['band'],
'grace_days' => $grace['days_since'],
'grace_banner' => $grace['banner'],
'grace_banner_threshold_d' => 14,
'grace_hard_threshold_d' => 30,
// Surface the P2 phone-home banner from getEffectiveLicense() so the
// UI can show the same message even after community degrade.
'effective_band' => $effective['phonehome_band'] ?? null,
'effective_banner' => $effective['phonehome_banner'] ?? null,
];
}

// ── Force phone-home validate (P2) ──
//
// Body: {} — admin clicks "Validate now" in the Phone-home card.
// Bypasses the 24h throttle and synchronously calls the Worker.
function handle_license_force_validate(PDO $pdo, array $admin_session, $json_input): void {
requirePermission('system_settings', $admin_session);

$resp = phoneHomeValidate($pdo, /*force=*/true);
if ($resp === null) {
jsonResponse([
'success' => false,
'error' => 'Phone-home failed (network or no active license)',
]);
return;
}

logAdminActivity(
$admin_session['admin_id'],
$admin_session['id'] ?? 0,
'LICENSE_FORCE_VALIDATE',
'Forced phone-home validate (jti=' . substr((string)($resp['jti'] ?? ''), 0, 8) . ')'
);

jsonResponse([
'success' => true,
'valid' => !empty($resp['valid']),
'tier' => $resp['tier'] ?? null,
'revoked' => !empty($resp['revoked']),
'must_rebind' => !empty($resp['must_rebind']),
'expires_at' => $resp['expires_at'] ?? null,
'jti' => $resp['jti'] ?? null,
'server_time' => $resp['server_time'] ?? null,
'message' => 'Validated against license server',
]);
}

Expand Down
3 changes: 3 additions & 0 deletions FINAL_PRODUCTION_SYSTEM/database/docker-init/00-init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,9 @@ run_sql "license_p0_hmac_migration.sql" 27
# Phase 16: License hardware-fingerprint binding + rebind quota (P1)
run_sql "license_p1_hwbind_migration.sql" 28

# Phase 17: License phone-home grace + revocation + clock-drift (P2)
run_sql "license_p2_phonehome_migration.sql" 29

echo ""
echo "=== Database initialization complete ==="
echo ""
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
-- =============================================================
-- KeyGate v2.3.0 P2 — Phone-home + grace + revocation + clock-drift
-- =============================================================
-- Phone-home turns the Cloudflare Worker into the authoritative tier
-- source. Without it, a JWT registered once was good forever — pirates
-- could buy one license, export the JWT, and seed unlimited installs.
--
-- Phone-home cadence: once per 24h on PHP boot OR daily cron, whichever
-- fires first. Non-blocking; cached tier serves until next successful
-- validate. Grace: 0–14d OK, 14–30d banner, >30d community fallback.
--
-- Revocation: each issued JWT carries a `jti` claim. Worker maintains
-- a KV set `revoked:{jti}`. Validate response carries revoked:true →
-- PHP forces community immediately, regardless of grace window.
--
-- Clock drift: Worker returns server_time; PHP records server vs local
-- delta. Three consecutive checks with >5min drift → 'clock_drift'.
-- Defeats pirates rolling system clock back to dodge expires_at.
-- =============================================================

ALTER TABLE `#__license_info`
ADD COLUMN validation_failure_count INT UNSIGNED NOT NULL DEFAULT 0
AFTER last_validated_at,
ADD COLUMN last_validation_error TEXT NULL DEFAULT NULL
AFTER validation_failure_count,
ADD COLUMN server_time_drift_seconds INT NOT NULL DEFAULT 0
AFTER last_validation_error,
ADD COLUMN clock_drift_strikes TINYINT UNSIGNED NOT NULL DEFAULT 0
AFTER server_time_drift_seconds,
ADD COLUMN current_jti CHAR(36) NULL DEFAULT NULL
AFTER clock_drift_strikes;

-- system_config slots used by P2.
-- license_validation_cache: JSON of last validate response + HMAC anchor
-- so the cache itself can't be forged via direct UPDATE.
INSERT INTO `#__system_config` (config_key, config_value, description) VALUES
('license_validation_cache', '', 'Last /api/validate response (JSON, HMAC-anchored to license_row_secret)')
ON DUPLICATE KEY UPDATE config_key = config_key;

INSERT INTO `#__system_config` (config_key, config_value, description) VALUES
('license_phonehome_interval', '86400', 'Seconds between phone-home validate calls (default 24h)')
ON DUPLICATE KEY UPDATE config_key = config_key;
35 changes: 35 additions & 0 deletions FINAL_PRODUCTION_SYSTEM/frontend/src/api/license.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,30 @@ export interface LicenseHardware {
rebind_window_days: number
}

// P2: phone-home status surfaced to the License page.
export interface LicensePhoneHome {
available: boolean
last_validated_at?: string | null
failure_count?: number
last_error?: string | null
server_time_drift_seconds?: number
clock_drift_strikes?: number
current_jti?: string | null
grace_band?: 'ok' | 'banner' | 'expired'
grace_days?: number
grace_banner?: string | null
grace_banner_threshold_d?: number
grace_hard_threshold_d?: number
effective_band?: string | null
effective_banner?: string | null
}

export interface LicenseStatusResponse {
success: boolean
license: LicenseInfo & { rebind_required?: boolean; rebind_grace_ends?: string | null }
usage: LicenseUsage
hardware?: LicenseHardware
phonehome?: LicensePhoneHome
}

export function getLicenseStatus() {
Expand Down Expand Up @@ -132,3 +151,19 @@ export function rebindLicense(reason?: string) {
error?: string
}>('license_rebind', { reason: reason || '' })
}

// P2: force a phone-home validate now (bypass 24h throttle).
export function forceValidate() {
return apiPostJson<{
success: boolean
valid?: boolean
tier?: string
revoked?: boolean
must_rebind?: boolean
expires_at?: string | null
jti?: string | null
server_time?: string | null
message?: string
error?: string
}>('license_force_validate')
}
27 changes: 27 additions & 0 deletions FINAL_PRODUCTION_SYSTEM/frontend/src/hooks/use-license.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
migrateLegacyLicense,
redetectHardware,
rebindLicense,
forceValidate,
} from '@/api/license'

export function useLicenseStatus() {
Expand Down Expand Up @@ -127,3 +128,29 @@ export function useRebindLicense() {
onError: (e: Error) => toast.error(e.message),
})
}

// P2: force phone-home validate now (bypass 24h throttle).
export function useForceValidate() {
const qc = useQueryClient()
const { t } = useTranslation()
return useMutation({
mutationFn: () => forceValidate(),
onSuccess: (data) => {
qc.invalidateQueries({ queryKey: ['license-status'] })
if (data.success) {
if (data.revoked) {
toast.error(t('license.validate_revoked', 'License revoked by issuer'))
} else if (data.must_rebind) {
toast.warning(t('license.validate_rebind', 'Hardware rebind required'))
} else if (data.valid) {
toast.success(t('license.validate_ok', 'Validated successfully'))
} else {
toast.error(t('license.validate_failed', 'Validation failed'))
}
} else {
toast.error(data.error || t('license.validate_failed', 'Validation failed'))
}
},
onError: (e: Error) => toast.error(e.message),
})
}
12 changes: 12 additions & 0 deletions FINAL_PRODUCTION_SYSTEM/frontend/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1758,6 +1758,18 @@
"license.hw_redetected": "Hardware fingerprint re-detected",
"license.rebound": "License rebound to current hardware",
"license.rebind_failed": "Rebind failed",
"license.validate_ok": "Validated successfully",
"license.validate_failed": "Validation failed",
"license.validate_revoked": "License revoked by issuer",
"license.validate_rebind": "Hardware rebind required",
"sub.phonehome_title": "License validation (phone-home)",
"sub.phonehome_desc": "Daily check against the issuer. 14-day grace if offline; 30-day hard cutoff.",
"sub.phonehome_last": "Last validated",
"sub.phonehome_never": "never",
"sub.phonehome_failures": "Recent failures",
"sub.phonehome_drift": "Clock drift (sec)",
"sub.phonehome_last_error": "Last error",
"sub.phonehome_force": "Validate now",
"sub.registered_to": "Registered to {{email}}",
"sub.free_tier": "Free tier — limited to 1 technician and 50 keys",
"sub.technicians": "Technicians",
Expand Down
12 changes: 12 additions & 0 deletions FINAL_PRODUCTION_SYSTEM/frontend/src/i18n/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -1758,6 +1758,18 @@
"license.hw_redetected": "Отпечаток оборудования обновлён",
"license.rebound": "Лицензия перенесена на текущее оборудование",
"license.rebind_failed": "Перенос не удался",
"license.validate_ok": "Проверка успешна",
"license.validate_failed": "Проверка не удалась",
"license.validate_revoked": "Лицензия отозвана издателем",
"license.validate_rebind": "Требуется привязка к оборудованию",
"sub.phonehome_title": "Проверка лицензии (phone-home)",
"sub.phonehome_desc": "Ежедневная проверка у издателя. 14 дней льготы при отсутствии сети; жёсткое отключение через 30 дней.",
"sub.phonehome_last": "Последняя проверка",
"sub.phonehome_never": "никогда",
"sub.phonehome_failures": "Недавние сбои",
"sub.phonehome_drift": "Сдвиг часов (сек)",
"sub.phonehome_last_error": "Последняя ошибка",
"sub.phonehome_force": "Проверить сейчас",
"sub.registered_to": "Зарегистрирована на {{email}}",
"sub.free_tier": "Бесплатный тариф — 1 техник, 50 ключей",
"sub.technicians": "Техники",
Expand Down
Loading
Loading