Controlling a GPIO through an ESP8266-based web server

Tags:

It’s very hot and humid in our office, and I’d like to be able to turn on the A/C remotely half a hour before I get there, and do it in a reasonably safe way, viz. without exposing the internal network to whoever discovers an RCE in the IoT Fad Device of the Week.

I’ve done this using ESP8266 and MicroPython (primarily to avoid dealing with the awkward Xtensa native code toolchain as well as to avoid parsing HTTP in C).

The code is as follows:

esp8266-gpio-ctrl.py (download)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
# Begin configuration
TITLE    = "Air conditioner"
GPIO_NUM = 5
STA_SSID = "[redacted]"
STA_PSK  = "[redacted]"
# End configuration

import network
import machine
import usocket

ap_if = network.WLAN(network.AP_IF)
if ap_if.active(): ap_if.active(False)
sta_if = network.WLAN(network.STA_IF)
if not ap_if.active(): sta_if.active(True)
if not sta_if.isconnected(): sta_if.connect(STA_SSID, STA_PSK)

pin = machine.Pin(GPIO_NUM)
pin.init(pin.OUT)
pin.low()

def ok(socket, query):
    socket.write("HTTP/1.1 OK\r\n\r\n")
    socket.write("<!DOCTYPE html><title>"+TITLE+"</title><body>")
    socket.write(TITLE+" status: ")
    if pin.value():
        socket.write("<span style='color:green'>ON</span>")
    else:
        socket.write("<span style='color:red'>OFF</span>")
    socket.write("<br>")
    if pin.value():
        socket.write("<form method='POST' action='/off?"+query.decode()+"'>"+
                     "<input type='submit' value='turn OFF'>"+
                     "</form>")
    else:
        socket.write("<form method='POST' action='/on?"+query.decode()+"'>"+
                     "<input type='submit' value='turn ON'>"+
                     "</form>")

def err(socket, code, message):
    socket.write("HTTP/1.1 "+code+" "+message+"\r\n\r\n")
    socket.write("<h1>"+message+"</h1>")

def handle(socket):
    (method, url, version) = socket.readline().split(b" ")
    if b"?" in url:
        (path, query) = url.split(b"?", 2)
    else:
        (path, query) = (url, b"")
    while True:
        header = socket.readline()
        if header == b"":
            return
        if header == b"\r\n":
            break

    if version != b"HTTP/1.0\r\n" and version != b"HTTP/1.1\r\n":
        err(socket, "505", "Version Not Supported")
    elif method == b"GET":
        if path == b"/":
            ok(socket, query)
        else:
            err(socket, "404", "Not Found")
    elif method == b"POST":
        if path == b"/on":
            pin.high()
            ok(socket, query)
        elif path == b"/off":
            pin.low()
            ok(socket, query)
        else:
            err(socket, "404", "Not Found")
    else:
        err(socket, "501", "Not Implemented")

server = usocket.socket()
server.bind(('0.0.0.0', 80))
server.listen(1)
while True:
    try:
        (socket, sockaddr) = server.accept()
        handle(socket)
    except:
        socket.write("HTTP/1.1 500 Internal Server Error\r\n\r\n")
        socket.write("<h1>Internal Server Error</h1>")
    socket.close()

It defines three endpoints: GET / that returns status, POST /on and POST /off that change the status. All of the endpoints accept an arbitrary query string, and the generated HTML propagates it to other endpoints, which makes it easy to add authentication using e.g. Nginx.

It can be flashed to an ESP8266 module with MicroPython already installed using the following (very dirty) script:

flash.py
1
2
3
4
5
6
7
8
#!/usr/bin/env python
# usage: ./flash.py firmware.py
import os, sys
os.system("stty -F /dev/ttyUSB0 115200")
with open(sys.argv[1]) as f:
   code = f.read()
with open('/dev/ttyUSB0', 'w') as f:
   f.write('with open("/main.py", "w") as f: f.write('+repr(code)+')\r\n\r\n')

After verifying that it works, Nginx can be configured such that only requests using a pre-shared key inside the query string would be passed to the device:

esp8266-gpio-ctrl.conf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
upstream esp8266-gpio-ctrl {
  server 192.168.1.XXX;
}

server {
  listen [::];
  server_name esp8266-gpio-ctrl.shadycorp.com;
  location / {
    if ($args !~ "[redacted]") {
      return 403;
    }
    proxy_pass http://esp8266-gpio-ctrl;
  }
}

This way, the code running on ESP8266 never sees any untrusted input, and even for requests with the valid pre-shared key, it ensures that the HTTP requests are well-formed. Nginx can also provide IPv6 as well as SSL termination; the built-in SSL library on ESP8266 does not perform certificate validation and is thus useless.


Want to discuss this note? Drop me a letter.