Skip to content

Commit

Permalink
Add commands for manipulating IPv6 host exposure settings on Arris mo…
Browse files Browse the repository at this point in the history
…dems (#160)

* Add host-exposure:get command for Arris modems

* Add host-exposure:set command for Arris modems

* Add host-exposure:(en|dis)able commands for Arris

* Add topic description for `host-exposure` commands

Without it, the help description just lists the description of the first
subcommand in lexical order (`disable` in this case).
  • Loading branch information
phesch authored Jul 4, 2024
1 parent ad41de6 commit 1bf7bc7
Show file tree
Hide file tree
Showing 8 changed files with 402 additions and 4 deletions.
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@
"commands": "./lib/commands",
"plugins": [
"@oclif/plugin-help"
]
],
"topics": {
"host-exposure": { "description": "Manage IPv6 host exposure settings." }
}
},
"scripts": {
"build": "shx rm -rf lib && tsc -b",
Expand Down
51 changes: 51 additions & 0 deletions src/commands/host-exposure/disable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {Flags} from '@oclif/core'
import Command from '../../base-command'
import {toggleHostExposureEntries} from '../../modem/host-exposure';

export default class DisableHostExposureEntries extends Command {
static description =
'Disable a set of host exposure entries';

static examples = [
`$ vodafone-station-cli host-exposure:disable -p PASSWORD [ENTRY NAME | [ENTRY NAME...]]`,
];

static args = [
{
name: "entries",
description: 'Host exposure entries to disable. Pass no names to disable every existing entry.',
required: false,
}
];

static flags = {
password: Flags.string({
char: 'p',
description: 'router/modem password',
}),
};

static strict = false

async run(): Promise<void> {
const {argv, flags} = await this.parse(DisableHostExposureEntries)

const password = flags.password ?? process.env.VODAFONE_ROUTER_PASSWORD
if (!password || password === '') {
this.log(
'You must provide a password either using -p or by setting the environment variable VODAFONE_ROUTER_PASSWORD'
)
this.exit()
}

try {
await toggleHostExposureEntries(false, argv as string[], password!, this.logger)
}
catch (error) {
this.error(error as Error, {message: 'Something went wrong.'})
}


this.exit()
}
}
51 changes: 51 additions & 0 deletions src/commands/host-exposure/enable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {Flags} from '@oclif/core'
import Command from '../../base-command'
import {toggleHostExposureEntries} from '../../modem/host-exposure';

export default class EnableHostExposureEntries extends Command {
static description =
'Enable a set of host exposure entries';

static examples = [
`$ vodafone-station-cli host-exposure:enable -p PASSWORD [ENTRY NAME | [ENTRY NAME...]]`,
];

static args = [
{
name: "entries",
description: 'Host exposure entries to enable. Pass no names to enable every existing entry.',
required: false,
}
];

static flags = {
password: Flags.string({
char: 'p',
description: 'router/modem password',
}),
};

static strict = false

async run(): Promise<void> {
const {argv, flags} = await this.parse(EnableHostExposureEntries)

const password = flags.password ?? process.env.VODAFONE_ROUTER_PASSWORD
if (!password || password === '') {
this.log(
'You must provide a password either using -p or by setting the environment variable VODAFONE_ROUTER_PASSWORD'
)
this.exit()
}

try {
await toggleHostExposureEntries(true, argv as string[], password!, this.logger)
}
catch (error) {
this.error(error as Error, {message: 'Something went wrong.'})
}


this.exit()
}
}
64 changes: 64 additions & 0 deletions src/commands/host-exposure/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {Flags} from '@oclif/core'
import Command from '../../base-command'
import {discoverModemIp, ModemDiscovery} from '../../modem/discovery'
import {modemFactory} from '../../modem/factory'
import {Log} from '../../logger'
import {HostExposureSettings} from '../../modem/modem';


export async function getHostExposureSettings(password: string, logger: Log): Promise<HostExposureSettings> {
const modemIp = await discoverModemIp()
const discoveredModem = await new ModemDiscovery(modemIp, logger).discover()
const modem = modemFactory(discoveredModem, logger)
try {
await modem.login(password)
const settings = await modem.getHostExposure()
return settings
} catch (error) {
console.error('Could not get host exposure settings from modem.', error)
throw error
} finally {
await modem.logout()
}
}

export default class GetHostExposure extends Command {
static description =
'Get the current IPV6 host exposure settings';

static examples = [
`$ vodafone-station-cli host-exposure:get -p PASSWORD
{JSON data}
`,
];

static flags = {
password: Flags.string({
char: 'p',
description: 'router/modem password',
}),
};

async run(): Promise<void> {
const {flags} = await this.parse(GetHostExposure)

const password = flags.password ?? process.env.VODAFONE_ROUTER_PASSWORD
if (!password || password === '') {
this.log(
'You must provide a password either using -p or by setting the environment variable VODAFONE_ROUTER_PASSWORD'
)
this.exit()
}
try {
const settings = await getHostExposureSettings(password!, this.logger)
const settingsJSON = JSON.stringify(settings, undefined, 4)
this.log(settingsJSON)
}
catch (error) {
this.error(error as Error, {message: 'Something went wrong.'})
}


this.exit()
}
}
72 changes: 72 additions & 0 deletions src/commands/host-exposure/set.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import {readFile} from 'node:fs/promises'
import {Flags} from '@oclif/core'
import Command from '../../base-command'
import {discoverModemIp, ModemDiscovery} from '../../modem/discovery'
import {modemFactory} from '../../modem/factory'
import {Log} from '../../logger'
import {HostExposureSettings} from '../../modem/modem';


export async function setHostExposureSettings(settings: HostExposureSettings, password: string, logger: Log): Promise<HostExposureSettings> {
const modemIp = await discoverModemIp()
const discoveredModem = await new ModemDiscovery(modemIp, logger).discover()
const modem = modemFactory(discoveredModem, logger)
try {
await modem.login(password)
await modem.setHostExposure(settings)
return settings
} catch (error) {
logger.error('Could not get host exposure settings from modem.', error)
throw error
} finally {
await modem.logout()
}
}

export default class SetHostExposure extends Command {
static description =
'Set the current IPV6 host exposure settings from a JSON file';

static examples = [
`$ vodafone-station-cli host-exposure:set -p PASSWORD <FILE>`,
];

static args = [
{
name: "file",
description: "input JSON file",
required: true,
}
];

static flags = {
password: Flags.string({
char: 'p',
description: 'router/modem password',
}),
};

async run(): Promise<void> {
const {args, flags} = await this.parse(SetHostExposure)

const password = flags.password ?? process.env.VODAFONE_ROUTER_PASSWORD
if (!password || password === '') {
this.log(
'You must provide a password either using -p or by setting the environment variable VODAFONE_ROUTER_PASSWORD'
)
this.exit()
}
try {
const newSettingsJSON = await readFile(args.file, {encoding: 'utf8'})
const newSettings = JSON.parse(newSettingsJSON) as HostExposureSettings
await setHostExposureSettings(newSettings, password!, this.logger)
this.log("New host exposure settings set.")
}
catch (error) {
this.error(error as Error, {message: 'Something went wrong.'})
}


this.exit()
}
}
102 changes: 100 additions & 2 deletions src/modem/arris-modem.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {Log} from '../logger'
import {DocsisStatus, HumanizedDocsis31ChannelStatus, HumanizedDocsisChannelStatus, Modem, DocsisChannelType} from './modem'
import {DocsisStatus, HumanizedDocsis31ChannelStatus, HumanizedDocsisChannelStatus, Modem, DocsisChannelType, ExposedHostSettings, HostExposureSettings, Protocol} from './modem'
import {decrypt, deriveKey, encrypt} from './tools/crypto'
import {CryptoVars, extractCredentialString, extractCryptoVars, extractDocsisStatus} from './tools/html-parser'

Expand Down Expand Up @@ -34,6 +34,35 @@ export interface SetPasswordResponse {
p_waitTime?: number;
}

interface ArrisGetHostExposureSettings {
hostExposure: ArrisGetExposedHostSettings[];
dhcpclient: any;
}

interface ArrisGetExposedHostSettings {
ServiceName: string;
MAC: string;
Protocol: Protocol;
StartPort: number;
EndPort: number;
Status: string;
Index: string;
}

interface ArrisSetHostExposureSettings {
hEditRule: ArrisSetExposedHostSettings[];
}

interface ArrisSetExposedHostSettings {
name: string;
macAddress: string;
protocol: Protocol;
startPort: number;
endPort: number;
enable: string;
index: string;
}

export function normalizeChannelStatus(channelStatus: ArrisDocsisChannelStatus): HumanizedDocsisChannelStatus | HumanizedDocsis31ChannelStatus {
const frequency: Record<string, number> = {}
if (channelStatus.ChannelType === 'SC-QAM') {
Expand All @@ -45,7 +74,7 @@ export function normalizeChannelStatus(channelStatus: ArrisDocsisChannelStatus):
frequency.frequencyStart = Number(ofdmaFrequency[0])
frequency.frequencyEnd = Number(ofdmaFrequency[1])
}

const powerLevel = parseFloat(channelStatus.PowerLevel.split("/")[0]);
const snr = parseInt(`${channelStatus.SNRLevel ?? 0}`, 10);
return {
Expand Down Expand Up @@ -237,5 +266,74 @@ export class Arris extends Modem {
throw error
}
}

_convertGetExposedHostSettings(settings: ArrisGetExposedHostSettings): ExposedHostSettings {
return {
serviceName: settings.ServiceName,
mac: settings.MAC,
protocol: settings.Protocol,
startPort: settings.StartPort,
endPort: settings.EndPort,
enabled: settings.Status === "Enabled" ? true : false,
index: Number.parseInt(settings.Index),
} as ExposedHostSettings
}

async getHostExposure(): Promise<HostExposureSettings> {
try {
const {data} = await this.httpClient.get(
'php/net_ipv6_host_exposure_data.php?{"hostExposure":{},"dhcpclient":{}}',
{
headers: {
csrfNonce: this.csrfNonce,
Referer: `http://${this.modemIp}/?net_ipv6_host_exposure&mid=NetIPv6HostExposure`,
Connection: 'keep-alive',
},
}
)
return {
hosts: (data as ArrisGetHostExposureSettings)
.hostExposure.map(this._convertGetExposedHostSettings)
} as HostExposureSettings
}
catch (error) {
this.logger.error("Could not get host exposure data:\n", error)
throw error
}
}

_convertSetExposedHostSettings(settings: ExposedHostSettings): ArrisSetExposedHostSettings {
return {
name: settings.serviceName,
macAddress: settings.mac,
protocol: settings.protocol,
startPort: settings.startPort,
endPort: settings.endPort,
enable: settings.enabled ? "Enabled" : "Disabled",
index: settings.index.toString(),
}
}

async setHostExposure(settings: HostExposureSettings): Promise<void> {
const convertedSettings =
{hEditRule: settings.hosts.map(this._convertSetExposedHostSettings)} as ArrisSetHostExposureSettings
try {
await this.httpClient.post(
'php/ajaxSet_net_ipv6_host_exposure_data.php',
convertedSettings,
{
headers: {
csrfNonce: this.csrfNonce,
Referer: `http://${this.modemIp}/?net_ipv6_host_exposure&mid=NetIPv6HostExposure`,
Connection: 'keep-alive',
}
}
)
}
catch (error) {
console.error("Could not set host exposure data:\n", error)
throw error
}
}
}

Loading

0 comments on commit 1bf7bc7

Please sign in to comment.