Skip to content

Commit

Permalink
feat: simbridge.local (mDNS) (#60)
Browse files Browse the repository at this point in the history
This PR implements a multicast DNS (mDNS) resolver which gives users the
ability to navigate to http://simbridge.local:${PORT} on any device[^1]
  • Loading branch information
Nufflee authored Nov 29, 2022
1 parent 01192a3 commit 784e810
Show file tree
Hide file tree
Showing 8 changed files with 305 additions and 52 deletions.
6 changes: 3 additions & 3 deletions apps/server/src/interfaces/mcdu.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { OnGatewayConnection, OnGatewayInit, WebSocketGateway, WebSocketServer }
import { Server, WebSocket } from 'ws';
import { PrinterService } from '../utilities/printer.service';
import serverConfig from '../config/server.config';
import { IpService } from '../utilities/ip.service';
import { NetworkService } from '../utilities/network.service';

@WebSocketGateway({
cors: { origin: '*' },
Expand All @@ -14,7 +14,7 @@ export class McduGateway implements OnGatewayInit, OnGatewayConnection {
constructor(
@Inject(serverConfig.KEY) private serverConf: ConfigType<typeof serverConfig>,
private printerService: PrinterService,
private ipService: IpService,
private networkService: NetworkService,
) {}

private readonly logger = new Logger(McduGateway.name);
Expand All @@ -24,7 +24,7 @@ export class McduGateway implements OnGatewayInit, OnGatewayConnection {
async afterInit(server: Server) {
this.server = server;
this.logger.log('Remote MCDU websocket initialised');
this.logger.log(`Initialised on http://${await this.ipService.getLocalIp()}:${this.serverConf.port}${server.path}`);
this.logger.log(`Initialised on http://${await this.networkService.getLocalIp(true)}:${this.serverConf.port}${server.path}`);
}

handleConnection(client: WebSocket) {
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { platform } from 'os';
import { hideConsole } from 'node-hide-console-window';
import { ShutDownService } from './utilities/shutdown.service';
import { AppModule } from './app.module';
import { IpService } from './utilities/ip.service';
import { NetworkService } from './utilities/network.service';

declare const module: any;

Expand Down Expand Up @@ -58,7 +58,7 @@ async function bootstrap() {
await app.listen(port);

const logger = app.get(WINSTON_MODULE_NEST_PROVIDER);
logger.log(`FlyByWire SimBridge started on: http://${await app.get(IpService).getLocalIp()}:${port}`, 'NestApplication');
logger.log(`FlyByWire SimBridge started on: http://${await app.get(NetworkService).getLocalIp(true)}:${port}`, 'NestApplication');

if (platform() === 'win32' && isConsoleHidden) {
hideConsole();
Expand Down
41 changes: 0 additions & 41 deletions apps/server/src/utilities/ip.service.ts

This file was deleted.

188 changes: 188 additions & 0 deletions apps/server/src/utilities/network.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { AddressInfo, createConnection } from 'net';
import { platform } from 'os';
import { execSync } from 'child_process';
import { Injectable, Logger, OnApplicationShutdown } from '@nestjs/common';
import * as createMDNSServer from 'multicast-dns';
import { MulticastDNS, QueryPacket } from 'multicast-dns';
import { StringAnswer } from 'dns-packet';
import { RemoteInfo } from 'dgram';

@Injectable()
export class NetworkService implements OnApplicationShutdown {
private readonly logger = new Logger(NetworkService.name);

private mDNSServer: MulticastDNS | undefined;

constructor() {
this.startMDNSServer();
}

async startMDNSServer() {
const localIp = await this.getLocalIp();

if (!localIp) {
this.logger.warn('Couldn\'t determine local IP, mDNS server won\'t be started and simbridge.local will not be available');
return;
}

this.logger.log(`Local IP is ${localIp}`);

this.mDNSServer = createMDNSServer({
interface: localIp,
multicast: true,
reuseAddr: true,
});

this.mDNSServer.on('error', (error) => {
this.logger.warn(`mDNS server couldn't be started. Error: ${error.message}`);
});

this.mDNSServer.on('warning', (error) => {
this.logger.warn(`mDNS server warning: ${error.message}`);
});

this.mDNSServer.on('ready', () => {
this.makeAnnouncement(localIp);
});

this.mDNSServer.on('query', (query, client) => {
this.onMDNSQuery(query, client);
});
}

makeAnnouncement(localIp: string) {
this.logger.log('mDNS server started, simbridge.local is available');

// First, make two announcements, one second apart (https://www.rfc-editor.org/rfc/rfc6762.html#section-8.3)
this.mDNSServer.respond([{
name: 'simbridge.local',
type: 'A',
ttl: 1,
flush: true,
data: localIp,
}]);

setTimeout(() => {
this.mDNSServer.respond([{
name: 'simbridge.local',
type: 'A',
ttl: 1,
flush: true,
data: localIp,
}]);
}, 1000);
}

async onMDNSQuery(query: QueryPacket, client: RemoteInfo) {
// TODO: Handle AAAA records (https://www.rfc-editor.org/rfc/rfc6762.html#section-6.2) or send NSEC (https://www.rfc-editor.org/rfc/rfc6762.html#section-6.1)
if (query.questions.some((q) => q.type === 'A' && q.name === 'simbridge.local')) {
// Make sure that the IP is always up-to-date despite DHCP shenanigans
const localIp = await this.getLocalIp();

if (!localIp) {
this.logger.warn('Couldn\'t determine the local IP address, no mDNS answer will be sent');
return;
}

// Whether this is a simple mDNS resolver or not (https://www.rfc-editor.org/rfc/rfc6762.html#section-6.7)
const isSimpleResolver = client.port !== 5353;

const answer: StringAnswer = {
name: 'simbridge.local',
type: 'A',
ttl: isSimpleResolver ? 10 : 120,
data: localIp,
};

if (isSimpleResolver) {
// Simple resolvers require the ID and questions be included in the response, and the response to be sent via unicast
const response = {
id: query.id,
questions: query.questions,
answers: [answer],
};

this.mDNSServer.respond(response, client);
} else {
const response = { answers: [answer] };

this.mDNSServer.respond(response);
}
}
}

/**
* Get the local (LAN) IP address of the computer. By default it creates a TCP connection to api.flybywire.com:443
* but has fallbacks for Windows and Linux in case internet connection is not available.
* @param defaultToLocalhost Returns 'localhost' in case the IP address couldn't be determined, instead of undefined
* @returns the local IP address, undefined or 'localhost'
*/
async getLocalIp(defaultToLocalhost = false): Promise<string | undefined> {
return new Promise<string | undefined>((resolve) => {
const conn = createConnection({ host: 'api.flybywiresim.com', port: 443, timeout: 1000 })
.on('connect', () => {
resolve((conn.address() as AddressInfo).address);
})
.on('timeout', () => {
resolve(this.getLocalIpFallback(defaultToLocalhost));
})
.on('error', () => {
resolve(this.getLocalIpFallback(defaultToLocalhost));
});
});
}

onApplicationShutdown(_signal?: string) {
this.logger.log(`Destroying ${NetworkService.name}`);

if (this.mDNSServer) {
this.mDNSServer.destroy();
}
}

private getLocalIpFallback(defaultToLocalhost = true) {
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;

if (platform() === 'win32') {
let lines: string[];
try {
lines = execSync('route print 0.0.0.0', { encoding: 'utf-8', stdio: 'pipe' }).split('\n');
} catch (e) {
this.logger.warn(`Couldn't execute \`route\`. This is probably a bug. Details: ${e.stderr.trim()}`);
}

for (const [i, line] of lines.entries()) {
if (line.startsWith('Network Destination')) {
const ip = lines[i + 1].trim().split(' ').filter((p) => p !== '')[3].trim();

if (ipv4Regex.test(ip)) {
return ip;
}
}
}
} else if (platform() === 'linux') {
/** Example output:
* > 1.0.0.0 via 172.20.96.1 dev eth0 src 172.20.108.184 uid 1000
* > cache
*/
let parts: string[];
try {
parts = execSync('ip -4 route get to 1', { encoding: 'utf-8', stdio: 'pipe' }).split('\n')[0].split(' ');
} catch (e) {
this.logger.warn(`Couldn't execute \`ip\`. Make sure the \`iproute2\` (or equivalent) package is installed. Details: '${e.stderr.trim()}'`);
}

const ip = parts[parts.indexOf('src') + 1].trim();

if (ipv4Regex.test(ip)) {
return ip;
}
}

if (defaultToLocalhost) {
return 'localhost';
}

return undefined;
}
}
6 changes: 3 additions & 3 deletions apps/server/src/utilities/systray.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { hideConsole, showConsole } from 'node-hide-console-window';
import open = require('open');
import SysTray, { MenuItem } from 'systray2';
import { join } from 'path';
import { IpService } from './ip.service';
import { NetworkService } from './network.service';
import serverConfig from '../config/server.config';
import { ShutDownService } from './shutdown.service';

Expand All @@ -18,7 +18,7 @@ export class SysTrayService implements OnApplicationShutdown {
constructor(
@Inject(serverConfig.KEY)
private serverConf: ConfigType<typeof serverConfig>,
private ipService: IpService,
private networkService: NetworkService,
private shutdownService: ShutDownService,
) {
this.sysTray = new SysTray({
Expand Down Expand Up @@ -59,7 +59,7 @@ export class SysTrayService implements OnApplicationShutdown {
tooltip: 'Open the MCDU remote display with your default browser, using your local IP',
enabled: true,
click: async () => {
open(`http://${await this.ipService.getLocalIp()}:${this.serverConf.port}/interfaces/mcdu`);
open(`http://${await this.networkService.getLocalIp(true)}:${this.serverConf.port}/interfaces/mcdu`);
},
}],
};
Expand Down
6 changes: 3 additions & 3 deletions apps/server/src/utilities/utilities.module.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Module } from '@nestjs/common';
import { IpService } from './ip.service';
import { NetworkService } from './network.service';
import { UtilityController } from './utilities.controller';
import { FileService } from './file.service';
import { PrinterService } from './printer.service';
Expand All @@ -15,8 +15,8 @@ import { ShutDownService } from './shutdown.service';
SysTrayService,
MsfsService,
ShutDownService,
IpService,
NetworkService,
],
exports: [FileService, PrinterService, ShutDownService, IpService],
exports: [FileService, PrinterService, ShutDownService, NetworkService],
})
export class UtilitiesModule {}
Loading

0 comments on commit 784e810

Please sign in to comment.