Building a Web server in Bash, part III - Login
In the previous posts we've seen how to manipulate sockets using netcat. Moreover, we learned how to start reading and parsing an HTTP message using ShellScript.
Time to definitely improve our app.
App requirements
Since our application is a Web application, we should respond in a more appropriate way. Let's define some requirements for our application:
GET /loginshould render an HTML form containing just a text field called "Name" to perform the loginGET /should render a home page containing the textHello, {{name}}. Where does the{{name}}come from? We'll see soon, but we should read the HTTP headerCookie.POST /loginshould request with a body containing the name coming from the HTML form. Afterwards, the server responds anHTTP 301with a headerSet-Cookie, so the client could use this cookie to perform future requests such asGET /POST /logoutshould request with a headerCookiecontaining the name. Afterwards, the server responds anHTTP 301with a headerSet-Cookieusing an expiration date in the past. This way, a client HTTP will permanently remove the Cookie.
Web fundamentals matter
If you need, read the requirements again.
Make no mistake, this is exactly how a simple web app used to work prior to the blast of frontend frameworks circa 2015.
Software abstractions are good and save us a lot of time, but unfortunately they also keep developers far from the basics. It's very important for us as developers, to understand how servers keep sessions across HTTP requests.
If you acknowledge that HTTP requests are stateless (the server usually closes the TCP connection at every request), a wide range of capabilities will open for debugging and troubleshooting.
Let's build a login system, and as soon as we understand that HTTP requests are stateless, we realize the logins do not exist.
After all, originally, the web mostly uses HTTP header Cookies to store user data in the HTTP client-side, sending those cookies across future requests to the same domain.
Separate HTTP responses in files
A good practice is to keep our HTTP responses in separate files, thus leading to a more readable and extensible code:
cat login.html
HTTP/1.1 200 OK
Content-Type: text/html
cat 404.html
HTTP/1.1 404 NotFound
Content-Type: text/html
Sorry, not found
Okay, with HTML files in place, let's see how our function handleRequest looks like so far:
Yes, much more shorter and readable.
Keep the server running forever
Not sure if you noticed, but the server is being closed at every client connection, which is making us to run the server over and over again, at every single time we want to perform a new HTTP request.
We can keep the server in an infinite loop. By doing this, we no longer need to run the server for every request:
while ; do
| |
done
How cool is that?
Nice, but show me some login
Okay. Open localhost:3000/login in your web browser, type a name in the form and submit it.
I'm sure you got the response "Sorry, not found", right?
No problem, now go to the server STDOUT and check the message coming from the HTTP client:
POST /login HTTP/1.1
Host: localhost:3000
Content-Length: 12
....
....
What does it mean?
Following the HTTP standard, web browsers perform a POST on a form submit, like we have in form action=POST. Upon submission, every field is then added to the HTTP request body.
Please also note that the HTTP message contains a lot of HTTP headers (I'm using Google Chrome), but the HTTP body is nowhere to be found. Why's that?
We should keep reading the request body after the empty line, remember? However, we should first acknowledge the "length" of the body, otherwise, when should we stop reading the remaining bytes?
Thankfully, the HTTP standard covers that. The web browser has already sent an important header called Content-Length.
Our work is then basically parsing this header and use it to read the remaining HTTP request message bytes, the body.
Reading the HTTP body message
Similar to the headline parsing, inside the reading loop, let's place:
CONTENT_LENGTH_REGEX='Content-Length:\s(.*?)'
&&
CONTENT_LENGTH=
Now, after the reading loop, we should read the remaining bytes in case there's a Content-Length header (not always clients send the Content-Length header).
## Check if Content-Length is not empty
if [; then
BODY_REGEX='(.*?)=(.*?)'
## Read the remaining request body
while ; do
INPUT_NAME=
INPUT_VALUE=
done
fi
Nice. Go and perform a POST /login, you should see the body being displayed in the server STDOUT!
Explaining the read:
-n {N}: reads an arbitrary number of bytes from a file or STDIN-t {T}: timeout to read the entire message from the STDINinside the body reading loop, we parse each line trying to find a match to
field=valuecoming from thePOSTHTTP request
Fantastic!
Handle the message
Reading the body message is no way close to the end. We have to handle it in the switch-case, remember?
How about refactoring to something like this?
## Route to the response handlers (functions)
case "" in
"GET /login") ;;
"GET /") ;;
"POST /login") ;;
*) ;;
esac
All we have to do is implementing the above functions. Let's start the handle_POST_login:
reads the
post-login.httpfilesearch and replace for a pattern using
sedNow, let's check thepost-login.httpfile:
HTTP/1.1 301
Location: http://localhost:3000/
Set-Cookie: {{cookie_name}}={{cookie_value}}; path=/; HttpOnly
Time to perform the test. Check yourself a dead-simple login written in ShellScript!
Finally, the complete implementation

Handle request
First we should:
setup the FIFO
put
netcatin a loophandle requests
#!/bin/bash
## Create the response FIFO
## Keep server running forever
while ; do
## 1. wait for FIFO
## 2. creates a socket and listens to the port 3000
## 3. as soon as a request message arrives to the socket, pipes it to the handleRequest function
## 4. the handleRequest function processes the request message and routes it to the response handler, which writes to the FIFO
## 5. as soon as the FIFO receives a message, it's sent to the socket
## 6. closes the connection (`-N`), closes the socket and repeat the loop
| |
done
In short, the server:
- waits for FIFO
- creates a socket and listens to the port 3000
- as soon as a request message arrives to the socket, pipes it to the handleRequest function
- the handleRequest function processes the request message and routes it to the response handler, which writes to the FIFO
- as soon as the FIFO receives a message, it's sent to the socket
- closes the connection (
-N), closes the socket and repeat the loop
Implement the handle response functions
Look at the switch-case. It's pretty simple but the functions are yet to be implemented.
Place them before the handleRequest function, because ShellScript expects functions to be declared prior to calling.
Place the files accordingly
Now that we have the handle response functions, note that all of them expect HTTP/HTML files to exist. Let's create them. login.html
HTTP/1.1 200 OK
Content-Type: text/html
home.html
HTTP/1.1 200 OK
Content-Type: text/html
Hello, {{name}}
404.html
HTTP/1.1 404 NotFound
Content-Type: text/html
Sorry, not found
post-login.http
HTTP/1.1 301
Location: http://localhost:3000/
Set-Cookie: {{cookie_name}}={{cookie_value}}; path=/; HttpOnly
post-logout.http
HTTP/1.1 301
Location: http://localhost:3000/login
Set-Cookie: {{cookie_name}}={{cookie_value}}; path=/; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:00 GMT
So much YAY!
Wrapping up
Today we walked through refactoring our Web server.
We added more capabilities, making it a complete web server delivering a login, home page and logout features.
In the next steps we'll explore the triad of Web: HTML, CSS and JS.