Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .elp.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
type = "rebar3"

[otp]
exclude_apps = ["megaco"]
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Mnesia*
log
*_gpb*
*.pem
*.key
*.coverdata
doc
*.log
Expand All @@ -32,3 +33,6 @@ devenv.local.nix

GEMINI.md
.gemini
.agents
.agent
.vscode
112 changes: 112 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# AGENTS.md - erlmur Development Guide

## Build Commands

```bash
just build # Format, compile, and build release
just dev # Start development shell
just test # Run all tests (xref, dialyzer, proper, eunit, ct)
just eunit # Run eunit tests
just ct # Run common test suite
just proper # Run property-based tests (1000 iterations)
just dialyzer # Static analysis
just xref # Cross-reference analysis
just format # Format code with erlfmt
just clean # Clean build artifacts
```

## Code Style Guidelines

### Imports and Includes
- Use `-include("module.hrl")` for local records
- Use `-include_lib("app/include/module.hrl")` for other app headers
- Include gpb headers last: `-include("Mumble_gpb.hrl")`

### Module Documentation
- All modules must have `-moduledoc` with description
- Export functions must have `-doc` with input/output specs
- Use `-spec` for all exported functions with proper types
- Callback functions need `-doc` in behaviour definitions

### Records and State
- Use records for process state (`-record(state, {...})`)
- Use maps for protocol messages
- Access record fields via `#state.field` or `#state{field = Value}`
- Define opaque types with `-opaque` and `-export_type`

### Naming Conventions
- Modules: `snake_case.erl`
- Functions: `snake_case`
- Records: `snake_case`
- Constants: `UPPER_SNAKE`
- Variables: `UpperCamelCase`
- Message types: `'CamelCase'`

### Function Structure
```erlang
-spec function_name(arg1(), arg2()) -> return_type().
function_name(Arg1, Arg2) ->
case do_something(Arg1) of
{ok, Result} ->
handle_result(Result, Arg2);
{error, Reason} ->
handle_error(Reason)
end.
```

### Guards and Pattern Matching
- Use guards for input validation
- Pattern match in function heads when possible
- Use `when` guards for type/size checks
- Prefer specific patterns over catch-all clauses

### Error Handling
- Return `{ok, Value}` or `{error, Reason}` for recoverable errors
- Use `try...of...else` for exceptions that need cleanup
- Log errors with appropriate level (`logger:error`, `logger:warning`)
- Match specific errors before general ones

### gen_statem Conventions
- Use named state functions (`state_name/3`)
- Export callback mode as list: `callback_mode() -> [state_functions, state_enter]`
- Handle `enter` events for state transitions
- Return `{keep_state, Data}` or `{next_state, Name, Data}`
- Use `?TIMEOUT` constant for timeouts

### Logging
- Use `logger:info/notice/debug` for normal events
- Use `logger:warning` for unexpected but handled situations
- Use `logger:error` for failures requiring attention
- Include context in log messages: `"Failed to ~p: ~p", [Action, Reason]`

### Testing
- EUnit: Use `?_test(Fun)` and `?_assert*` macros
- Common Test: Define `-suite()` and `-export([suite/0, ...])`
- Proper: Define properties with `prop_*` and `?FORALL`
- Mocks: Use Meck for OTP module mocking

### Protocol Messages
- Messages are maps with `message_type => 'MessageName'`
- Use atoms for message types (matching GPB definitions)
- Validate required fields before processing
- Include version information in handshake

### Log Level Configuration

The log level can be configured via:

1. **Environment variable** (highest priority):
```bash
ERLMUR_LOG_LEVEL=info rebar3 shell
ERLMUR_LOG_LEVEL=notice rebar3 shell
ERLMUR_LOG_LEVEL=debug rebar3 shell
```

2. **Application environment** (in sys.config):
```erlang
[{erlmur, [{log_level, info}]}].
```

3. **Default**: `info` (shows all `logger:info` messages)

Available levels: `debug`, `info`, `notice`, `warning`, `error`, `critical`, `alert`, `emergency`
8 changes: 3 additions & 5 deletions src/erlmur.app.src → apps/erlmur/src/erlmur.app.src
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
{application, erlmur, [
{description, ""},
{description, "Mumble server implementation in Erlang"},
{vsn, "0.1.0"},
{registered, []},
{applications, [
kernel,
stdlib,
crypto,
public_key,
ranch,
ssl,
mnesia
ranch,
mumble_protocol
]},
{mod, {erlmur_app, []}},
{env, [
Expand Down
4 changes: 1 addition & 3 deletions src/erlmur.erl → apps/erlmur/src/erlmur.erl
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@ server, allowing external applications or the Erlang shell to manage the server.
-export([start/0, register_user/1]).

start() ->
ssl:start(),
application:start(mnesia),
application:start(erlmur).
application:ensure_all_started(erlmur).

register_user(_User) ->
ok.
45 changes: 45 additions & 0 deletions apps/erlmur/src/erlmur_app.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
-module(erlmur_app).

-moduledoc "Main application entry point for the erlmur server.\n\nThis "
"module defines the application behavior and is responsible "
"for starting\nand stopping the main supervisor tree.".

-behaviour(application).

%% Application callbacks
-export([start/2, stop/1]).

%% ===================================================================
%% Application callbacks
%% ===================================================================

start(_StartType, _StartArgs) ->
%% Configure log level: ERLMUR_LOG_LEVEL env var > application env > default (info)
LogLevel = case os:getenv("ERLMUR_LOG_LEVEL") of
false -> application:get_env(erlmur, log_level, info);
LevelStr -> list_to_atom(LevelStr)
end,
logger:set_primary_config(level, LogLevel),
logger:notice("[erlmur] Log level set to: ~p", [LogLevel]),

logger:info("[erlmur] Starting erlmur application..."),
PrivDir = code:priv_dir(erlmur),
Port = application:get_env(erlmur, listen_port, 64738),
CertFile = application:get_env(erlmur, cert_pem, filename:join(PrivDir, "server.pem")),
KeyFile = application:get_env(erlmur, key_pem, filename:join(PrivDir, "server.key")),
logger:info("PrivDir Port CertFile KeyFile",[PrivDir, Port,CertFile,KeyFile]),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Logger format string has no placeholders — will crash or produce a logger error at runtime.

The format string "PrivDir Port CertFile KeyFile" contains no ~p/~s specifiers, but a 4-element list is passed as the second argument. This will cause a logger formatting error.

Proposed fix
-		logger:info("PrivDir Port CertFile KeyFile",[PrivDir, Port,CertFile,KeyFile]),
+		logger:info("PrivDir: ~p Port: ~p CertFile: ~p KeyFile: ~p", [PrivDir, Port, CertFile, KeyFile]),
🤖 Prompt for AI Agents
In `@apps/erlmur/src/erlmur_app.erl` at line 30, Logger call in erlmur_app.erl
passes a four-element list (PrivDir, Port, CertFile, KeyFile) but the format
string has no specifiers; update the logger:info call so the format string
contains four matching placeholders (e.g. ~p or ~s) and retain the existing
argument list so each of PrivDir, Port, CertFile, KeyFile is interpolated;
locate the logger:info invocation in erlmur_app.erl and replace the current
plain string with one that includes four specifiers.

logger:info("[erlmur] Starting Mumble server with SSL cert: ~s", [CertFile]),
%% Use erlmur_server_handler for production, not mock_mumble_handler
case mumble:start_server(CertFile, KeyFile, Port, erlmur_server_handler) of
{ok, ServerRef} ->
logger:info("[erlmur] Mumble server started: ~p", [ServerRef]),
erlmur_sup:start_link(),
logger:info("[erlmur] erlmur application started successfully"),
{ok, self()};
{error, Reason} ->
logger:error("[erlmur] Failed to start Mumble server: ~p", [Reason]),
{error, Reason}
end.

stop(_State) ->
ok.
86 changes: 86 additions & 0 deletions apps/erlmur/src/erlmur_server_handler.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
-module(erlmur_server_handler).
-moduledoc """
Implements the mumble_server_behaviour for the Erlmur server.

This module bridges the mumble_protocol connection handling with the
Erlmur application logic, handling authentication and message routing.
""".

-behaviour(mumble_server_behaviour).

%% mumble_server_behaviour callbacks
-export([init/1, authenticate/2, handle_msg/2, get_caps/1]).

-record(state, {
session_id :: pos_integer() | undefined,
username :: binary() | undefined
}).

%%%===================================================================
%%% mumble_server_behaviour callbacks
%%%===================================================================

init(_Opts) ->
{ok, #state{}}.

authenticate(#{message_type := 'Authenticate', username := Username}, State) ->
%% MVP: Accept all users unconditionally
{ok, SessionId} = erlmur_user_manager:register_user(self(), Username),
UserInfo = #{session_id => SessionId, username => Username},
logger:info("User ~s authenticated with session ~p", [Username, SessionId]),
{ok, UserInfo, State#state{session_id = SessionId, username = Username}};
authenticate(Msg, State) ->
logger:warning("Invalid authenticate message: ~p", [Msg]),
{error, invalid_auth, State}.
Comment on lines +26 to +34
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Unhandled error from erlmur_user_manager:register_user/2 will crash the connection.

Line 28 uses {ok, SessionId} = erlmur_user_manager:register_user(self(), Username). If register_user returns {error, _} (e.g., duplicate user, manager down), the match fails and the process crashes with a badmatch. Handle the error case gracefully.

♻️ Proposed fix
 authenticate(#{message_type := 'Authenticate', username := Username}, State) ->
-    %% MVP: Accept all users unconditionally
-    {ok, SessionId} = erlmur_user_manager:register_user(self(), Username),
-    UserInfo = #{session_id => SessionId, username => Username},
-    logger:info("User ~s authenticated with session ~p", [Username, SessionId]),
-    {ok, UserInfo, State#state{session_id = SessionId, username = Username}};
+    %% MVP: Accept all users unconditionally
+    case erlmur_user_manager:register_user(self(), Username) of
+        {ok, SessionId} ->
+            UserInfo = #{session_id => SessionId, username => Username},
+            logger:info("User ~s authenticated with session ~p", [Username, SessionId]),
+            {ok, UserInfo, State#state{session_id = SessionId, username = Username}};
+        {error, Reason} ->
+            logger:error("Failed to register user ~s: ~p", [Username, Reason]),
+            {error, registration_failed, State}
+    end;
🤖 Prompt for AI Agents
In `@apps/erlmur/src/erlmur_server_handler.erl` around lines 26 - 34, The
authenticate/2 function currently does a destructive match on the call to
erlmur_user_manager:register_user(self(), Username) which will crash on
{error,_}; update authenticate/2 to handle both successful and error tuples from
erlmur_user_manager:register_user/2 instead of using a direct {ok, SessionId} =
... match: on {ok, SessionId} proceed to build UserInfo, log with logger:info
and return {ok, UserInfo, State#state{session_id = SessionId, username =
Username}}; on {error, Reason} log a warning or error with the Reason (using
logger:warning/2 or logger:error/2) and return a non-crashing error tuple such
as {error, {register_failed, Reason}, State} (or {error, invalid_auth, State} if
you prefer), ensuring you reference the authenticate/2 function,
erlmur_user_manager:register_user/2, SessionId, UserInfo and State#state when
making the change.


handle_msg(#{message_type := connection_status, status := established, session_id := SessionId}, State) ->
logger:info("Session ~p established", [SessionId]),
{ok, State};

handle_msg(#{message_type := connection_status, status := udp_verified, session_id := SessionId}, State) ->
logger:info("UDP verified for session ~p", [SessionId]),
{ok, State};

handle_msg(#{message_type := connection_status, status := udp_lost, session_id := SessionId}, State) ->
logger:info("UDP lost for session ~p, falling back to TCP", [SessionId]),
{ok, State};

handle_msg(#{message_type := 'TextMessage', message := Message}, State = #state{session_id = SessionId}) ->
logger:info("Text message from session ~p: ~s", [SessionId, Message]),
erlmur_user_manager:broadcast_text(SessionId, Message),
{ok, State};

handle_msg(#{message_type := 'UserState'}, State) ->
%% Ignore user state updates for MVP
{ok, State};

handle_msg(#{message_type := 'ChannelState'}, State) ->
%% Ignore channel state updates for MVP
{ok, State};

handle_msg(#{message_type := 'PermissionQuery'}, State) ->
%% Respond with default permissions for MVP
mumble_server_conn:send(self(), #{
message_type => 'PermissionQuery',
channel_id => 0,
permissions => 16#FFFFFFFF %% All permissions
}),
{ok, State};
Comment on lines +61 to +68
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Security: granting all permissions unconditionally.

16#FFFFFFFF gives every client full admin rights (kick, ban, move, manage channels, etc.). Even for an MVP, this is risky if exposed to untrusted networks. Consider returning a minimal read/speak/text permission set now and expanding later.

🤖 Prompt for AI Agents
In `@apps/erlmur/src/erlmur_server_handler.erl` around lines 61 - 68, The
PermissionQuery handler in function handle_msg currently grants full admin
rights by sending permissions => 16#FFFFFFFF; change this to a minimal safe
permission bitmask (e.g. only read/speak/text bits) before sending via
mumble_server_conn:send in the handle_msg clause that matches #{message_type :=
'PermissionQuery'} so clients are not given kick/ban/manage/channel rights;
update the permissions value and add a short comment explaining the reduced
default.


handle_msg(#{message_type := 'VoiceTarget'}, State) ->
%% Ignore voice target setup for MVP
{ok, State};

handle_msg(Msg, State) ->
logger:debug("Unhandled message: ~p", [Msg]),
{ok, State}.

get_caps(_State) ->
#{
major => 1,
minor => 2,
patch => 4,
release => <<"Erlmur MVP">>,
os => <<"Erlang/OTP">>,
os_version => erlang:system_info(otp_release)
}.
32 changes: 32 additions & 0 deletions apps/erlmur/src/erlmur_sup.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
-module(erlmur_sup).

-moduledoc("The main supervisor for the erlmur application.").

-behaviour(supervisor).

%% API
-export([start_link/0]).
%% Supervisor callbacks
-export([init/1]).

%% ===================================================================
%% API functions
%% ===================================================================

start_link() ->
supervisor:start_link({local, ?MODULE}, ?MODULE, []).

%% ===================================================================
%% Supervisor callbacks
%% ===================================================================

init([]) ->
logger:info("[erlmur_sup] Starting user manager..."),
UserManager = #{
id => erlmur_user_manager,
start => {erlmur_user_manager, start_link, []},
restart => permanent,
type => worker
},
logger:info("[erlmur_sup] User manager started"),
{ok, {{one_for_one, 5, 10}, [UserManager]}}.
Loading
Loading