Learn LXC Homepage

Hey there, welcome to my site!

       ,
       \`-._           __
        \\  `-..____,.'  `.
         :`.         /    \`.
         :  )       :      : \
          ;'        '   ;  |  :
          )..      .. .:.`.;  :
         /::...  .:::...   ` ;
         ; _ '    __        /:\
         `:o>   /\o_>      ;:. `.
        `-`.__ ;   __..--- /:.   \
        === \_/   ;=====_.':.     ;
         ,/'`--'...`--....        ;
              ;                    ;
            .'                      ;
          .'                        ;
        .'     ..     ,      .       ;
       :       ::..  /      ;::.     |
      /      `.;::.  |       ;:..    ;
     :         |:.   :       ;:.    ;
     :         ::     ;:..   |.    ;
      :       :;      :::....|     |
      /\     ,/ \      ;:::::;     ;
    .:. \:..|    :     ; '.--|     ;
   ::.  :''  `-.,,;     ;'   ;     ;
.-'. _.'\      / `;      \,__:      \
`---'    `----'   ;      /    \,.,,,/
                   `----`

test

This is Samuel. Say hi to him!
Artist: Marcin 'StfoReK' Glinski

What is this site running?

I've got a ton of stuff hosted, and want to stretch this little Azure VPS to its breaking point.

And admin panels:

Of course, you can't forget the classic

ssh username@learnlxc.dedyn.io

How did you handle authentication?

I marked all the fully private services above with an asterisk. Basically I'm using Authelia for SSO, which authenticates us to most of the services that support OpenID Connect. The services that don't either:

What are you hosting this on?

An old abandoned VPS. My dad left it up, forgot about it, and I built a lot on it.
It's got the following specs:

username@learnlxc:~$ free -h
               total        used        free      shared  buff/cache   available
Mem:           3.3Gi       1.3Gi       952Mi        80Mi       1.1Gi       1.7Gi
Swap:          3.8Gi       934Mi       2.9Gi

username@learnlxc:~$ cat /proc/cpuinfo 
processor       : 0
vendor_id       : GenuineIntel
cpu family      : 6
model           : 79
model name      : Intel(R) Xeon(R) CPU E5-2673 v4 @ 2.30GHz
stepping        : 1
microcode       : 0xffffffff
cpu MHz         : 2294.686
cache size      : 51200 KB
physical id     : 0
siblings        : 1
core id         : 0
cpu cores       : 1
apicid          : 0
initial apicid  : 0
fpu             : yes
fpu_exception   : yes
cpuid level     : 20
wp              : yes
flags           : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss syscall nx pdpe1gb rdtscp lm constant_tsc rep_good nopl xtopology cpuid tsc_known_freq pni pclmulqdq ssse3 fma cx16 pcid sse4_1 sse4_2 movbe popcnt aes xsave avx f16c rdrand hypervisor lahf_lm abm 3dnowprefetch pti fsgsbase bmi1 hle avx2 smep bmi2 erms invpcid rtm rdseed adx smap xsaveopt md_clear
bugs            : cpu_meltdown spectre_v1 spectre_v2 spec_store_bypass l1tf mds swapgs taa itlb_multihit mmio_stale_data bhi its
bogomips        : 4589.37
clflush size    : 64
cache_alignment : 64
address sizes   : 46 bits physical, 48 bits virtual
power management: samuel-based

username@learnlxc:~$ ping 1.1.1.1
PING 1.1.1.1 (1.1.1.1) 56(84) bytes of data.
64 bytes from 1.1.1.1: icmp_seq=1 ttl=55 time=1.77 ms
64 bytes from 1.1.1.1: icmp_seq=2 ttl=55 time=1.87 ms
64 bytes from 1.1.1.1: icmp_seq=3 ttl=55 time=1.86 ms
64 bytes from 1.1.1.1: icmp_seq=4 ttl=55 time=1.99 ms
64 bytes from 1.1.1.1: icmp_seq=5 ttl=55 time=1.97 ms
64 bytes from 1.1.1.1: icmp_seq=6 ttl=55 time=1.85 ms
64 bytes from 1.1.1.1: icmp_seq=7 ttl=55 time=1.88 ms
64 bytes from 1.1.1.1: icmp_seq=8 ttl=55 time=1.90 ms
64 bytes from 1.1.1.1: icmp_seq=9 ttl=55 time=1.89 ms
64 bytes from 1.1.1.1: icmp_seq=10 ttl=55 time=1.88 ms
^C
--- 1.1.1.1 ping statistics ---
10 packets transmitted, 10 received, 0% packet loss, time 9005ms
rtt min/avg/max/mdev = 1.772/1.886/1.989/0.057 ms

It's got ONE core, and four gigabytes of RAM.

Optimizations?

Of course, I use zram:

username@learnlxc:~$ zramctl
NAME       ALGORITHM DISKSIZE   DATA  COMPR  TOTAL STREAMS MOUNTPOINT
/dev/zram0 zstd          2.8G 933.9M 159.2M 200.2M       1 [SWAP]

Memory optimization is also helped by the fact that most binaries are Go or Rust.

Occasionally though, I have to force swap to fill with cold memory, by filling active memory with junk:

from time import sleep
value = int(input('Enter the number of 1MB blocks you want to allocate: '))
print('Applying 1GB memory pressure to the system . . .')
buffer = 'A'*1024*1024*value
print(len(buffer),' bytes of memory allocated')
sleep(1)
buffer = None
raise SystemExit

And of course, I don't use any VMs, opting for (far lighter) system containers.

Why do you have :21, :23, :25, :3389, :5432 AND :5900 TCP ALL OPEN?!?!?

Okay, okay, calm down! Before you come at me with pitchforks and knives, know that it's not quite as dangerous as it looks.

All of those ports are in fact forwarded to a honeypot:

import socket
import json
import asyncio
import requests
import struct
import sys
from datetime import datetime
from uuid import uuid4
from base64 import b64encode
from functools import partial

MAX_CONNECTIONS = 500  # Don't run out of file descriptors
MAX_TIMEOUT = 15  # Keep skids flowing
connection_semaphore = asyncio.Semaphore(MAX_CONNECTIONS)

ntfy_server_url = None
ntfy_topic = None
ntfy_access_token = None
ntfy_queue = asyncio.Queue()


def log(message):
    # Helper function to print messages with a precise timestamp.
    # AI because I didn't feel writing it manually.
    timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
    print(f"[{timestamp}] {message}")


async def ntfy_worker():
    while True:
        message = await ntfy_queue.get()
        try:
            await asyncio.to_thread(
                requests.post,
                url=f"{ntfy_server_url}/{ntfy_topic}",
                data=message,
                headers={"Authorization": f"Bearer {ntfy_access_token}", "Priority": "min"},
                timeout=5 # Don't make the OS go kaboom
            )
        except Exception as e:
            log(f"[ERROR] Failed to send background ntfy alert: {e}")
        finally:
            ntfy_queue.task_done()


async def client_handler(reader, writer, name, response):
    addr = writer.get_extra_info('peername')
    linger_struct = struct.pack('ii', 1, 0)
    if connection_semaphore.locked():
        log(f"[{name}] Hit {MAX_CONNECTIONS} connections, dropping skid from {addr}.")
        writer.get_extra_info('socket').setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, linger_struct)
        writer.get_extra_info('socket').close()
        return

    await connection_semaphore.acquire()
    identity = uuid4()
    log(f"[{name}] Cooked skid from {addr}, connection ID is {identity}")

    if ntfy_topic:
        await ntfy_queue.put(f"[{name}] Got a script kiddie connecting from {addr}!")

    if response:
        writer.write(response.encode())
        await writer.drain()
        log(f"[{identity}] We sent {b64encode(response.encode()).decode()}")
    try:
        while True:
            try:
                data = await asyncio.wait_for(reader.read(4096), timeout=MAX_TIMEOUT)
            except asyncio.TimeoutError:
                log(f"[{identity}] Skid was too slow! Keep things moving.")
                writer.get_extra_info('socket').setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, linger_struct)
                writer.get_extra_info('socket').close()
                break

            if not data:
                log(f"[DISCONNECTED] {identity} closed the connection.")
                break
            message = b64encode(data).decode()
            log(f"[{identity}] {message}")
    except asyncio.CancelledError:
        log(f"[CANCELLED] Connection {identity} was forcibly closed.")
    except OSError:
        log(f"[WARNING] Failed to kill {identity} - probably because we viciously murdered the connection earlier.") # Oops we murdered it
    except Exception as e:
        log(f"[ERROR] Connection {identity} errored: {e}")
    finally:
        connection_semaphore.release()
        try:
            writer.close()
            await writer.wait_closed()
        except Exception:
            pass


async def main():
    global ntfy_topic, ntfy_server_url, ntfy_access_token
    try:
        with open('config.json', 'r') as f:
            config = json.load(f)
    except FileNotFoundError:
        log("[SkidBaiter] No config file found!")
        raise SystemExit
    except json.JSONDecodeError:
        log("[SkidBaiter] Invalid config file!")
        raise SystemExit

    if not config['enabled']:
        log("[SkidBaiter] Honeypot was disabled in config, shutting down . . .")
        raise SystemExit

    if config['ntfy']:
        log("[SkidBaiter] Initializing NTFY alerts . . .")
        ntfy_server_url = config['ntfy']['server_url']
        ntfy_topic = config['ntfy']['topic']
        ntfy_access_token = config['ntfy']['token']
        await ntfy_queue.put("[SkidBaiter] Initialized NTFY alerts!")

    log("[SkidBaiter] Loaded configuration file, initializing . . .")
    worker_task = asyncio.create_task(ntfy_worker())

    honeypots = config["honeypots"]
    servers = []
    for honeypot in honeypots:
        log("[SkidBaiter] Loading skid trap " + honeypot["name"])
        try:
            _ = honeypot['response']
        except KeyError:
            honeypot['response'] = ""
        try:
            server = await asyncio.start_server(
                partial(client_handler, name=honeypot['name'], response=honeypot['response']),
                honeypot['host'],
                honeypot['port'],
                reuse_address=True,
            )
            servers.append(server)
            honeypot['enabled'] = True
            log(f"[{honeypot['name']}] Listening on {honeypot['host']}:{honeypot['port']}")
        except Exception as e:
            log(f"[ERROR] Failed to spin up {honeypot['name']}: {e}")

    if not servers:
        log("[SkidBaiter] No servers were able to start. Exiting.")
        worker_task.cancel()
        raise SystemExit

    log("\n[SkidBaiter] All traps active. Awaiting connections...\n")

    if ntfy_topic:
        active_traps = ', '.join([f'{hp["host"]}:{hp["port"]}' for hp in honeypots if hp.get('enabled')])
        await ntfy_queue.put(f"[SkidBaiter] Skid traps listening on: {active_traps}")

    try:
        await asyncio.gather(*(server.serve_forever() for server in servers))
    except asyncio.CancelledError:
        log("[SkidBaiter] Servers are shutting down...")
        if ntfy_topic:
            try:
	            # We have to do it manually, the ntfy thread already went kaput
                requests.post(
                    url=f"{ntfy_server_url}/{ntfy_topic}",
                    data="[SkidBaiter] Shutting down . . .",
                    headers={"Authorization": f"Bearer {ntfy_access_token}"},
                    timeout=4.0
                )
            except Exception as e:
                log(f"[SkidBaiter] Something cooked our notif server: {e}")
    finally:
        for server in servers:
            server.close()
            await server.wait_closed()
        worker_task.cancel()
asyncio.run(main())

And of course, I configured it pretty simply:

{
  "enabled": true,
  "ntfy": {
    "server_url": "https://ntfy.learnlxc.dedyn.io",
    "token": "<TOKEN>",
    "topic": "skids"
  },
  "honeypots": [
    {
      "name": "telnet-trap",
      "host": "0.0.0.0",
      "port": 23,
      "response": "login: "
    },
    {
      "name": "ftp-trap",
      "host": "0.0.0.0",
      "port": 21
    },
    {
      "name": "smtp-trap",
      "host": "0.0.0.0",
      "port": 25
    },
    {
      "name": "rdp-trap",
      "host": "0.0.0.0",
      "port": 3389,
      "response": "\u0003\u0000\u0000\u0013\u000e\u00d0\u0000\u0000\u00124\u0000\u0002\u0000\u0000\u0000\u0000\u0000\u0000"
    },
    {
      "name": "postgres-trap",
      "host": "0.0.0.0",
      "port": 5432,
      "response": "E\u0000\u0000\u001adFATAL\u0000C28000\u0000Mno pg_hba.conf entry for host\u0000\u0000"
    },
    {
      "name": "vnc-trap",
      "host": "0.0.0.0",
      "port": 5900,
      "response": "RFB 003.008\n"
    }
  ]
}

(Credit for the above config to Google Gemini 🤣🤣🤣)

No one's really responded except a ton of bots running zMap.

How did you write this page?

I'm terrible at HTML. I wrote the page in Obsidian, and passed it through the below parser.

# render.py
import os
import shutil
import multiprocessing
from markdown_it import MarkdownIt
from minify_html import minify as htmlmin
from jsmin import jsmin
from csscompressor import compress as cssmin
# config stuff
PUBLISHED_DIR = os.path.abspath('published')
STATIC_DIR = os.path.abspath('static')
TEMPLATE = 'template.html'
PROCESS_COUNT = os.cpu_count()


def render_file(filepath):
    md = MarkdownIt("commonmark", {"html": True, "linkify": True, "breaks": True})
    md.enable(["table", "strikethrough", "replacements", "smartquotes"])
    
    destination = os.path.abspath(os.path.join(STATIC_DIR, os.path.relpath(filepath, PUBLISHED_DIR))) # Resolve the static version of our file
    parent = os.path.dirname(destination)
    os.makedirs(parent, exist_ok=True)
    try:
        if filepath.endswith('.md'):
            destination = destination.removesuffix('.md')+'.html' # Go from .md to .html
            # render logic
            with open(filepath, 'r', encoding='utf-8') as src_file:
                with open(destination, 'w', encoding='utf-8') as dst_file:
                    html = TEMPLATE_STRING.replace('{0}', 'Learn LXC Homepage').replace('{1}', md.render(src_file.read()))
                    content = htmlmin(html,minify_doctype=True,minify_css=True,minify_js=True)
                    dst_file.write(content)
            
        elif filepath.endswith('.html'):
            with open(filepath, 'r', encoding='utf-8') as src_file:
                with open(destination, 'w', encoding='utf-8') as dst_file:
                    content = htmlmin(src_file.read(),minify_doctype=True,minify_css=True,minify_js=True)
                    dst_file.write(content)
        
        elif filepath.endswith('.css'):
            with open(filepath, 'r', encoding='utf-8') as src_file:
                with open(destination, 'w', encoding='utf-8') as dst_file:
                    content = cssmin(src_file.read(),preserve_exclamation_comments=False)
                    dst_file.write(content)
        
        elif filepath.endswith('.js'):
            with open(filepath, 'r', encoding='utf-8') as src_file:
                with open(destination, 'w', encoding='utf-8') as dst_file:
                    content = jsmin(src_file.read())
                    dst_file.write(content)
            
        else:
            shutil.copy2(filepath, destination)
    except Exception as e:
        return f"[-] {filepath}"
    return f'[+] {filepath} ====> {destination}'

os.makedirs(PUBLISHED_DIR, exist_ok=True)
os.makedirs(STATIC_DIR, exist_ok=True)
with open(TEMPLATE, 'r') as template_file:
    TEMPLATE_STRING = template_file.read()

print('[*] Indexing files . . .')
tasks = []
for root, _, files in os.walk(PUBLISHED_DIR):
    for file in files:
        path = os.path.join(root, file)
        tasks.append(path)
        print(f'[+] Found file {path}')

with multiprocessing.Pool(PROCESS_COUNT) as pool:
    for result in pool.imap_unordered(render_file, tasks):
        print(result)

print('[*] Rendered all files')
<!-- template.html -->

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>{0}</title>
        <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/open-fonts@1.1.1/fonts/inter.min.css">
        <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@exampledev/new.css@1.1.2/new.min.css">
    </head>
    <body>
        <article>
            {1}
        </article>
    </body>
</html>

Then I uploaded it to Caddy and the site was hosted.

Can I DoS/DDoS you?

Please don't. While Azure's network can handle a lot of traffic, and Caddy is fast, the server is tiny(see our above section). You won't get any meaningful stress testing results; you will just . . . crash the server. Even one client can do a lot of damage with say, Apache Benchmark - simply because the client is almost always bigger than the server.

I understand some people want to act as independent stress testers(and that's fine!), but please point your traffic elsewhere.

Also, you'll get me in hot water with my parents; if you throw enough junk at the server, my dad's SIEM team at work is going to notice, and I'm going to be grounded for seven decades.

What about anecdotes?

Oh, there's quite a few. Firstly, when I was first setting up the server, I locked myself out using NFTables:

sudo nft add rule ip filter input drop; # Cooked in one line

Thankfully, my dad's laptop was upstairs and unlocked, so I simply reset the VM. I lost my SSH config, but small price to pay for having a functional server.

Another time, I was configuring my web terminal when I suddenly realized that the VM had NO network security group(a fancy Azure term meaning firewall). But, I had assumed that all the ports were closed to the public except me, and in classic me fashion, left a full ttyd instance running open to the public with zero authentication. It was supposed to be behind Caddy's Basic authentication, but anyone who simply changed the web browser's port to :3001 could instantly use the terminal over HTTP without a password.

I frantically checked for .bash_history changes and auditd logs, but found no indications my VPS got hacked. Pretty close call, though.

Then after about a week, I was cleaning up old archived containers. I was taking a container named code-server out back(I had tried to run VS Code Server, but it ate RAM like the Cookie Monster). But, I misclicked and deleted my Synapse container instead. After screaming into the void, I finally broke my habit of holding SHIFT whenever deleting containers to "save time". Everyone blows up production at least once before learning 🤣🤣🤣.