Laravel Zero - Let's build a TCP server

February 25, 2024

Hello 👋

cover

A few weeks ago, I began working on a TCP server for an upcoming annual CTF hosted by my team "Securinets ISI". The goal is to enable players to quickly submit flags using a command like:

echo "flag" | nc 127.0.0.1 8000

For this task, I needed to develop a console application. Instead of pulling packages independently, like the DB and Views component from Laravel, the console component from Symfony, and Phinx for DB migrations, I found Laravel Zero to be the perfect fit. We'll be using it to build the server!

Laravel Zero is a lightweight and modular micro-framework for developing fast and powerful console applications. Built on top of the Laravel components.

A little about servers

If you're not familiar with what a TCP server is, no worries, you've been using one your whole life. Web servers are TCP servers, just with a layer of abstraction. This one operates on a slightly lower level. A server will listen on a "server socket", and a socket is nothing but an IP bound to a PORT, represented as IP:PORT. This socket waits for clients to make requests. Therefore, every communication involves two sockets; one for the server and one for the client.

The client receives a random port the OS assigns on the fly when connecting to the server. Yes, a single server socket can handle multiple clients, though not concurrently. Each time a client connects, it is pushed to an accept queue, waiting for its turn.

Knowing how servers work is genuinely interesting. I've provided a few links for you to read; have fun exploring. And yes, I'll be writing about those soon.

Building the server

Without further ado, let's begin by installing Laravel Zero:

composer create-project --prefer-dist laravel-zero/laravel-zero securinets

Now, you can rename the application to whatever makes sense to you:

php application app:rename securinets

Next, I want to be able to use environment variables. My application will need the database, and I'll also require the Blade engine. Laravel Zero makes it easy to install those components:

php securinets app:install dotenv
php securinets app:install database
php securinets app:install view

We can start using our console app by running:

php securinets [command]

For our case, we need to build a TCP server, actively listening for incoming connections. We have a few options for that, like RoadRunner or Swoole, and I am choosing the latter.

Swoole is a PHP extension that enables you to do A LOT of things, such as asynchronous programming, event loops, coroutines, and much more.

For Linux, the installation is straightforward:

sudo apt update && sudo apt install -y php8.2-openswoole

I am using PHP 8.2, adjust the version accordingly.

For VSCode users, you can assist the IDE by installing the following package:

composer require openswoole/ide-helper

Then, in your settings.json:

"intelephense.environment.includePaths": [
    "vendor/openswoole/ide-helper"
],

Now, we can use Laravel Zero to generate a new command to start the server:

php securinets make:command ServeCommand

In the ServeCommand, we can make use of Swoole. It provides a Server class that expects a host and a port. The good news is you can still define environment variables and reference them in the config, just like you would in your Laravel application. All of this is handled by Laravel Zero because, as you may recall, we installed those components using the app:install command, which you would otherwise have to do manually.

I appreciate how organized the Laravel serve command logs are, so maybe we create something similar? I know for a fact that Laravel constructs the logs by combining echo statements with str_repeat, everything is done in the command itself. However, I find this approach a bit noisy. So, let's leverage the Blade engine for a cleaner solution.

In your resources/views, create a log.blade.php view

<div class="flex">
    <span class="mr-1 ml-2 text-gray-600">{{ $date }}</span>
    <span class="mr-1">{{ $hour }}</span>
    <span class="mr-1 font-bold">[{{ $client }}]</span>
    <span class="mr-2 text-gray-600">
        {{ str_repeat('.', max(\Termwind\terminal()->width() - 80 - mb_strlen($client), 0)) }}
    </span>
    <span @class([
        'px-1 font-bold',
        'text-red' => !$connected,
        'text-green' => $connected,
    ])">{{ $connected ? 'CONNECTED' : 'DISCONNECTED' }}</span>
</div>

This is what we will see in our logs. Now, for the responses we will be sending to the clients, we want them to be cool as well. So, create a response.blade.php.

<div class="py-1 ml-2">
    <div @class([
        'px-1 text-white',
        'bg-red' => !$correct,
        'bg-green-600' => $correct,
    ])>
        {{ $correct ? 'SUCCESS' : 'ERROR' }}
    </div>
    <span class="ml-1">
        {{ $message }}
    </span>
</div>

Now that our views are ready, you might be wondering, "Isn't this Tailwind CSS?" Well, yes, it is! Laravel Zero ships with Termwind, another great package that enables you to use Tailwind CSS classes for console applications.

Now, we can use Swoole. But before that, let's go over the basics. To create a server, you need to instantiate the OpenSwoole\Server class. By doing that, you can hook into various events. We will be using Start, Connect, Receive, and Close. You can find all the events here.

Under the hood, Swoole runs an event loop. Event loops are what made Nginx revolutionary and are the solution to the C10k problem. Curious? We will be traveling back to 2004 soon to discuss those 👀

Now, with what we learned, the command will look like (code is commented)

<?php

namespace App\Commands;

use OpenSwoole\Server;
use Termwind\HtmlRenderer;
use LaravelZero\Framework\Commands\Command;

use function Termwind\render;

class ServeCommand extends Command
{
    private string $host;

    private int $port;

    public function __construct()
    {
        parent::__construct();

        $this->host = config('server.host', '127.0.0.1');
        $this->port = config('server.port', 9001);
    }

    /**
     * The signature of the command.
     *
     * @var string
     */
    protected $signature = 'serve';

    /**
     * The description of the command.
     *
     * @var string
     */
    protected $description = 'Start the flag submission server.';

    public function handle(): mixed
    {
        // Create a server object
        $server = new Server($this->host, $this->port);

        // Hook into the Start event
        $server->on('Start', function () {
            /** @var \Illuminate\Contracts\Support\Renderable $render */
            $render = view('start', [
                'host' => $this->host,
                'port' => $this->port,
            ]);

            // Yes, you can use the Blade engine to return the HTML as a rendered string,
            // which can then be rendered by Termwind
            render($render->render());
        });

        $server->on('Connect', function (Server $server, int $fd) {
            $this->log($server->getClientInfo($fd), true);
        });

        $server->on('Receive', function (Server $server, int $fd, int $reactor_id, string $data) {
            // I am only simulating the response; you should execute the business logic.
            $response = view('response', [
                'message' => 'Correct submission, keep it up.',
                'correct' => true,
            ])->render();

            $response = (new HtmlRenderer())->parse($data)->toString();

            // This is important; if you want the client to see a correctly rendered output,
            // You need to format it, so the result is an escaped ANSI sequence
            $response = $this->output->getFormatter()->format($response);

            $server->send($fd, $response . PHP_EOL);

            $server->close($fd);
        });

        $server->on('Close', function (Server $server, int $fd) {
            $this->log($server->getClientInfo($fd), false);
        });

        // This will start the TCP server
        $server->start();

        return Command::SUCCESS;
    }

    /**
     * @param array<string>|bool $infos
     */
    private function log(array|bool $infos, bool $connected): void
    {
        if (is_array($infos)) {
            /** @var \Illuminate\Contracts\Support\Renderable $render */
            $render = view('log', [
                'date' => date('Y-m-d'),
                'hour' => date('H:i:s'),
                'client' => $infos['remote_ip'],
                'connected' => $connected,
            ]);

            render($render->render());
        }
    }
}

You can find all the methods on the server object here.

One thing I want to bring to your attention is the receive event. In this example, we are echoing back whatever the client sent. Feel free to implement the logic there; for instance, in my case, I would validate the submitted flag against certain criteria. Additionally, please note that to print the output as you would expect it, you need to get the formatter used by the console commands under the hood and send its result, which is an ANSI sequence. This way, it can be rendered correctly on the client's terminal.

Now, we can run our server by executing the following command

php securinets serve

You will be prompted with

result

Am I the only one getting excited about this, I mean how cool is that?

What else?

Now that we have our console application, we want to ensure that this command is always running. For one reason or another, it can crash and stop. When that happens, we want to be able to restart it immediately. That's why I will be using Supervisor to do so.

For Linux users, you can run the following commands:

sudo apt update && sudo apt install supervisor

Now, create a config file:

sudo nano /etc/supervisor/conf.d/securinets.conf

Paste the following config:

[program:securinets]
process_name=%(program_name)s_%(process_num)02d
command=/usr/bin/php8.2 /path/to/console/application/securinets serve
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=always-a-low-privileged-user
numprocs=1
redirect_stderr=true
stdout_logfile=/var/log/securinets.log
stopwaitsecs=3600

Read more about the configuration here.

Start Supervisor by running:

sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start securinets:*

Now, your TCP server will be running. If you want to run multiple instances, set numprocs to 5, for example. In your application, check if the port is taken and move to the next one. This way, you'll have 5 TCP servers with ports 8000 to 8004 if you started with port 8000.

That's it! For more fun, you can set up Nginx as a reverse proxy to add rate limits. Although we won't cover it in this article, docs are your best friend.

Conclusion

Laravel Zero is a powerful package to kickstart console applications. We barely scratched the surface, as it offers much more. You can build interactive menus, schedule tasks, send desktop notifications, consume APIs using the built-in HTTP client, cache data, and even build the application as a standalone executable. So, the next time you're working on such apps, consider using Laravel Zero, it might be exactly what you're looking for! ✨


Profile picture

Written by Oussama Mater Software Engineering Student, CTF Player, and Web Developer.
Find me on X, Linkedin, Github, and Laracasts.