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.
Contents
- Creating a new Typescript project
- Configure VSCode for debugging
- Setting up Git
- Cleaning up JS Code
- Writing unit tests
- Publishing the NPM Module
- Using the module
- 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
todist
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 ofcompilerOptions
with*.ts
inside it (telling the compiler to include our TS files) - Create an
exclude
array withnode_modules
anddist
(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 bedist/index.js
(compiled JS now goes to thedist
folder) - Add a
types
property with the valuedist/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 executedist/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).
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.
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 messagegit push origin master
- to push this commit to the remote repository that we added asorigin
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.
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!
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
).
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.
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
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.