diff --git a/doc/cli.markdown b/doc/cli.markdown index 9e8380c1..5aa83596 100644 --- a/doc/cli.markdown +++ b/doc/cli.markdown @@ -2913,12 +2913,14 @@ Examples: $ balena push 23c73a1.local --system $ balena push 23c73a1.local --system --service my-service + + $ balena push 9f0cc5e4-0707-487c-ba84-edd0c36ff1c9 ### Arguments #### FLEETORDEVICE -fleet name or slug, or local device IP address or ".local" hostname +fleet name or slug, device UUID, or local device IP address or ".local" hostname ### Options diff --git a/lib/commands/push.ts b/lib/commands/push.ts index d9b7622c..674132fa 100644 --- a/lib/commands/push.ts +++ b/lib/commands/push.ts @@ -112,13 +112,15 @@ export default class PushCmd extends Command { '', '$ balena push 23c73a1.local --system', '$ balena push 23c73a1.local --system --service my-service', + '', + '$ balena push 9f0cc5e4-0707-487c-ba84-edd0c36ff1c9', ]; public static args = [ { name: 'fleetOrDevice', description: - 'fleet name or slug, or local device IP address or ".local" hostname', + 'fleet name or slug, device UUID, or local device IP address or ".local" hostname', required: true, parse: lowercaseIfSlug, }, @@ -315,7 +317,7 @@ export default class PushCmd extends Command { break; case BuildTarget.Device: - logger.logDebug(`Pushing to local device: ${params.fleetOrDevice}`); + logger.logDebug(`Pushing to device: ${params.fleetOrDevice}`); await this.pushToDevice( params.fleetOrDevice, options, @@ -442,9 +444,9 @@ export default class PushCmd extends Command { } protected async getBuildTarget(appOrDevice: string): Promise { - const { validateLocalHostnameOrIp } = await import('../utils/validation'); + const { validateDeviceAddress } = await import('../utils/validation'); - return validateLocalHostnameOrIp(appOrDevice) + return validateDeviceAddress(appOrDevice) ? BuildTarget.Device : BuildTarget.Cloud; } diff --git a/lib/utils/device/deploy.ts b/lib/utils/device/deploy.ts index ce09e81d..43a9cde0 100644 --- a/lib/utils/device/deploy.ts +++ b/lib/utils/device/deploy.ts @@ -40,7 +40,10 @@ import { DeviceAPI, DeviceInfo } from './api'; import * as LocalPushErrors from './errors'; import LivepushManager from './live'; import { displayBuildLog } from './logs'; -import { stripIndent } from '../lazy'; +import { getBalenaSdk, stripIndent } from '../lazy'; +import { validateIPAddress } from '../validation'; +import { Server, Socket } from 'net'; +import { BalenaSDK, Device } from 'balena-sdk'; const LOCAL_APPNAME = 'localapp'; const LOCAL_RELEASEHASH = 'localrelease'; @@ -121,7 +124,110 @@ async function environmentFromInput( return ret; } +const logConnection = ( + logger: Logger, + fromHost: string, + fromPort: number, + localAddress: string, + localPort: number, + deviceAddress: string, + devicePort: number, + err?: Error, +) => { + const logMessage = `${fromHost}:${fromPort} => ${localAddress}:${localPort} ===> ${deviceAddress}:${devicePort}`; + + if (err) { + logger.logError(`${logMessage} :: ${err.message}`); + } else { + logger.logLogs(logMessage); + } +}; + +async function openTunnel( + logger: Logger, + device: Device, + sdk: BalenaSDK, + port: number, +): Promise { + const localhost = 'localhost'; + try { + const { tunnelConnectionToDevice } = await import('../tunnel'); + const handler = await tunnelConnectionToDevice(device.uuid, port, sdk); + + const { createServer } = await import('net'); + const server = createServer(async (client: Socket) => { + try { + await handler(client); + logConnection( + logger, + client.remoteAddress || '', + client.remotePort || 0, + client.localAddress, + client.localPort, + device.vpn_address || '', + port, + ); + } catch (err: any) { + logConnection( + logger, + client.remoteAddress || '', + client.remotePort || 0, + client.localAddress, + client.localPort, + device.vpn_address || '', + port, + err, + ); + } + }); + + await new Promise((resolve, reject) => { + server.on('error', reject); + server.listen(port, localhost, () => { + resolve(server); + }); + }); + + logger.logInfo( + ` - tunnelling ${localhost}:${port} to ${device.uuid}:${port}`, + ); + + // opts.deviceHost = localhost; + } catch (err: any) { + logger.logWarn( + ` - tunnel failed ${localhost}:${port} to ${ + device.uuid + }:${port}, failed ${JSON.stringify(err.message)}`, + ); + } +} + export async function deployToDevice(opts: DeviceDeployOptions): Promise { + // Can only communicate with device using IP if local + const isLocal = + opts.deviceHost.includes('.local') || validateIPAddress(opts.deviceHost) + ? true + : false; + if (!isLocal) { + // 1. Open tunnel from remote device to localhost + // 2. Deploy to localhost + const logger = Logger.getLogger(); + const sdk = getBalenaSdk(); + + // Ascertain device uuid + const { getOnlineTargetDeviceUuid } = await import('../patterns'); + const uuid = await getOnlineTargetDeviceUuid(sdk, opts.deviceHost); + const device = await sdk.models.device.get(uuid); + logger.logInfo(`Opening a tunnel to ${device.uuid}...`); + + await openTunnel(logger, device, sdk, 48484); + await openTunnel(logger, device, sdk, 2375); + + logger.logInfo('Opened tunnels to supervisor and docker...'); + + opts.deviceHost = 'localhost'; + } + // Resolve .local addresses to IP to avoid // issue with Windows and rapid repeat lookups. // see: https://github.com/balena-io/balena-cli/issues/1518 @@ -145,7 +251,7 @@ export async function deployToDevice(opts: DeviceDeployOptions): Promise { throw new ExpectedError(stripIndent` Could not communicate with device supervisor at address ${opts.deviceHost}:${port}. Device may not have local mode enabled. Check with: - balena device local-mode + balena device local-mode `); } diff --git a/lib/utils/validation.ts b/lib/utils/validation.ts index 54ecbfa8..6b68f0b4 100644 --- a/lib/utils/validation.ts +++ b/lib/utils/validation.ts @@ -57,6 +57,10 @@ export function validateDotLocalUrl(input: string): boolean { return DOTLOCAL_REGEX.test(input); } +export function validateDeviceAddress(input: string): boolean { + return validateLocalHostnameOrIp(input) || validateUuid(input); +} + export function validateLocalHostnameOrIp(input: string): boolean { return validateIPAddress(input) || validateDotLocalUrl(input); }