mirror of
https://github.com/samuelthomas2774/nxapi.git
synced 2026-09-07 10:55:09 -05:00
Move f generation server to a separate repository
This commit is contained in:
@@ -1,738 +1,20 @@
|
||||
import process from 'node:process';
|
||||
import * as path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import * as net from 'node:net';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as crypto from 'node:crypto';
|
||||
import createDebug from 'debug';
|
||||
import { v4 as uuidgen } from 'uuid';
|
||||
import express from 'express';
|
||||
import bodyParser from 'body-parser';
|
||||
import mkdirp from 'mkdirp';
|
||||
import type { Arguments as ParentArguments } from '../cli.js';
|
||||
import { NintendoAccountIdTokenJwtPayload } from '../api/na.js';
|
||||
import { CoralJwtPayload, ZNCA_CLIENT_ID } from '../api/coral.js';
|
||||
import { ArgumentsCamelCase, Argv, YargsArguments } from '../util/yargs.js';
|
||||
import { initStorage, paths } from '../util/storage.js';
|
||||
import { getJwks, Jwt } from '../util/jwt.js';
|
||||
import { product } from '../util/product.js';
|
||||
import { parseListenAddress } from '../util/net.js';
|
||||
|
||||
const debug = createDebug('cli:android-znca-api-server-frida');
|
||||
const debugApi = createDebug('cli:android-znca-api-server-frida:api');
|
||||
|
||||
const script_dir = path.join(paths.temp, 'android-znca-api-server');
|
||||
|
||||
export const command = 'android-znca-api-server-frida <device>';
|
||||
export const desc = 'Connect to a rooted Android device with frida-server over ADB running the Nintendo Switch Online app and start a HTTP server to generate f parameters';
|
||||
export const command = 'android-znca-api-server-frida';
|
||||
export const desc = null;
|
||||
|
||||
export function builder(yargs: Argv<ParentArguments>) {
|
||||
return yargs.positional('device', {
|
||||
describe: 'ADB server address/port',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
}).option('exec-command', {
|
||||
describe: 'Command to use to run a file on the device',
|
||||
type: 'string',
|
||||
}).option('adb-path', {
|
||||
describe: 'Path to the adb executable',
|
||||
type: 'string',
|
||||
}).option('adb-root', {
|
||||
describe: 'Run `adb root` to restart adbd as root',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
}).option('frida-server-path', {
|
||||
describe: 'Path to the frida-server executable on the device',
|
||||
type: 'string',
|
||||
default: '/data/local/tmp/frida-server',
|
||||
}).option('start-method', {
|
||||
describe: 'Method to ensure the app is running (one of "spawn", "none", "activity", "service")',
|
||||
type: 'string',
|
||||
default: 'service',
|
||||
}).option('strict-validate', {
|
||||
describe: 'Validate data exactly matches the format that would be generated by Nintendo\'s Android app',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
}).option('validate-tokens', {
|
||||
describe: 'Validate tokens before passing them to znca',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
}).option('listen', {
|
||||
describe: 'Server address and port',
|
||||
type: 'array',
|
||||
default: ['[::]:0'],
|
||||
});
|
||||
return yargs;
|
||||
}
|
||||
|
||||
type Arguments = YargsArguments<ReturnType<typeof builder>>;
|
||||
|
||||
enum StartMethod {
|
||||
/** Spawn the app process with Frida, even if the app is already running (recommended) */
|
||||
SPAWN,
|
||||
/** Start the app's main activity using the am command */
|
||||
ACTIVITY,
|
||||
/**
|
||||
* Start a background service using the am command (default)
|
||||
* This tricks Android into not killing the app for some reason and allows the process to be started
|
||||
* in the background.
|
||||
*/
|
||||
SERVICE,
|
||||
/** Do not attempt to start the app - if it is not already running the server will fail */
|
||||
NONE,
|
||||
}
|
||||
|
||||
interface PackageInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
build: number;
|
||||
}
|
||||
interface SystemInfo {
|
||||
board: string;
|
||||
bootloader: string;
|
||||
brand: string;
|
||||
abis: string[];
|
||||
device: string;
|
||||
display: string;
|
||||
fingerprint: string;
|
||||
hardware: string;
|
||||
host: string;
|
||||
id: string;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
product: string;
|
||||
tags: string;
|
||||
time: string;
|
||||
type: string;
|
||||
user: string;
|
||||
|
||||
version: {
|
||||
codename: string;
|
||||
release: string;
|
||||
// release_display: string;
|
||||
sdk: string;
|
||||
sdk_int: number;
|
||||
security_patch: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface FResult {
|
||||
f: string;
|
||||
timestamp: string;
|
||||
/** Queue wait duration */
|
||||
dw: number;
|
||||
/** Initialisation duration */
|
||||
di: number;
|
||||
/** Processing duration */
|
||||
dp: number;
|
||||
}
|
||||
|
||||
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
||||
const start_method =
|
||||
argv.startMethod === 'spawn' ? StartMethod.SPAWN :
|
||||
argv.startMethod === 'activity' ? StartMethod.ACTIVITY :
|
||||
argv.startMethod === 'service' ? StartMethod.SERVICE :
|
||||
StartMethod.NONE;
|
||||
|
||||
await mkdirp(script_dir);
|
||||
|
||||
const storage = await initStorage(argv.dataPath);
|
||||
await setup(argv, start_method);
|
||||
|
||||
let {session, script} = await attach(argv, start_method);
|
||||
let ready: Promise<void> | null = null;
|
||||
|
||||
let api: {
|
||||
ping(): Promise<true>;
|
||||
getPackageInfo(): Promise<PackageInfo>;
|
||||
getSystemInfo(): Promise<SystemInfo>;
|
||||
genAudioH(token: string, timestamp: string | number | undefined, request_id: string): Promise<FResult>;
|
||||
genAudioH2(token: string, timestamp: string | number | undefined, request_id: string): Promise<FResult>;
|
||||
} = script.exports as any;
|
||||
|
||||
let system_info = await api.getSystemInfo();
|
||||
let package_info = await api.getPackageInfo();
|
||||
|
||||
const onexit = (code: number | NodeJS.Signals) => {
|
||||
// @ts-expect-error
|
||||
process.removeListener('exit', onexit);
|
||||
// @ts-expect-error
|
||||
process.removeListener('SIGTERM', onexit);
|
||||
// @ts-expect-error
|
||||
process.removeListener('SIGINT', onexit);
|
||||
|
||||
debug('Exiting', code);
|
||||
debug('Releasing wake lock', argv.device);
|
||||
execScript(argv.device, '/data/local/tmp/android-znca-api-server-shutdown.sh', argv.execCommand, argv.adbPath);
|
||||
process.exit(typeof code === 'number' ? code : 0);
|
||||
};
|
||||
|
||||
process.on('exit', onexit);
|
||||
process.on('SIGTERM', onexit);
|
||||
process.on('SIGINT', onexit);
|
||||
|
||||
function reattach() {
|
||||
// Already attempting to reattach
|
||||
if (ready) return;
|
||||
|
||||
debug('Attempting to reconnect to the device');
|
||||
|
||||
ready = attach(argv, start_method).then(async a => {
|
||||
ready = null;
|
||||
session = a.session;
|
||||
script = a.script;
|
||||
api = script.exports as any;
|
||||
|
||||
const new_system_info = await api.getSystemInfo();
|
||||
const new_package_info = await api.getPackageInfo();
|
||||
|
||||
if (system_info.version.sdk_int !== new_system_info.version.sdk_int) {
|
||||
debug('Android system version updated while disconnected');
|
||||
}
|
||||
if (package_info.build !== new_package_info.build) {
|
||||
debug('znca version updated while disconnected');
|
||||
}
|
||||
|
||||
system_info = new_system_info;
|
||||
package_info = new_package_info;
|
||||
}).catch(err => {
|
||||
console.error('Reattach failed', err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use('/api/znca', (req, res, next) => {
|
||||
console.log('[%s] %s %s HTTP/%s from %s, port %d%s, %s',
|
||||
new Date(), req.method, req.path, req.httpVersion,
|
||||
req.socket.remoteAddress, req.socket.remotePort,
|
||||
req.headers['x-forwarded-for'] ? ' (' + req.headers['x-forwarded-for'] + ')' : '',
|
||||
req.headers['user-agent']);
|
||||
|
||||
res.setHeader('Server', product + ' android-znca-api-frida');
|
||||
res.setHeader('X-Android-Build-Type', system_info.type);
|
||||
res.setHeader('X-Android-Release', system_info.version.release);
|
||||
res.setHeader('X-Android-Platform-Version', system_info.version.sdk_int);
|
||||
res.setHeader('X-znca-Platform', 'Android');
|
||||
res.setHeader('X-znca-Version', package_info.version);
|
||||
res.setHeader('X-znca-Build', package_info.build);
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
app.post('/api/znca/f', bodyParser.json(), async (req, res) => {
|
||||
const start = Date.now();
|
||||
|
||||
if (req.body && 'type' in req.body) req.body = {
|
||||
hash_method:
|
||||
req.body.type === 'nso' ? '1' :
|
||||
req.body.type === 'app' ? '2' : null!,
|
||||
token: req.body.token,
|
||||
timestamp: '' + req.body.timestamp,
|
||||
request_id: req.body.uuid,
|
||||
};
|
||||
|
||||
if (req.body && typeof req.body.hash_method === 'number') req.body.hash_method = '' + req.body.hash_method;
|
||||
|
||||
const data: {
|
||||
hash_method: '1' | '2' | 1 | 2;
|
||||
token: string;
|
||||
timestamp?: string | number;
|
||||
request_id?: string;
|
||||
} = req.body;
|
||||
|
||||
if (
|
||||
!data ||
|
||||
typeof data !== 'object' ||
|
||||
(data.hash_method !== '1' && data.hash_method !== '2') ||
|
||||
typeof data.token !== 'string' ||
|
||||
(data.timestamp && typeof data.timestamp !== 'string' && typeof data.timestamp !== 'number') ||
|
||||
(data.request_id && typeof data.request_id !== 'string')
|
||||
) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({error: 'invalid_request'}));
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = 'timestamp' in data ? '' + data.timestamp : undefined;
|
||||
const request_id = 'request_id' in data ? data.request_id! : uuidgen();
|
||||
|
||||
try {
|
||||
try {
|
||||
const [jwt, sig] = Jwt.decode<NintendoAccountIdTokenJwtPayload | CoralJwtPayload>(data.token);
|
||||
|
||||
const check_signature = jwt.payload.iss === 'https://accounts.nintendo.com';
|
||||
|
||||
if (data.hash_method === '1' && jwt.payload.iss !== 'https://accounts.nintendo.com') {
|
||||
throw new Error('Invalid token issuer');
|
||||
}
|
||||
if (data.hash_method === '1' && jwt.payload.aud !== ZNCA_CLIENT_ID) {
|
||||
throw new Error('Invalid token audience');
|
||||
}
|
||||
if (data.hash_method === '2' && jwt.payload.iss !== 'api-lp1.znc.srv.nintendo.net') {
|
||||
throw new Error('Invalid token issuer');
|
||||
}
|
||||
|
||||
if (jwt.payload.exp <= (Date.now() / 1000)) {
|
||||
throw new Error('Token expired');
|
||||
}
|
||||
|
||||
const jwks = jwt.header.kid &&
|
||||
jwt.header.jku?.match(/^https\:\/\/([^/]+\.)?nintendo\.(com|net)(\/|$)/i) ?
|
||||
await getJwks(jwt.header.jku, storage) : null;
|
||||
|
||||
if (check_signature && !jwks) {
|
||||
throw new Error('Requires signature verification, but trusted JWKS URL and key ID not included in token');
|
||||
}
|
||||
|
||||
const jwk = jwks?.keys.find(jwk => jwk.use === 'sig' && jwk.alg === jwt.header.alg &&
|
||||
jwk.kid === jwt.header.kid && jwk.x5c?.length);
|
||||
const cert = jwk?.x5c?.[0] ? '-----BEGIN CERTIFICATE-----\n' +
|
||||
jwk.x5c[0].match(/.{1,64}/g)!.join('\n') + '\n-----END CERTIFICATE-----\n' : null;
|
||||
|
||||
if (!cert) {
|
||||
if (check_signature) throw new Error('Not verifying signature, no JKW found for this token');
|
||||
else debug('Not verifying signature, no JKW found for this token');
|
||||
}
|
||||
|
||||
const signature_valid = cert && jwt.verify(sig, cert);
|
||||
|
||||
if (check_signature && !signature_valid) {
|
||||
throw new Error('Invalid signature');
|
||||
}
|
||||
|
||||
if (!check_signature) {
|
||||
if (signature_valid) debug('JWT signature is valid');
|
||||
else debug('JWT signature is not valid or not checked');
|
||||
}
|
||||
} catch (err) {
|
||||
if (argv.validateTokens) throw err;
|
||||
debug('Error validating token from %s, continuing anyway', req.ip, err);
|
||||
}
|
||||
|
||||
if (argv.strictValidate && 'timestamp' in data) {
|
||||
if (!timestamp!.match(/^\d+$/)) {
|
||||
throw new Error('Non-numeric timestamp is not likely to be accepted by the Coral API');
|
||||
}
|
||||
|
||||
// For Android the timestamp should be in milliseconds
|
||||
const timestamp_ms = parseInt(timestamp!);
|
||||
const now_ms = Date.now();
|
||||
|
||||
if (timestamp_ms > now_ms + 10000 || timestamp_ms + 10000 < now_ms) {
|
||||
throw new Error('Timestamp not matching the Android device is not likely to be accepted by the Coral API');
|
||||
}
|
||||
}
|
||||
|
||||
if (argv.strictValidate && 'request_id' in data) {
|
||||
// For Android the request_id should be lowercase hex
|
||||
if (!request_id!.match(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/)) {
|
||||
throw new Error('Request ID not a valid lowercase-hex v4 UUID is not likely to be accepted by the Coral API');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
debug('Error validating request from %s', req.ip, err);
|
||||
res.statusCode = 400;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Server-Timing', 'validate;dur=' + (Date.now() - start));
|
||||
res.end(JSON.stringify({error: 'invalid_request', error_message: (err as Error)?.message}));
|
||||
return;
|
||||
}
|
||||
|
||||
const validated = Date.now();
|
||||
|
||||
const handle = async () => {
|
||||
const was_connected = !ready;
|
||||
await ready;
|
||||
const connected = Date.now();
|
||||
|
||||
debugApi('Calling %s', data.hash_method === '2' ? 'genAudioH2' : 'genAudioH');
|
||||
|
||||
const result = data.hash_method === '2' ?
|
||||
await api.genAudioH2(data.token, timestamp, request_id) :
|
||||
await api.genAudioH(data.token, timestamp, request_id);
|
||||
|
||||
debugApi('Returned %s', result);
|
||||
|
||||
const response = {
|
||||
f: result.f,
|
||||
timestamp: data.timestamp ? undefined : result.timestamp,
|
||||
request_id: data.request_id ? undefined : request_id,
|
||||
};
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Server-Timing',
|
||||
'validate;dur=' + (validated - start) + ',' +
|
||||
(!was_connected ? 'attach;dur=' + (connected - validated) + ',' : '') +
|
||||
'queue;dur=' + result.dw + ',' +
|
||||
'init;dur=' + result.di + ',' +
|
||||
'process;dur=' + result.dp);
|
||||
res.end(JSON.stringify(response));
|
||||
};
|
||||
|
||||
try {
|
||||
await handle();
|
||||
} catch (err) {
|
||||
if ((err as any)?.message === 'Script is destroyed') {
|
||||
debugApi('Error in request from %s, retrying', req.ip, err);
|
||||
|
||||
reattach();
|
||||
|
||||
try {
|
||||
await handle();
|
||||
} catch (err) {
|
||||
debugApi('Error in request from %s', req.ip, err);
|
||||
|
||||
res.statusCode = 500;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({error: 'unknown'}));
|
||||
|
||||
if ((err as any)?.message === 'Script is destroyed') {
|
||||
reattach();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debugApi('Error in request from %s', req.ip, err);
|
||||
|
||||
res.statusCode = 500;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({error: 'unknown'}));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const address of argv.listen) {
|
||||
const [host, port] = parseListenAddress(address);
|
||||
const server = app.listen(port, host ?? '::');
|
||||
server.on('listening', () => {
|
||||
const address = server.address() as net.AddressInfo;
|
||||
console.log('Listening on %s, port %d', address.address, address.port);
|
||||
});
|
||||
}
|
||||
|
||||
setInterval(async () => {
|
||||
try {
|
||||
await api.ping();
|
||||
} catch (err) {
|
||||
if ((err as any)?.message === 'Script is destroyed') {
|
||||
reattach();
|
||||
return;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
debug('System info', system_info);
|
||||
debug('Package info', package_info);
|
||||
|
||||
try {
|
||||
debug('Test gen_audio_h');
|
||||
const result = await api.genAudioH('id_token', 'timestamp', 'request_id');
|
||||
debug('Test returned', result);
|
||||
} catch (err) {
|
||||
debug('Test failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
const frida_script = `
|
||||
const perform = callback => new Promise((rs, rj) => {
|
||||
Java.perform(() => {
|
||||
Java.scheduleOnMainThread(() => {
|
||||
try {
|
||||
rs(callback());
|
||||
} catch (err) {
|
||||
rj(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
rpc.exports = {
|
||||
ping() {
|
||||
return true;
|
||||
},
|
||||
getPackageInfo() {
|
||||
return perform(() => {
|
||||
const context = Java.use('android.app.ActivityThread').currentApplication().getApplicationContext();
|
||||
|
||||
const info = context.getPackageManager().getPackageInfo(context.getPackageName(), 0);
|
||||
|
||||
return {
|
||||
name: info.packageName.value,
|
||||
version: info.versionName.value,
|
||||
build: info.versionCode.value,
|
||||
// build: info.getLongVersionCode(),
|
||||
};
|
||||
});
|
||||
},
|
||||
getSystemInfo() {
|
||||
return perform(() => {
|
||||
const Build = Java.use('android.os.Build');
|
||||
const Version = Java.use('android.os.Build$VERSION');
|
||||
|
||||
return {
|
||||
board: Build.BOARD.value,
|
||||
bootloader: Build.BOOTLOADER.value,
|
||||
brand: Build.BRAND.value,
|
||||
abis: Build.SUPPORTED_ABIS.value,
|
||||
device: Build.DEVICE.value,
|
||||
display: Build.DISPLAY.value,
|
||||
fingerprint: Build.FINGERPRINT.value,
|
||||
hardware: Build.HARDWARE.value,
|
||||
host: Build.HOST.value,
|
||||
id: Build.ID.value,
|
||||
manufacturer: Build.MANUFACTURER.value,
|
||||
model: Build.MODEL.value,
|
||||
product: Build.PRODUCT.value,
|
||||
tags: Build.TAGS.value,
|
||||
time: Build.TIME.value,
|
||||
type: Build.TYPE.value,
|
||||
user: Build.USER.value,
|
||||
|
||||
version: {
|
||||
codename: Version.CODENAME.value,
|
||||
release: Version.RELEASE.value,
|
||||
sdk: Version.SDK.value,
|
||||
sdk_int: Version.SDK_INT.value,
|
||||
security_patch: Version.SECURITY_PATCH.value,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
genAudioH(token, timestamp, request_id) {
|
||||
const called = Date.now();
|
||||
|
||||
return perform(() => {
|
||||
const start = Date.now();
|
||||
|
||||
const libvoip = Java.use('com.nintendo.coral.core.services.voip.LibvoipJni');
|
||||
const context = Java.use('android.app.ActivityThread').currentApplication().getApplicationContext();
|
||||
libvoip.init(context);
|
||||
|
||||
if (!timestamp) timestamp = Date.now();
|
||||
|
||||
const init = Date.now();
|
||||
const f = libvoip.genAudioH(token, '' + timestamp, request_id);
|
||||
const end = Date.now();
|
||||
|
||||
return {
|
||||
f, timestamp,
|
||||
dw: start - called,
|
||||
di: init - start,
|
||||
dp: end - init,
|
||||
};
|
||||
});
|
||||
},
|
||||
genAudioH2(token, timestamp, request_id) {
|
||||
const called = Date.now();
|
||||
|
||||
return perform(() => {
|
||||
const start = Date.now();
|
||||
|
||||
const libvoip = Java.use('com.nintendo.coral.core.services.voip.LibvoipJni');
|
||||
const context = Java.use('android.app.ActivityThread').currentApplication().getApplicationContext();
|
||||
libvoip.init(context);
|
||||
|
||||
if (!timestamp) timestamp = Date.now();
|
||||
|
||||
const init = Date.now();
|
||||
const f = libvoip.genAudioH2(token, '' + timestamp, request_id);
|
||||
const end = Date.now();
|
||||
|
||||
return {
|
||||
f, timestamp,
|
||||
dw: start - called,
|
||||
di: init - start,
|
||||
dp: end - init,
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
`;
|
||||
|
||||
const setup_script = (options: {
|
||||
frida_server_path: string;
|
||||
start_method: StartMethod;
|
||||
}) => `#!/system/bin/sh
|
||||
|
||||
if [ "\`id -u\`" != "0" ]; then
|
||||
echo ""
|
||||
echo "-- Not running as root, this will not work --"
|
||||
echo "Use --adb-root to restart adbd as root (or run adb root manually) or use --exec-command to specify a su-like command to escalate to root."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Ensure frida-server is running
|
||||
echo "Running frida-server"
|
||||
killall ${JSON.stringify(path.basename(options.frida_server_path))}
|
||||
nohup ${JSON.stringify(options.frida_server_path)} >/dev/null 2>&1 &
|
||||
|
||||
if [ "$?" != "0" ]; then
|
||||
echo "Failed to start frida-server"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
|
||||
${(options.start_method === StartMethod.ACTIVITY ? `
|
||||
# Ensure the app is running
|
||||
echo "Starting com.nintendo.znca in foreground"
|
||||
am start-activity com.nintendo.znca/com.nintendo.coral.ui.boot.BootActivity
|
||||
` : options.start_method === StartMethod.SERVICE ? `
|
||||
# Ensure the app is running
|
||||
echo "Starting com.nintendo.znca"
|
||||
am start-foreground-service com.nintendo.znca/com.google.firebase.messaging.FirebaseMessagingService
|
||||
am start-service com.nintendo.znca/com.google.firebase.messaging.FirebaseMessagingService
|
||||
` : '').trim()}
|
||||
|
||||
if [ "$?" != "0" ]; then
|
||||
echo "Failed to start com.nintendo.znca"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Acquiring wake lock"
|
||||
echo androidzncaapiserver > /sys/power/wake_lock
|
||||
|
||||
exit 0
|
||||
`;
|
||||
|
||||
const shutdown_script = `#!/system/bin/sh
|
||||
|
||||
echo "Releasing wake lock"
|
||||
echo androidzncaapiserver > /sys/power/wake_unlock
|
||||
|
||||
exit 0
|
||||
`;
|
||||
|
||||
async function setup(argv: ArgumentsCamelCase<Arguments>, start_method: StartMethod) {
|
||||
debug('Connecting to device %s', argv.device);
|
||||
let co = execFileSync(argv.adbPath ?? 'adb', [
|
||||
'connect',
|
||||
argv.device,
|
||||
], {
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
while (co.toString().includes('failed to authenticate')) {
|
||||
console.log('');
|
||||
console.log('-- Allow this computer to connect to the device. --');
|
||||
console.log('');
|
||||
await new Promise(rs => setTimeout(rs, 5 * 1000));
|
||||
|
||||
execAdb([
|
||||
'disconnect',
|
||||
argv.device,
|
||||
], argv.adbPath);
|
||||
|
||||
debug('Connecting to device %s', argv.device);
|
||||
co = execFileSync(argv.adbPath ?? 'adb', [
|
||||
'connect',
|
||||
argv.device,
|
||||
], {
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
|
||||
debug('Pushing scripts');
|
||||
|
||||
await pushScript(argv.device, setup_script({
|
||||
frida_server_path: argv.fridaServerPath,
|
||||
start_method,
|
||||
}), '/data/local/tmp/android-znca-api-server-setup.sh', argv.adbPath);
|
||||
await pushScript(argv.device, shutdown_script, '/data/local/tmp/android-znca-api-server-shutdown.sh', argv.adbPath);
|
||||
}
|
||||
|
||||
async function attach(argv: ArgumentsCamelCase<Arguments>, start_method: StartMethod) {
|
||||
const frida = await import('frida');
|
||||
type Session = import('frida').Session;
|
||||
|
||||
if (argv.adbRoot) {
|
||||
debug('Restarting adbd as root');
|
||||
|
||||
execAdb([
|
||||
'root',
|
||||
], argv.adbPath, argv.device);
|
||||
}
|
||||
|
||||
debug('Running scripts');
|
||||
execScript(argv.device, '/data/local/tmp/android-znca-api-server-setup.sh', argv.execCommand, argv.adbPath);
|
||||
|
||||
debug('Done');
|
||||
|
||||
const device = await frida.getDevice(argv.device);
|
||||
debug('Connected to frida device %s', device.name);
|
||||
|
||||
let session: Session;
|
||||
|
||||
try {
|
||||
const process = start_method === StartMethod.SPAWN ?
|
||||
{pid: await device.spawn('com.nintendo.znca')} :
|
||||
await device.getProcess('Nintendo Switch Online');
|
||||
|
||||
debug('process', process);
|
||||
|
||||
session = await device.attach(process.pid);
|
||||
|
||||
if (start_method === StartMethod.SPAWN) {
|
||||
await device.resume(session.pid);
|
||||
}
|
||||
} catch (err) {
|
||||
debug('Could not attach to process', err);
|
||||
throw new Error('Failed to attach to process');
|
||||
}
|
||||
|
||||
debug('Attached to app process, pid %d', session.pid);
|
||||
|
||||
const script = await session.createScript(frida_script);
|
||||
await script.load();
|
||||
|
||||
return {session, script};
|
||||
}
|
||||
|
||||
function execAdb(args: string[], adb_path?: string, device?: string) {
|
||||
execFileSync(adb_path ?? 'adb', device ? ['-s', device, ...args] : args, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function getScriptPath(content: string) {
|
||||
const filename = path.join(script_dir, crypto.createHash('sha256').update(content).digest('hex') + '.sh');
|
||||
|
||||
await fs.writeFile(filename, content);
|
||||
await fs.chmod(filename, 0o755);
|
||||
|
||||
return filename;
|
||||
}
|
||||
|
||||
async function pushScript(device: string, content: string, path: string, adb_path?: string) {
|
||||
const filename = await getScriptPath(content);
|
||||
|
||||
debug('Pushing script', path, filename);
|
||||
|
||||
execAdb([
|
||||
'push',
|
||||
filename,
|
||||
path,
|
||||
], adb_path, device);
|
||||
|
||||
execAdb([
|
||||
'shell',
|
||||
'chmod 755 ' + JSON.stringify(path),
|
||||
], adb_path, device);
|
||||
}
|
||||
|
||||
function execScript(device: string, path: string, exec_command?: string, adb_path?: string) {
|
||||
const command = exec_command ?
|
||||
exec_command.replace('{cmd}', JSON.stringify(path)) :
|
||||
path;
|
||||
|
||||
debug('Running script', command);
|
||||
|
||||
execAdb([
|
||||
'shell',
|
||||
command,
|
||||
], adb_path, device);
|
||||
console.log('This command is now part of a separate package available at https://gitlab.fancy.org.uk/samuel/nxapi-znca-api or https://github.com/samuelthomas2774/nxapi-znca-api.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user