Skip to main content

src/vpn_n2o_admin.erl

%%%-------------------------------------------------------------------
%% @doc Minimal read-only N2O/Nitro administration dashboard skeleton.
%%%-------------------------------------------------------------------
-module(vpn_n2o_admin).

-include_lib("nitro/include/cx.hrl").
-include_lib("nitro/include/nitro.hrl").

-export([init/2,
         cx/2,
         event/1,
         refresh_dashboard/0]).

init(Req0, State) ->
    case cowboy_req:method(Req0) of
        <<"GET">> ->
            reply_page(Req0, State);
        _Other ->
            Req = cowboy_req:reply(
                405,
                #{<<"allow">> => <<"GET">>},
                Req0),
            {ok, Req, State}
    end.

reply_page(Req0, State) ->
    try render(vpn_admin:summary_view()) of
        Html ->
            Req = cowboy_req:reply(
                200,
                #{<<"content-type">> => <<"text/html; charset=utf-8">>},
                Html,
                Req0),
            {ok, Req, State}
    catch
        Class:Reason:Stack ->
            logger:error("N2O dashboard render failed: ~p:~p~n~p",
                         [Class, Reason, Stack]),
            Req = cowboy_req:reply(
                500,
                #{<<"content-type">> => <<"text/html; charset=utf-8">>},
                <<"<!doctype html><html><head><title>VPN Dashboard N2O Error</title></head>"
                  "<body><h1>VPN Dashboard N2O Error</h1></body></html>">>,
                Req0),
            {ok, Req, State}
    end.

render(Summary) ->
    nitro:actions([]),
    Counts = maps:get(counts, Summary, #{}),
    Peers = maps:get(peers, Summary, []),
    Body = nitro:render([
        #main{
            body = [
                #h1{body = <<"VPN Dashboard (N2O)">>},
                render_actions(),
                render_message(<<>>),
                render_counts(Counts),
                render_peer_table(Peers),
                #p{class = <<"runtime">>,
                   body = [<<"N2O ">>, n2o_version(), <<" / Nitro rendered">>]}
            ]
        }
    ]),
    iolist_to_binary([
        <<"<!doctype html><html><head><meta charset=\"utf-8\">">>,
        <<"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">">>,
        <<"<title>VPN Dashboard (N2O)</title>">>,
        style(),
        <<"</head><body>">>,
        Body,
        scripts(),
        <<"</body></html>">>
    ]).

render_actions() ->
    #section{
        class = <<"actions">>,
        body = [
            #button{
                body = <<"Reload Config">>,
                onclick = <<"direct(atom('reload_config'));">>
            }
        ]
    }.

render_message(Message) ->
    #panel{
        id = <<"dashboard_messages">>,
        class = <<"messages">>,
        body = Message
    }.

render_counts(Counts) ->
    #section{
        id = <<"dashboard_counts">>,
        class = <<"counts">>,
        body = [
            count_card(<<"Configured Peers">>, maps:get(configured, Counts, 0)),
            count_card(<<"Running Peers">>, maps:get(running, Counts, 0)),
            count_card(<<"Stopped Peers">>, maps:get(stopped, Counts, 0)),
            count_card(<<"Certificates">>, maps:get(certificates, Counts, 0))
        ]
    }.

count_card(Label, Value) ->
    #panel{
        class = <<"count">>,
        body = [
            #span{body = Label},
            #span{class = <<"value">>, body = integer_to_binary(Value)}
        ]
    }.

render_peer_table(Peers) ->
    HeaderRow = #tr{cells = [header_cell(Label) || Label <- table_headers()]},
    Rows = [render_peer_row(Peer) || Peer <- Peers],
    #table{
        id = <<"dashboard_peers">>,
        header = HeaderRow,
        body = #tbody{body = Rows}
    }.

table_headers() ->
    [
        <<"Peer">>,
        <<"Running">>,
        <<"Mode">>,
        <<"IP">>,
        <<"Remote Peer">>,
        <<"Trusted">>,
        <<"Key Match">>,
        <<"Expires">>,
        <<"Crypto Failures">>,
        <<"Frames Rejected">>,
        <<"Actions">>
    ].

header_cell(Label) ->
    #th{body = Label}.

render_peer_row(Peer) ->
    Certificate = maps:get(certificate, Peer, #{}),
    #tr{
        cells = [
            cell(maps:get(id, Peer, null)),
            cell(yes_no(maps:get(running, Peer, false))),
            cell(maps:get(mode, Peer, null)),
            cell(maps:get(ip, Peer, null)),
            cell(maps:get(remote_peer_id, Peer, null)),
            cell(yes_no(maps:get(trusted, Certificate, false))),
            cell(yes_no(maps:get(key_match, Certificate, false))),
            cell(maps:get(not_after, Certificate, null)),
            cell(maps:get(crypto_failures, Peer, 0)),
            cell(maps:get(frames_rejected, Peer, 0)),
            action_cell(Peer)
        ]
    }.

cell(Value) ->
    #td{body = value(Value)}.

action_cell(Peer) ->
    PeerId = maps:get(id, Peer, null),
    Running = maps:get(running, Peer, false),
    #td{body = action_button(PeerId, Running)}.

action_button(PeerId, true) ->
    #button{
        body = <<"Stop">>,
        onclick = direct_peer_event(<<"stop_peer">>, PeerId)
    };
action_button(PeerId, false) ->
    #button{
        body = <<"Start">>,
        onclick = direct_peer_event(<<"start_peer">>, PeerId)
    }.

yes_no(true) ->
    <<"yes">>;
yes_no(false) ->
    <<"no">>;
yes_no(_Value) ->
    <<"no">>.

value(null) ->
    <<>>;
value(undefined) ->
    <<>>;
value(Value) when is_binary(Value) ->
    Value;
value(Value) when is_integer(Value) ->
    integer_to_binary(Value);
value(Value) when is_float(Value) ->
    float_to_binary(Value, [{decimals, 6}, compact]);
value(true) ->
    <<"true">>;
value(false) ->
    <<"false">>;
value(Value) when is_atom(Value) ->
    atom_to_binary(Value, utf8);
value(Value) when is_list(Value) ->
    unicode:characters_to_binary(Value);
value(_Value) ->
    <<>>.

style() ->
    <<"<style>"
      "body{font-family:system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;"
      "margin:0;background:#f6f7f9;color:#17202a;}"
      "main{max-width:1180px;margin:0 auto;padding:32px 20px;}"
      "h1{font-size:28px;margin:0 0 24px;}"
      ".actions{margin:0 0 18px;}"
      ".messages{min-height:20px;margin:0 0 14px;color:#57606a;font-size:14px;}"
      ".counts{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:24px;}"
      ".count{background:#fff;border:1px solid #d8dee4;border-radius:6px;padding:14px;}"
      ".count span{display:block;color:#57606a;font-size:13px;margin-bottom:8px;}"
      ".count .value{font-size:24px;color:#17202a;}"
      "table{width:100%;border-collapse:collapse;background:#fff;border:1px solid #d8dee4;border-radius:6px;overflow:hidden;}"
      "th,td{text-align:left;border-bottom:1px solid #d8dee4;padding:10px 12px;font-size:14px;}"
      "th{background:#eef1f4;color:#24292f;font-weight:600;}"
      "tr:last-child td{border-bottom:0;}"
      "button{appearance:none;border:1px solid #57606a;background:#fff;border-radius:6px;"
      "padding:7px 12px;font:inherit;cursor:pointer;}"
      "button:hover{background:#eef1f4;}"
      ".runtime{color:#57606a;font-size:13px;}"
      "</style>">>.

scripts() ->
    [
        <<"<script>var port = window.location.port || \"\";</script>">>,
        <<"<script src=\"/n2o/utf8.js\"></script>">>,
        <<"<script src=\"/n2o/bert.js\"></script>">>,
        <<"<script src=\"/n2o/heart.js\"></script>">>,
        <<"<script src=\"/n2o/n2o.js\"></script>">>,
        <<"<script src=\"/nitro/js/nitro.js\"></script>">>,
        <<"<script>N2O_start();</script>">>
    ].

direct_peer_event(Event, PeerId) ->
    EscapedPeerId = nitro:js_escape(value(PeerId)),
    [<<"direct(tuple(atom('">>, Event, <<"'),bin('">>, EscapedPeerId, <<"')));">>].

n2o_version() ->
    case n2o:version() of
        undefined ->
            <<"unknown">>;
        Version ->
            nitro:to_binary(Version)
    end.

event({start_peer, PeerIdValue}) ->
    PeerIdBin = value(PeerIdValue),
    Message =
        case find_peer_id(PeerIdBin) of
            {ok, PeerId} ->
                action_message(start, PeerIdBin, vpn_manager:start_peer(PeerId));
            {error, not_found} ->
                log_action_error(start, PeerIdBin, not_found),
                <<"Peer not found">>
        end,
    refresh_dashboard(Message);
event({stop_peer, PeerIdValue}) ->
    PeerIdBin = value(PeerIdValue),
    Message =
        case find_peer_id(PeerIdBin) of
            {ok, PeerId} ->
                action_message(stop, PeerIdBin, vpn_manager:stop_peer(PeerId));
            {error, not_found} ->
                log_action_error(stop, PeerIdBin, not_found),
                <<"Peer not found">>
        end,
    refresh_dashboard(Message);
event(reload_config) ->
    Message = reload_message(vpn_manager:reload_config()),
    refresh_dashboard(Message);
event(init) ->
    ok;
event(_Event) ->
    ok.

refresh_dashboard() ->
    refresh_dashboard(<<>>).

refresh_dashboard(Message) ->
    Summary = vpn_admin:summary_view(),
    Counts = maps:get(counts, Summary, #{}),
    Peers = maps:get(peers, Summary, []),
    nitro:update(dashboard_messages, render_message(Message)),
    nitro:update(dashboard_counts, render_counts(Counts)),
    nitro:update(dashboard_peers, render_peer_table(Peers)),
    ok.

action_message(start, PeerIdBin, {ok, _Pid}) ->
    [<<"Peer ">>, PeerIdBin, <<" started">>];
action_message(start, PeerIdBin, {error, already_started}) ->
    log_action_error(start, PeerIdBin, already_started),
    [<<"Peer ">>, PeerIdBin, <<" already started">>];
action_message(start, PeerIdBin, {error, Reason}) ->
    log_action_error(start, PeerIdBin, Reason),
    [<<"Failed to start peer ">>, PeerIdBin];
action_message(stop, PeerIdBin, ok) ->
    [<<"Peer ">>, PeerIdBin, <<" stopped">>];
action_message(stop, PeerIdBin, {error, Reason}) ->
    log_action_error(stop, PeerIdBin, Reason),
    [<<"Failed to stop peer ">>, PeerIdBin].

reload_message(#{failed := []}) ->
    <<"Configuration reloaded">>;
reload_message(#{failed := Failed}) ->
    logger:warning("N2O dashboard reload completed with failures: ~p", [Failed]),
    <<"Configuration reloaded with failures">>;
reload_message(Result) ->
    logger:info("N2O dashboard reload completed: ~p", [Result]),
    <<"Configuration reloaded">>.

find_peer_id(PeerIdBin) ->
    case [PeerId || PeerId <- vpn_manager:list_peers(),
                    peer_id_binary(PeerId) =:= PeerIdBin] of
        [PeerId | _] ->
            {ok, PeerId};
        [] ->
            {error, not_found}
    end.

peer_id_binary(PeerId) when is_binary(PeerId) ->
    PeerId;
peer_id_binary(PeerId) when is_atom(PeerId) ->
    atom_to_binary(PeerId, utf8).

log_action_error(Action, PeerIdBin, Reason) ->
    logger:warning("N2O dashboard peer action failed action=~p peer=~s reason=~p",
                   [Action, PeerIdBin, Reason]).

cx(Cookies, Req) ->
    Token =
        case lists:keyfind(<<"X-Auth-Token">>, 1, Cookies) of
            {_, Value} ->
                Value;
            false ->
                <<>>
        end,
    Sid =
        case n2o:depickle(Token) of
            {{Session, _}, _} ->
                Session;
            _Other ->
                <<>>
        end,
    #cx{actions = [],
        path = cowboy_req:path(Req),
        req = Req,
        params = [],
        session = Sid,
        token = Token,
        module = ?MODULE,
        handlers = []}.