Jack Reeve
Version 1
Published in
13 min readDec 4, 2023

--

Welcome to Part 3 of the Reverse Engineering My Router series.

If you haven’t already, check out Part 1 and Part 2.

In this episode, we’ll be converting the work from the last two posts into a usable NPM module for others to use in their projects.

Photo by Kevin Ku on Unsplash

Contents

  1. Creating a new Typescript project
  2. Configure VSCode for debugging
  3. Setting up Git
  4. Cleaning up JS Code
  5. Writing unit tests
  6. Publishing the NPM Module
  7. Using the module
  8. Wrapping up

Creating a new Typescript project

First, we’ll want to create a new folder for our project. I’ll name this npm-gx90-api.

Inside the folder, we want to initialize a new npm project with npm init. The CLI will take us through some basic configurations for our package.json. Feel free to add proper values in for these questions, I prefer to accept the defaults and manually edit the package.json once I'm ready to commit/publish.

Install typescript and type definitions for node as a dev dependency with npm i typescript @types/node --save-dev and npx tsc --init to set up typescript support in this project.

This will create a tsconfig.json file in the root of the project containing options to modify typescripts compilation behaviour. We will need to make a few changes to the defaults:

  • Uncomment and change outDir to dist to separate compiled JS files from our source TS ones
  • Uncomment declaration so that our types are exported and accessible to consumers of our module
  • Uncomment resolveJsonModule so we can import static JSON files (useful during testing for fixtures)
  • Uncomment sourceMap so we can pause on breakpoints when debugging
  • Create an include array outside of compilerOptions with *.ts inside it (telling the compiler to include our TS files)
  • Create an exclude array with node_modules and dist (we want to exclude generated/downloaded files)
{
"compilerOptions": {
- // "outDir": "./",
+ "outDir": "dist",
- // "declaration": true,
+ "declaration": true,
- // "resolveJsonModule": true,
+ "resolveJsonModule": true,
- // "sourceMap": true
+ "sourceMap": true
},
+ "include": [
+ "*.ts"
+ ]
+ "exclude": [
+ "node_modules",
+ "dist"
+ ]
}

Back in our package.json we need to make the following changes:

  • Change main to be dist/index.js (compiled JS now goes to the dist folder)
  • Add a types property with the value dist/index.d.ts to tell consumers of our module that type definitions start in this file
  • Change the start script to npx tsc && node dist/index.js (compile and then execute dist/index.js in node)
{
- "main": "index.js"
+ "main": "dist/index.js"
+ "types": "dist/index.d.ts",
"scripts": {
+ "start": "tsc && node dist/index.js"
"test": "echo \"Error: no test specified\" && exit 1"
}
}

Note that since this is destined to be an npm module rather than a standalone node app it’s unlikely that we’ll ever make use of the start script.

Configure VSCode for debugging

To enable us to place breakpoints and step through our code, we need a launch configuration. Create a .vscode/launch.json file that looks like this:

{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"skipFiles": [
"<node_internals>/**"
],
"program": "${workspaceFolder}\\dist\\index.js",
"preLaunchTask": "tsc: build - tsconfig.json",
"outFiles": [
"${workspaceFolder}/dist/**/*.js"
],
"outputCapture": "std"
}
]
}

Similarly to our start script in the package.json, this tells VSCode to compile using the tsconfig.json and then run dist/index.js with debugging support.

Setting up Git

Git allows us to track changes and store our source code so that we’re not reliant on a single device for publishing new versions to NPM. Declaring a public git repo in the package.json also promotes trust as developers can vet the code before downloading it.

Set up a new repo on your git hoster of choice (mine is GitLab). I’ve chosen to create a blank repo so I can push my code straight to it (“Initialize with a README” will create a commit that you’ll need to pull down locally first).

GitLab’s create a blank project page

Grab the cloning link (preferably SSH but HTTPS will do too) and run git init and git remote add origin <LINK> to add a remote.

Terminal commands for setting up a local git repo

You’ll want to add a .gitignore file so that certain generated files (like node_modules) are not uploaded on commit. A basic .gitignore might look like this:

# Don't commit our dependancies, they can be grabbed with an npm install after cloning
node_modules
# No need to commit generated files
dist
# coverage files generated by any testing tools
coverage
.nyc_output
# Env file used for potentially sensitive environment variables
.env

You can commit with

  • git add . - to add all modified files that do not match the .gitignore contents)
  • git commit -m "init" - to commit with a message
  • git push origin master - to push this commit to the remote repository that we added as origin earlier

Cleaning up JS code

I’ve rewritten the code in TS to give us and the consumers of this module better type visibility. In the earlier parts I used the same libraries as the web code to more easily match functionality. One of these modules CryptoJS has been deprecated in favour of node's built in crypto module, so I've migrated over to that to reduce the chances of vulnerabilities and overall dependency size.

Missing type declarations for node-bignumber

We’re still using node-bignumber for RSA logic due to the format of the public key we get from the router. This library does not come with type information bundled and the compiler will complain that it doesn't know what node-bignumber contains.

Compiler error about missing type definitions for node-bignumber

In most cases this can be solved with an npm i @types/<PACKAGE> --save-dev call as suggested in the error, this will fetch type definitions from the DefinitelyTyped project if they exist. This is not the case for node-bignumber. Instead, do as also suggested and create a type definition file (*.d.ts) ourselves. The file name does not matter so long as it ends with .d.ts. Fill it with the following:

declare module 'node-bignumber' {
export class Key {
setPublic(n: string, e: string): void
encrypt(data: string): string
n: string
e: string
}
}

This is not the complete type definition, but it is all that our module cares for. This will shut the compiler up and give us intellisense completion based on the contents of the above.

Note that the compiler will blindly trust this definition file so be careful to get its contents right. Just because it compiles does not mean it will execute at runtime!

Intellisense working on node-bignumber

Exporting types

The biggest change made in preparing this for an npm module was to clean up method signatures and bundle credentials into an object rather than passing them through as individual parameters. I’ve created the following types to achieve this, and methods now take an object of type Credentials.

export type AES = {
key: string
iv: string
}

export type Credentials = {
aesKey: AES
rsaKey: RSA.Key
password: string
seq: string
hash: string
sessionId: string
stok: string
}

These have the export keyword in front of them so that they're accessible outside of the module. Omitting export here would restrict consumers from being able to use this type on their own objects, but they would still be able to see what Credentials looks like when an object of that type is returned from a function within this module.

These have the export keyword in front of them so that they're accessible outside of the module. Omitting export here would restrict consumers from being able to use this type on their own variables (they would still know that our methods return this type).

Earlier we enabled declaration in the tsconfig.json. This generates a dist/index.d.ts file containing all of our custom types as well as method signatures and any other references. The typescript compiler is clever enough to work without this file in most cases, however, it won't pick up on that bignumnber.d.ts file we made earlier to get around node-bignumber missing type definitions. The dist/index.d.ts will expose this for us (the first line below).

/// <reference path="../bignumber.d.ts" />
import * as RSA from 'node-bignumber';
export type AES = {
key: string;
iv: string;
};

export type Credentials = {
aesKey: AES;
rsaKey: RSA.Key;
password: string;
seq: string;
hash: string;
sessionId: string;
stok: string;
};
export declare function getLoginEncryptKey(): Promise<RSA.Key>;
export declare function getRsaKeyAndSec(): Promise<{
rsaKey: RSA.Key;
seq: string;
}>;
export declare function getHash(password: string): string;
export declare function login(password: string): Promise<Credentials>;
export declare function getDhcpLeases(creds: Credentials): Promise<any[]>;
export declare function getDevices(creds: Credentials): Promise<any[]>;

See the final code here https://gitlab.com/jack.reevies/gx90-api/-/blob/master/index.ts

Writing unit tests

Unit testing our code helps to ensure that the individual parts of our API work properly and continue to work in future when code is edited. It’s also a useful tool for finding breaking changes if the GX90 firmware receives an update and changes something about the API.

Configuration

In the package.json we need to edit the test script to tell Node to compile and then execute our test file

{
"scripts": {
"start": "tsc && node dist/index.js",
- "test": "echo \"Error: no test specified\" && exit 1"
+ "test": "tsc && node --test dist/test/index.spec.js"
},
}

It would also be nice to step through debug these tests, we want to add the following into .vscode/launch.json:

{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"skipFiles": [
"<node_internals>/**"
],
"program": "${workspaceFolder}\\dist\\index.js",
"preLaunchTask": "tsc: build - tsconfig.json",
"outFiles": [
"${workspaceFolder}/dist/**/*.js"
],
"outputCapture": "std"
},
+ {
+ "type": "node",
+ "request": "launch",
+ "name": "Unit test",
+ "preLaunchTask": "tsc: build - tsconfig.json",
+ "skipFiles": ["<node_internals>/**"],
+ "program": "${file}",
+ "args": ["--test"]
+ }
]
}

Similarly to our test script, this will tell VSCode to compile our code and then execute the currently open file in test mode.

Testing

Jest is a favourite test runner for Node based projects, however, node actually has its own test runner and tools containing everything we’ll need. There are tons of tutorials out there on setting up Jest and the like, but remarkably few for node’s internal toolset. I’m a fan of simplicity so lets keep our dependencies few.

All of our main code is in index.ts so we'll create a test/index.spec.ts file to house our tests for functions inside index.ts. Create a fixtures directory inside test, our mocked responses can live here to keep our test files small and readable.

We want to import our “real” code as a module. Both import and require will work here although require is more flexible as it allows for mocking of functions exported by the module.

Let’s start with testing the first thing our code will do when trying to log in (that is, requesting the first RSA public key from the router).

Only public/exported methods can be called directly, so export this if it isn’t already. It’s bad practice to make code public just for the sake of testing as it should be tested implicitly through the caller, however, its easier to get started this way and illustrates flow better. If you would prefer keeping methods private but calling them directly in a test context, look into rewire

Below is the method we are testing, it’s practically just a network call that returns a constructed RSA key from the response.

// index.ts
export async function getLoginEncryptKey(): Promise<RSA.Key> {
const res = await fetch("http://tplinkwifi.net/cgi-bin/luci/;stok=/login?form=keys", {
"headers": getHeaders(),
"body": "operation=read",
"method": "POST"
});

const json = await res.json()
const key = new RSA.Key()
key.setPublic(json.data.password[0], json.data.password[1])
return key
}
// test/index.spec.ts
import { mock, test } from "node:test"
import assert, { fail } from "assert"

// mut for module under test as typeof... to get type annotations
const mut = require('..') as typeof import('..')

// Fixtures containing response data from mocked network calls
import loginFormKeysResponse from './fixtures/loginFormKeysResponse.json'

test('getLoginEncryptKey', async () => {
mock.method(global, 'fetch', async (url: string, opts: RequestInit) => {
const expectedUrl = 'http://tplinkwifi.net/cgi-bin/luci/;stok=/login?form=keys'
const expectedBody = 'operation=read'
if (url === expectedUrl && opts.body === expectedBody) {
return { json: () => Promise.resolve(loginFormKeysResponse) }
}
fail(`Unexpected url: ${url} or body: ${opts.body}`)
})

const key = await mut.getLoginEncryptKey()
assert.equal(key.n.toString(), '<redacted>') // Real value is far too long to show
assert.equal(key.e.toString(), '65537')
})

Above is the test code. We first define a test using the test method with a name and a callback function.

We don’t want to make any real network calls and we want to control the response so we can test that our code is interpreting it correctly. We use mock.method to 'replace' a method with our own. Here we mock the fetch method on the global scope (node's built in fetch) and give it a custom function that returns a snapshotted response if the method was called with the expected parameters.

If the URL and body are correct, we return an object representing a response. We can be lazy/efficient in this case and just mock out the json method of the response as that is all the calling code cares for. The json method returns a promise for a JSON object, we can do this with Promise.resolve(data) (create an already resolved promise returning data). If the input URL or body are not what we expected, we can explicitly fail the test withfail(reason).

loginFormKeysResponse is imported from a JSON file that contains the response from the faked network call. Ideally mock data should be as realistic as possible (while also not doxxing anyone). A good tip is to grab a real response from an actual network call and modify it where necessary. This is the approach I've taken with these mocks (especially when dealing with encrypted responses - these are hard to manually craft).

At the end, we simply assert that our return values are what we expected and the test will pass or fail. We can either start debugging to step through the test or run npm test (referencing our test script in package.json).

Test output showing 4 passing tests

See the full test code here https://gitlab.com/jack.reevies/gx90-api/-/blob/master/test/index.spec.ts

Publishing the NPM module

First you’ll need an npmjs account, go sign up there and then return to the terminal with npm login where you'll be asked to login through a special link.

Once authorised, make sure the package.json is correct. version matters here as you cannot re-publish over a version. Versions follow Semantic Versioning format (major.minor.patch). I'm going to change my name to "@spacepumpkin/gx90-api" so that the module gets grouped with my others (where "@spacepumpkin" is the organisation). I'm also going to add a description, git repo and author.

I recommend adding a README.md file to the root of the project. This will be picked up both by your git hoster (probably) and npm itself. Describing how to install and use will make it easier on any developers consuming this module to get started. Failing that, they always have those generated typescript definitions to help them figure it out themselves.

My final package.json looks like this

{
"name": "@spacepumpkin/gx90-api",
"version": "0.0.1",
"description": "Provides functionality for interacting with TPLink routers running GX90 firmware",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"start": "npx tsc && node dist/index.js",
"test": "npx tsc && node --test dist/test/index.spec.js"
},
"repository": {
"type": "git",
"url": "git+https://gitlab.com/jack.reevies/gx90-api.git"
},
"author": "SpacePumpkin",
"license": "ISC",
"devDependencies": {
"@types/node": "^20.10.0",
"typescript": "^5.3.2"
},
"dependencies": {
"node-bignumber": "^1.2.2"
}
}

We can also add a .npmignore file to ignore some files when packaging the module. By default npm will use the .gitignore file (so our node_modules won't be published anyway) but can use .npmignore if you want to be more specific. We want our test files to be committed to Git but there's no need for them to be included in the npm package, conversely, we are excluding the entire dist folder from Git but we do want this in our npm module (as the JS code is what will actually run), so our .npmignore should look like this:

node_modules
.vscode
.env
dist/test
test
coverage

WARNING npm will ignore the .gitignore file if an .npmignore is present. So please make sure to exclude any sensitive files from both the .gitignore and the .npmignore

Finally, run npm publish --access=public to make it official. This will publish to npm as a public module, omit the --access=public for a private module, but these require a paid account.

Published module on npm

Using the module

We can verify that the new module works as expected by adding it as a dependency in a new project and using it. Consider this example code from the README

import { login, getDevices } from '@spacepumpkin/gx90-api'

async function main() {
const creds = await login('password')
const devices = await getDevices(creds)
console.log(devices)
}

main()

Results in the following output, demonstrating that it can login and fetch a list of connected devices

Output in terminal when consuming our created module

Wrapping up

In this series we have reverse engineered a web frontend to programmatically get useful data from the router and then wrapped this inside a public npm module. We have demonstrated how easy it is to use our hard work simply by importing the module and making two function calls with the router’s password. The complexity of encryption/decryption and figuring out what to send to the router has been abstracted behind simply login(password) and getDevices.

I hope you’ve enjoyed this journey with me and that you now feel empowered and confident to reverse engineer / automate some tasks in your own life. If you have any feedback, questions, or suggestions for me, please feel free to leave a comment below. Thank you for reading and happy hacking! 😊

About the author

Jack Reeve is a Full Stack Software Developer here at Version 1.

--

--