commit d0c1fa37197e3da3a01c9c225f390926be1ca27a Author: teesh3rt Date: Tue Feb 24 11:23:13 2026 +0200 init: and so there was the tree diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a4e3784 --- /dev/null +++ b/.gitignore @@ -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 ### + diff --git a/README.md b/README.md new file mode 100644 index 0000000..db03797 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Sunbeam + +An IRCd in Elixir, made as an experiment by `teesh3rt`. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..8c5193d --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..4745e82 --- /dev/null +++ b/flake.nix @@ -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); +} diff --git a/lib/sunbeam/acceptor_supervisor.ex b/lib/sunbeam/acceptor_supervisor.ex new file mode 100644 index 0000000..9a6b6ba --- /dev/null +++ b/lib/sunbeam/acceptor_supervisor.ex @@ -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 diff --git a/lib/sunbeam/application.ex b/lib/sunbeam/application.ex new file mode 100644 index 0000000..eff3960 --- /dev/null +++ b/lib/sunbeam/application.ex @@ -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 diff --git a/lib/sunbeam/channel.ex b/lib/sunbeam/channel.ex new file mode 100644 index 0000000..e4a8816 --- /dev/null +++ b/lib/sunbeam/channel.ex @@ -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 diff --git a/lib/sunbeam/channel_supervisor.ex b/lib/sunbeam/channel_supervisor.ex new file mode 100644 index 0000000..3e19c17 --- /dev/null +++ b/lib/sunbeam/channel_supervisor.ex @@ -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 diff --git a/lib/sunbeam/connection.ex b/lib/sunbeam/connection.ex new file mode 100644 index 0000000..43a72bd --- /dev/null +++ b/lib/sunbeam/connection.ex @@ -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 diff --git a/lib/sunbeam/connection_supervisor.ex b/lib/sunbeam/connection_supervisor.ex new file mode 100644 index 0000000..74822fd --- /dev/null +++ b/lib/sunbeam/connection_supervisor.ex @@ -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 diff --git a/lib/sunbeam/debug/supervision_tree.ex b/lib/sunbeam/debug/supervision_tree.ex new file mode 100644 index 0000000..c84c3bd --- /dev/null +++ b/lib/sunbeam/debug/supervision_tree.ex @@ -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 diff --git a/lib/sunbeam/irc/codes.ex b/lib/sunbeam/irc/codes.ex new file mode 100644 index 0000000..a559d92 --- /dev/null +++ b/lib/sunbeam/irc/codes.ex @@ -0,0 +1,5 @@ +defmodule Sunbeam.Irc.Codes do + @rpl_welcome "001" + + def rpl_welcome(), do: @rpl_welcome +end diff --git a/lib/sunbeam/monitoring_worker.ex b/lib/sunbeam/monitoring_worker.ex new file mode 100644 index 0000000..54b573a --- /dev/null +++ b/lib/sunbeam/monitoring_worker.ex @@ -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 diff --git a/lib/sunbeam/socket_supervisor.ex b/lib/sunbeam/socket_supervisor.ex new file mode 100644 index 0000000..0a8700f --- /dev/null +++ b/lib/sunbeam/socket_supervisor.ex @@ -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 diff --git a/lib/sunbeam/tcp_acceptor.ex b/lib/sunbeam/tcp_acceptor.ex new file mode 100644 index 0000000..be53c8c --- /dev/null +++ b/lib/sunbeam/tcp_acceptor.ex @@ -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 diff --git a/lib/sunbeam/user.ex b/lib/sunbeam/user.ex new file mode 100644 index 0000000..62233b3 --- /dev/null +++ b/lib/sunbeam/user.ex @@ -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 diff --git a/lib/sunbeam/user_supervisor.ex b/lib/sunbeam/user_supervisor.ex new file mode 100644 index 0000000..e37ff41 --- /dev/null +++ b/lib/sunbeam/user_supervisor.ex @@ -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 diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..50d4a65 --- /dev/null +++ b/mix.exs @@ -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 diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..e6e30cb --- /dev/null +++ b/mix.lock @@ -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"}, +} diff --git a/packages/nix/shell.nix b/packages/nix/shell.nix new file mode 100644 index 0000000..891f6b2 --- /dev/null +++ b/packages/nix/shell.nix @@ -0,0 +1,14 @@ +{...}: { + perSystem = {pkgs, ...}: { + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + beamPackages.elixir + beamPackages.erlang + rebar3 + hex + openssl + git + ]; + }; + }; +} diff --git a/packages/nix/systems.nix b/packages/nix/systems.nix new file mode 100644 index 0000000..98d0555 --- /dev/null +++ b/packages/nix/systems.nix @@ -0,0 +1,5 @@ +{...}: { + systems = [ + "x86_64-linux" + ]; +}