Giter Club home page Giter Club logo

tequilapi-webapp-tutorial's Introduction

Mysterium node management app example

In this tutorial, we are going to create a web app that will allow us to manage and see the stats of multiple nodes. We will use the mysterium-vpn-js npm package to interact with our nodes and manage them.

Our web app will consist of 2 parts:

  • A react application
  • A web proxy

We need to use a web proxy due to CORS limitations. In the future, if we can set the CORS origin whitelist in our nodes, we will no longer need it. These limitations are set for security reasons.

To follow this tutorial you will need some requirements:

  • Node.js >= 14.0
  • npm
  • yarn
  • Mysterium node port 4449 forwarded in our router

We will start by building the proxy as it provides a base for our web app. We are going to use node.js with typescript to develop it.

Proxy creation

  1. Create a new directory called proxy where we are going to put the proxy code.

  2. Run yarn init inside the proxy directory to start a new project, you can use the default values.

  3. Install dependencies using yarn add @types/node typescript and yarn add -D ts-node.

  4. Create the tsconfig file using yarn tsc --init.

  5. Create an index.ts file.

  6. We will start adding some code! We are just going to use default libraries. To create a proxy we will need a server that listens to the requests, forwards them, and forwards the reply back to us with the correct CORS headers. To create the server we will type:

import { createServer, IncomingMessage, request, ServerResponse } from 'http';

createServer(onRequest).listen(5000);

function onRequest(req: IncomingMessage, res: ServerResponse) {
  
}
  1. Then we need to transform our target URL to the correct format. We will call the API using this URL format: http://localhost:5000/proxy/<ip>/<port>/tequilapi/<path> and the proxy will transform it to http://<ip>:<port>/tequilapi/<path>. To do this transformation we can use the following code:
  let url = req.url?.split('/')
  //validity checks
  if (url!.length < 6) {
    res.write('url path too short');
    res.end();
    return
  }
  
  let target_ip = url![2]
  let target_port = url![3]
  let target_path = '/' + url!.slice(4).join('/')

We could add more validity checks to make sure that the URL we are getting is correct, but for now, we will keep it simple.

  1. We now want to forward the request to our node, and then forward the response back as our API response but modifying some headers to avoid the CORS issues. To do so we can use this code:
  let cors_headers = {
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Methods': 'OPTIONS, POST, GET, PUT, DELETE',
    'Content-Type': '*/*',
    'Access-Control-Allow-Headers': '*'
  };

  var options = {
    hostname: target_ip,
    port: target_port,
    path: target_path,
    method: req.method,
    headers: req.headers
  };
  
  var proxy = request(options, function (target_res: IncomingMessage) {
    res.writeHead(target_res.statusCode!, cors_headers);
    target_res.pipe(res, {
      end: true
    });
  });

  req.pipe(proxy, {
    end: true
  });
  1. Also, we should add some code to handle request errors, this way, if the address we are given doesn't work we will notify it as an API response instead of crashing:
  proxy.on('error', function(err) {
    console.log("Request failed")
    res.writeHead(500, cors_headers);
    res.write("Request to node failed")
    res.end()
  });
  1. Finally, we will also need to answer the CORS preflight requests. A CORS preflight request is a CORS request that checks to see if the CORS protocol is understood and a server is aware of using specific methods and headers.
    For example, a client might be asking a server if it would allow a DELETE request, before sending a DELETE request, by using a preflight request. You can learn more about them using this link.
    To answer them saying that everything is okay we should use this code at the start of the onRequest function:
  if (req.method === 'OPTIONS') {
    // Pre-flight request. Reply successfully:
    res.writeHead(200, cors_headers);
    res.end();
    return;
  }

We completed our proxy! You can run it by using yarn ts-node ./index.ts or by adding this:

"scripts": {
  "build": "tsc",
  "start": "node ./index.js",
  "dev": "ts-node ./index.ts"
}

to the root of package.json and using yarn dev.

Webapp creation

  1. Let's start by going to the root of our project and running the create-react-app utility with the typescript option using npx create-react-app --template typescript frontend. This will create a new react project called frontend.

  2. Now, to install our dependencies we will use:

  • yarn add mysterium-vpn-js: to install the mysterium-vpn-js npm package
  • yarn add @material-ui/core: for style, so our web app is not ugly.
  • yarn add @material-ui/icons: for the refresh, start, and stop icons.
  1. We will create a very simple web app that will allow us to add nodes, see some stats and be able to turn the wireguard service on and off. To do so we will just modify the App.tsx file. We will start creating some variables to store our data. First, we will create a list of all our nodes addresses, a map to store the data of each node, and some helper functions to manipulate them:
  const [nodes, setNodes] = useState<Map<string,nodeData>>(new Map())
  const [nodesKeys, setNodesKeys] = useState<string[]>([])
  const updateNode = (k: string, v: nodeData) => {
    setNodes(new Map(nodes.set(k,v)));
  }
  const removeNode = (k: string) => {
    nodes.delete(k)
    setNodes(new Map(nodes));
    setNodesKeys((oldArray) => oldArray.filter(x => x != k))
  }
  1. Let's also create some state variables for our inputs of IP, port, and password, which is the data we need to connect to our nodes. Then we can also add text fields in our Html code for modifying the variables, as well as a button to trigger the addition of the node:
  const [ipField, setIpField] = useState('')
  const [portField, setPortField] = useState('')
  const [passwordField, setPasswordField] = useState('')
  <Box display="flex" flexDirection="column" alignItems="center">
    <TextField type="text" label="IP" value={ipField} onChange={(event) => setIpField(event.target.value)}></TextField>
    <TextField type="number" label="Port" value={portField} onChange={(event) => setPortField(event.target.value)} style={{marginTop: '0.5em'}}></TextField>
    <TextField type="password" label="Password" value={passwordField} onChange={(event) => setPasswordField(event.target.value)} style={{marginTop: '0.5em'}}></TextField>
    <Button variant="contained" color="primary" onClick={() => addOrUpdateNode(ipField, portField, passwordField)} style={{marginTop: '2em'}}>Add Node</Button>
  </Box>
  1. For a nice autocomplete experience, we can create a return type for the node data, this is all the data we will save in our nodes Map created in step 3:
interface nodeData {
  token: string,
  api: TequilapiClient,
  health: NodeHealthcheck,
  stats: SessionStatsAggregatedResponse,
  idenities: IdentityRef[],
  services: ServiceInfo[]
} 
  1. Now, let's create some functions to add or update a new node, this function will be triggered by our button:
  function addOrUpdateNode(ip: string, port: string, password?: string) {
    // Get data and save the result
    let address = ip + ':' + port
    getNodeData(ip, port, password).then(result => {
      if (password != undefined) {
        // Reset fields
        setIpField('')
        setPortField('')
        setPasswordField('')
      }
      updateNode(address, result)
      let index = nodesKeys.indexOf(address)
      if (index == -1) {
        setNodesKeys(oldArray => [...oldArray, address])
      }
    }).catch((e: any) => {
      // Need to request token again but we don't store the password, so we need to re-add node
      if (e instanceof TequilapiError && e.isUnauthorizedError) {
        alert("Request unauthorized, you need to add your node again.")
        removeNode(address)
      } else {
        if (e.originalResponseData) alert(e.originalResponseData)
        else alert(e.message)
      }
    })
  }

  async function getNodeData(ip: string, port: string, password?: string) : Promise<nodeData> {
    let address = ip + ':' + port
    let nodeApi: TequilapiClient | null = null
    let token: string | null = null
    if (password == undefined) {
      // If no password we assume the token is already in the api and saved
      if (nodes.has(address)) {
        token = nodes.get(address)!.token
        nodeApi = nodes.get(address)!.api
      }
    } else {
      // Create node client
      nodeApi = new TequilapiClientFactory('http://localhost:5000/proxy/' + ip + '/' + port + '/tequilapi').build()
      // Retrieve token
      let response = await nodeApi.authAuthenticate({ username: "myst", password: password }, true)
      token = response.token
    }
    if (nodeApi != null && token != null) {
      let health = await nodeApi.healthCheck()
      let stats = await nodeApi.sessionStatsAggregated()
      let idenities = await nodeApi.identityList()
      let services = await nodeApi.serviceList()
      return {
        api: nodeApi,
        health: health,
        stats: stats,
        idenities: idenities,
        services: services,
        token: token
      }
    }
    throw Error("Something went wrong when calling the node API")
  }
  1. Let's also add functions to start and stop the node. We will always use our first identity for starting, and the first service for stopping. This will be enough for the default node configuration:
  async function startNode(address: string) {
    if (nodes.has(address)) {
      let addressSplit = address.split(':')
      try {
        await nodes.get(address)!.api.serviceStart({
          providerId: nodes.get(address)!.idenities[0].id,
          type: "wireguard"
        })
        addOrUpdateNode(addressSplit[0], addressSplit[1])
      } catch (e) {
        // User doesn't have the updated status of the node, so we update it
        if (e instanceof TequilapiError && e.message.startsWith("Service already running")) {
          addOrUpdateNode(addressSplit[0], addressSplit[1])
        } else throw e
      }
    }
  }

  async function stopNode(address: string) {
    if (nodes.has(address)) {
      let addressSplit = address.split(':')
      try {
        await nodes.get(address)!.api.serviceStop(nodes.get(address)!.services[0].id)
        addOrUpdateNode(addressSplit[0], addressSplit[1])
      } catch (e) {
        // User doesn't have the updated status of the node, so we update it
        if (e instanceof TequilapiError && e.message.startsWith("Service not found")) {
          addOrUpdateNode(addressSplit[0], addressSplit[1])
        } else throw e
      }
    }
  }
  1. Now we will create the table for displaying the data, we can do it with Html code and a variable that will transform our node's data into table rows. We will also add a refresh button to trigger the update of all the nodes data:
  const nodesList = nodesKeys.map((key, i) => {
    if (nodes.has(key)) {
      let sumTokens = nodes.get(key)!.stats.stats.sumTokens/(10**18)
      let apiLoaded = nodes.get(key)!.api instanceof HttpTequilapiClient
      return  (
        <TableRow key={'row_'+i}>
          <TableCell component="th" scope="row">{key}</TableCell>
          {nodes.get(key)!.services.length > 0 ? <TableCell>{nodes.get(key)!.services[0].status}</TableCell> : <TableCell>Stopped</TableCell>}
          <TableCell>{nodes.get(key)!.health.uptime}</TableCell>
          <TableCell>{nodes.get(key)!.health.version}</TableCell>
          <TableCell>{nodes.get(key)!.stats.stats.count}</TableCell>
          <TableCell>{sumTokens.toFixed(2)}</TableCell>
          {apiLoaded ? (<TableCell>
            {nodes.get(key)!.services.length == 0 && <IconButton aria-label="start" onClick={() => startNode(key)}>
              <PlayArrow />
            </IconButton>}
            {nodes.get(key)!.services.length > 0 && <IconButton aria-label="stop" onClick={() => stopNode(key)}>
              <Stop />
            </IconButton>}
          </TableCell>) : (<TableCell>No API loaded</TableCell>)}
        </TableRow>)
    }
    else return
  })
  <Box display="flex" flexDirection="column" alignItems="center">
    <TableContainer component={Paper} style={{marginLeft: "10%", marginRight: "10%", width: "auto", marginTop: '2em'}}>
      <Table>
        <TableHead>
          <TableRow>
            <TableCell>Address</TableCell>
            <TableCell>Service status</TableCell>
            <TableCell>Uptime</TableCell>
            <TableCell>Version</TableCell>
            <TableCell>Sessions</TableCell>
            <TableCell>Total Earnings (MYSTT)</TableCell>
            <TableCell>
              {nodesList.length > 0 && <IconButton aria-label="refresh" onClick={refreshAll}>
                <Refresh />
              </IconButton>}
            </TableCell>
          </TableRow>
        </TableHead>
        <TableBody >
          {nodesList}
        </TableBody>
      </Table>
      </TableContainer>
  </Box>
  1. Of course, now we need a function to update all the nodes, which is very simple:
  function refreshAll() {
    for (let nodeKey of nodesKeys) {
      let addressSplit = nodeKey.split(':')
      addOrUpdateNode(addressSplit[0], addressSplit[1])
    }
  }
  1. Now we have a working web app that can see node's data, and start or stop the service. There is only one thing that is going to bother us, which is that when we refresh the page all of our added nodes are reset and we have to add them again.
    To mitigate this problem we can persist the data in the webpage local storage. To do it we will use some UseEffect hooks, which will be called when we update our data to save it, and when we load our page to retrieve the data if there is some saved.
    We will also use the hooks to check if no API is initialized, which would mean that nodes data has just been retrieved from local storage, and therefore we need to refresh all the data to update it and reload the clients.
  // Retrieve data from local storage
  useEffect(() => {
    if(localStorage.getItem('nodes') && localStorage.getItem('nodesKeys')) {
      setNodes(new Map<string,nodeData>(JSON.parse(localStorage.getItem('nodes')!)))
      setNodesKeys(JSON.parse(localStorage.getItem('nodesKeys')!) as string[])
    }
  }, []);

  // Save node data to local storage
  useEffect(() => {
    // If none have API initialized call refreshAll
    if (nodesKeys.map(key => {
      if (nodes.has(key) && !(nodes.get(key)!.api instanceof HttpTequilapiClient))
        return false
      else
        return true
    }).every(x => x == false)) refreshAll()
    localStorage.setItem('nodes', JSON.stringify(Array.from(nodes.entries())));
  }, [nodes, nodesKeys]);

  useEffect(() => {
    localStorage.setItem('nodesKeys', JSON.stringify(nodesKeys));
  }, [nodesKeys]);
  1. Another problem we have now is that the client used to call the API is not persisted in storage, but we have enough information to recreate it. We will create a function to do so and we will call it in the getNodeData function if the client is not created:
  async function checkNodeAPI(nodeApi: TequilapiClient | null, ip: string, port: string, token: string): Promise<TequilapiClient> {
    // If it was retrived from localStorage and has no client we re-create it
    if (!(nodeApi instanceof HttpTequilapiClient)) {
      nodeApi = new TequilapiClientFactory('http://localhost:5000/proxy/' + ip + '/' + port + '/tequilapi').build()
      nodeApi.authSetToken(token)
    }
    return nodeApi
  }

And then this:

  // If no password we assume the token is already in the api and saved
  if (nodes.has(address)) {
    token = nodes.get(address)!.token
    nodeApi = nodes.get(address)!.api
  }

Will become:

  // If no password we assume the token is already in the api and saved
  if (nodes.has(address)) {
    token = nodes.get(address)!.token
    nodeApi = await checkNodeAPI(nodes.get(address)!.api, ip, port, token)
  }

We have finally completed our management web app! Many improvements could be made, but I hope this guide provided inspiration and a good building base for your projects using the node's tequila API!

To make running both projects easier we can add this line in the package.json scripts of the frontend project:

"dev": "yarn --cwd ../proxy dev & yarn start"

And then you can run the project using yarn dev in the frontend folder.

Happy hacking!

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.