init: and so there was the tree

This commit is contained in:
Teesh 2026-02-24 11:23:13 +02:00
commit d0c1fa3719
23 changed files with 519 additions and 0 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake

4
.formatter.exs Normal file
View file

@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

23
.gitignore vendored Normal file
View file

@ -0,0 +1,23 @@
### Elixir ###
/_build
/cover
/deps
/doc
/.fetch
erl_crash.dump
*.ez
*.beam
/config/*.secret.exs
.elixir_ls/
.expert/
### End of Elixir ###
### Nix ###
# Ignore build outputs from performing a nix-build or `nix build` command
result
result-*
# Ignore automatically generated direnv output
.direnv
### End of Nix ###

3
README.md Normal file
View file

@ -0,0 +1,3 @@
# Sunbeam
An IRCd in Elixir, made as an experiment by `teesh3rt`.

77
flake.lock generated Normal file
View file

@ -0,0 +1,77 @@
{
"nodes": {
"flake-parts": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1769996383,
"narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "57928607ea566b5db3ad13af0e57e921e6b12381",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"import-tree": {
"locked": {
"lastModified": 1771045967,
"narHash": "sha256-oYO4poyw0Sb/db2PigqugMlDwsvwLg6CSpFrMUWxA3Q=",
"owner": "vic",
"repo": "import-tree",
"rev": "c968d3b54d12cf5d9c13f16f7c545a06c9d1fde6",
"type": "github"
},
"original": {
"owner": "vic",
"repo": "import-tree",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1771008912,
"narHash": "sha256-gf2AmWVTs8lEq7z/3ZAsgnZDhWIckkb+ZnAo5RzSxJg=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "a82ccc39b39b621151d6732718e3e250109076fa",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1769909678,
"narHash": "sha256-cBEymOf4/o3FD5AZnzC3J9hLbiZ+QDT/KDuyHXVJOpM=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "72716169fe93074c333e8d0173151350670b824c",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixpkgs.lib",
"type": "github"
}
},
"root": {
"inputs": {
"flake-parts": "flake-parts",
"import-tree": "import-tree",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

13
flake.nix Normal file
View file

@ -0,0 +1,13 @@
{
description = "A very basic flake";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
flake-parts.url = "github:hercules-ci/flake-parts";
import-tree.url = "github:vic/import-tree";
};
outputs = inputs:
inputs.flake-parts.lib.mkFlake {inherit inputs;}
(inputs.import-tree ./packages/nix);
}

View file

@ -0,0 +1,17 @@
defmodule Sunbeam.AcceptorSupervisor do
use Supervisor
def start_link(socket) do
Supervisor.start_link(__MODULE__, socket, name: __MODULE__)
end
def init(socket) do
children =
for i <- 1..20 do
Supervisor.child_spec({Sunbeam.TcpAcceptor, socket}, id: {Sunbeam.TcpAcceptor, i})
end
options = [strategy: :one_for_all, name: __MODULE__]
Supervisor.init(children, options)
end
end

View file

@ -0,0 +1,25 @@
defmodule Sunbeam.Application do
use Application
@impl true
def start(_type, _args) do
children = [
# Channels
{Registry, keys: :unique, name: Sunbeam.ChannelRegistry},
Sunbeam.ChannelSupervisor,
# Users
{Registry, keys: :unique, name: Sunbeam.UserRegistry},
Sunbeam.UserSupervisor,
# Sockets
Sunbeam.SocketSupervisor,
# Monitoring
Sunbeam.MonitoringWorker
]
opts = [strategy: :one_for_one, name: Sunbeam.RootSupervisor]
Supervisor.start_link(children, opts)
end
end

14
lib/sunbeam/channel.ex Normal file
View file

@ -0,0 +1,14 @@
defmodule Sunbeam.Channel do
use GenServer, restart: :transient
def start_link(name) do
GenServer.start_link(__MODULE__, name,
name: {:via, Registry, {Sunbeam.ChannelRegistry, name}}
)
end
@impl true
def init(_args) do
{:ok, %{users: MapSet.new()}}
end
end

View file

@ -0,0 +1,21 @@
defmodule Sunbeam.ChannelSupervisor do
use DynamicSupervisor
# NOTE: Also creates a new channel if it dosent exist
def get(name) do
case DynamicSupervisor.start_child(__MODULE__, {Sunbeam.Channel, name}) do
{:ok, pid} -> {:ok, pid}
{:error, {:already_started, pid}} -> {:ok, pid}
error -> error
end
end
def start_link(init) do
DynamicSupervisor.start_link(__MODULE__, init, name: __MODULE__)
end
@impl true
def init(_init) do
DynamicSupervisor.init(strategy: :one_for_one)
end
end

32
lib/sunbeam/connection.ex Normal file
View file

@ -0,0 +1,32 @@
defmodule Sunbeam.Connection do
use GenServer, restart: :temporary
require Logger
def start_link(socket) do
GenServer.start_link(__MODULE__, socket)
end
@impl true
def init(socket) do
{:ok, user} = Sunbeam.UserSupervisor.create_user(self())
Process.link(user)
{:ok, %{user: user, socket: socket}}
end
@impl true
def handle_info({:tcp, socket, data}, state) do
:inet.setopts(socket, active: :once)
{:noreply, state}
end
@impl true
def handle_info({:tcp_closed, socket}, state) do
{:stop, :normal, state}
end
@impl true
def handle_info({:tcp_error, socket, reason}, state) do
{:stop, reason, state}
end
end

View file

@ -0,0 +1,16 @@
defmodule Sunbeam.ConnectionSupervisor do
use DynamicSupervisor
def start_link(init) do
DynamicSupervisor.start_link(__MODULE__, init, name: __MODULE__)
end
def create_conn_from(client_socket) do
DynamicSupervisor.start_child(__MODULE__, {Sunbeam.Connection, client_socket})
end
@impl true
def init(_arg) do
DynamicSupervisor.init(strategy: :one_for_one)
end
end

View file

@ -0,0 +1,86 @@
defmodule Sunbeam.Debug.SupervisionTree do
@moduledoc """
Prints a supervision tree like the `tree` command with colors.
Depth-based coloring: fire-red at root, fading to ash-gray at deepest levels.
DynamicSupervisor children registered in a Registry show their name.
Limits children per node to avoid crashing the logger.
"""
@colors [
# fire red
"\e[38;5;196m",
# orange
"\e[38;5;202m",
# yellow
"\e[38;5;226m",
# light yellow
"\e[38;5;190m",
# light green
"\e[38;5;145m",
# cyan
"\e[38;5;111m",
# ash gray
"\e[38;5;250m"
]
@reset "\e[0m"
@max_children 20
# Public entry point
def print_tree(supervisor), do: print_tree(supervisor, "", 0)
# Internal recursive function with depth
defp print_tree(supervisor, prefix, depth) do
children = get_children(supervisor)
color = color_for_depth(depth)
IO.puts("#{prefix}#{color}#{inspect(supervisor)}#{@reset}")
Enum.take(children, @max_children)
|> Enum.with_index()
|> Enum.each(fn {{id, child_pid, type, _modules}, index} ->
last = index == min(length(children), @max_children) - 1
branch = if last, do: "└─ ", else: "├─ "
sub_prefix = if last, do: prefix <> " ", else: prefix <> ""
# Lookup registry name for dynamic workers
registry_name = get_registry_name(child_pid)
display_id =
cond do
id != :undefined -> inspect(id)
type == :worker and registry_name != nil -> inspect(registry_name)
true -> inspect(child_pid)
end
node_color = color_for_depth(depth + 1)
IO.puts("#{prefix}#{branch}#{node_color}#{display_id} (#{type})#{@reset}")
if type == :supervisor do
print_tree(child_pid, sub_prefix, depth + 1)
end
end)
if length(children) > @max_children do
IO.puts("#{prefix}│ ... (#{length(children) - @max_children} more children)")
end
end
defp get_children(pid) do
case :supervisor.which_children(pid) do
children when is_list(children) -> children
_ -> []
end
end
defp get_registry_name(pid) when is_pid(pid) do
case Registry.keys(Sunbeam.ChannelRegistry, pid) do
[name] -> name
_ -> nil
end
end
defp color_for_depth(depth) do
Enum.at(@colors, min(depth, length(@colors) - 1))
end
end

5
lib/sunbeam/irc/codes.ex Normal file
View file

@ -0,0 +1,5 @@
defmodule Sunbeam.Irc.Codes do
@rpl_welcome "001"
def rpl_welcome(), do: @rpl_welcome
end

View file

@ -0,0 +1,22 @@
defmodule Sunbeam.MonitoringWorker do
use GenServer
def start_link(arg) do
GenServer.start_link(__MODULE__, arg)
end
@impl true
def init(_arg) do
send(self(), :monitor)
{:ok, nil}
end
@impl true
def handle_info(:monitor, state) do
IO.write("\e[H\e[2J")
Sunbeam.Debug.SupervisionTree.print_tree(Sunbeam.RootSupervisor)
Process.send_after(self(), :monitor, 1000 * 1)
{:noreply, state}
end
end

View file

@ -0,0 +1,26 @@
defmodule Sunbeam.SocketSupervisor do
use Supervisor
def start_link(init_arg) do
Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
end
@impl true
def init(_arg) do
{:ok, socket} =
:gen_tcp.listen(6667, [
:binary,
packet: 0,
active: false,
reuseaddr: true
])
children = [
Sunbeam.ConnectionSupervisor,
{Sunbeam.AcceptorSupervisor, socket}
]
options = [strategy: :one_for_one]
Supervisor.init(children, options)
end
end

View file

@ -0,0 +1,35 @@
defmodule Sunbeam.TcpAcceptor do
use GenServer
require Logger
def start_link(listen_socket) do
GenServer.start_link(__MODULE__, listen_socket)
end
@impl true
def init(listen_socket) do
send(self(), :accept)
{:ok, listen_socket}
end
@impl true
def handle_info(:accept, listen_socket) do
case :gen_tcp.accept(listen_socket, :infinity) do
{:ok, client_socket} ->
{:ok, conn} = Sunbeam.ConnectionSupervisor.create_conn_from(client_socket)
:gen_tcp.controlling_process(client_socket, conn)
:inet.setopts(client_socket, active: :once)
send(self(), :accept)
{:noreply, listen_socket}
{:error, :closed} ->
{:stop, :normal, listen_socket}
{:error, reason} ->
Logger.error("Accept failed: #{inspect(reason)}")
send(self(), :accept)
{:noreply, listen_socket}
end
end
end

30
lib/sunbeam/user.ex Normal file
View file

@ -0,0 +1,30 @@
defmodule Sunbeam.User do
use GenServer, restart: :transient
def start_link(connection) do
GenServer.start_link(__MODULE__, connection)
end
@impl true
def init(connection) do
Process.monitor(connection)
{:ok,
%{
# NOTE: This is not a handle to a raw socket.
# What are we, Python???
connection: connection,
# NICK & USER, CAP, JOIN
# Can either be :registration, :negotiation or :ready
phase: :registration,
nick: nil,
user: nil
}}
end
@impl true
def handle_info({:DOWN, _, _, _, _}, state) do
{:stop, :normal, state}
end
end

View file

@ -0,0 +1,16 @@
defmodule Sunbeam.UserSupervisor do
use DynamicSupervisor
def start_link(init) do
DynamicSupervisor.start_link(__MODULE__, init, name: __MODULE__)
end
@impl true
def init(_init) do
DynamicSupervisor.init(strategy: :one_for_one)
end
def create_user(conn) do
DynamicSupervisor.start_child(__MODULE__, {Sunbeam.User, conn})
end
end

30
mix.exs Normal file
View file

@ -0,0 +1,30 @@
defmodule Sunbeam.MixProject do
use Mix.Project
def project do
[
app: :sunbeam,
version: "0.1.0",
elixir: "~> 1.18",
start_permanent: Mix.env() == :prod,
deps: deps()
]
end
# Run "mix help compile.app" to learn about applications.
def application do
[
extra_applications: [:logger, :observer_cli],
mod: {Sunbeam.Application, []}
]
end
# Run "mix help deps" to learn about dependencies.
defp deps do
[
# {:dep_from_hexpm, "~> 0.3.0"},
# {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}
{:observer_cli, "~> 1.7"}
]
end
end

4
mix.lock Normal file
View file

@ -0,0 +1,4 @@
%{
"observer_cli": {:hex, :observer_cli, "1.8.6", "65547365b56532dabb42c91de90af5b55894218a986ae47e1db47ecb8c979d8f", [:mix, :rebar3], [{:recon, "~> 2.5.6", [hex: :recon, repo: "hexpm", optional: false]}], "hexpm", "ea743c6477bc4799f215f8ef7cd5eee1536dcdda311fe2bd204e88e7c1e6a11a"},
"recon": {:hex, :recon, "2.5.6", "9052588e83bfedfd9b72e1034532aee2a5369d9d9343b61aeb7fbce761010741", [:mix, :rebar3], [], "hexpm", "96c6799792d735cc0f0fd0f86267e9d351e63339cbe03df9d162010cefc26bb0"},
}

14
packages/nix/shell.nix Normal file
View file

@ -0,0 +1,14 @@
{...}: {
perSystem = {pkgs, ...}: {
devShells.default = pkgs.mkShell {
packages = with pkgs; [
beamPackages.elixir
beamPackages.erlang
rebar3
hex
openssl
git
];
};
};
}

5
packages/nix/systems.nix Normal file
View file

@ -0,0 +1,5 @@
{...}: {
systems = [
"x86_64-linux"
];
}