···11-(*---------------------------------------------------------------------------
22- Copyright (c) 2025 Thomas Gazagnaire. All rights reserved.
33- SPDX-License-Identifier: ISC
44- ---------------------------------------------------------------------------*)
55-66-(** Borealis control CLI. *)
77-88-open Cmdliner
99-1010-let log_src = Logs.Src.create "borctl"
1111-1212-module Log = (val Logs.src_log log_src)
1313-1414-(* Common options *)
1515-let node_id =
1616- let doc = "Target node ID (IPN scheme node number)." in
1717- Arg.(value & opt int64 1L & info [ "n"; "node" ] ~docv:"NODE" ~doc)
1818-1919-let source_node =
2020- let doc = "Source node ID for admin bundles." in
2121- Arg.(value & opt int64 0L & info [ "s"; "source" ] ~docv:"SOURCE" ~doc)
2222-2323-let output_file =
2424- let doc = "Output file for admin bundle (default: stdout)." in
2525- Arg.(
2626- value & opt (some string) None & info [ "o"; "output" ] ~docv:"FILE" ~doc)
2727-2828-(* Create admin bundle *)
2929-let make_admin_bundle ~source ~dest record =
3030- let timestamp : Bundle.timestamp =
3131- { time = Int64.of_float (Unix.gettimeofday () *. 1000.); seq = 0L }
3232- in
3333- Admin.make_bundle ~source ~destination:dest ~timestamp record
3434-3535-let write_bundle bundle output =
3636- let data = Bundle.encode bundle in
3737- match output with
3838- | None -> print_string data
3939- | Some file ->
4040- let oc = open_out_bin file in
4141- output_string oc data;
4242- close_out oc;
4343- Log.info (fun m -> m "Wrote bundle to %s" file)
4444-4545-(* Status command *)
4646-let status source dest output () =
4747- let record = Admin.Query Admin.Query_status in
4848- let bundle = make_admin_bundle ~source ~dest record in
4949- write_bundle bundle output;
5050- Log.info (fun m -> m "Generated status query bundle")
5151-5252-let status_cmd =
5353- let doc = "Query node status." in
5454- let info = Cmd.info "status" ~doc in
5555- let dest = Term.(const (fun n -> Bundle.Ipn (n, 0L)) $ node_id) in
5656- let source = Term.(const (fun n -> Bundle.Ipn (n, 0L)) $ source_node) in
5757- Cmd.v info
5858- Term.(const status $ source $ dest $ output_file $ Vlog.setup "borctl")
5959-6060-(* Contacts command *)
6161-let contacts source dest output () =
6262- let record = Admin.Query Admin.Query_contacts in
6363- let bundle = make_admin_bundle ~source ~dest record in
6464- write_bundle bundle output;
6565- Log.info (fun m -> m "Generated contacts query bundle")
6666-6767-let contacts_cmd =
6868- let doc = "Query contact plan." in
6969- let info = Cmd.info "contacts" ~doc in
7070- let dest = Term.(const (fun n -> Bundle.Ipn (n, 0L)) $ node_id) in
7171- let source = Term.(const (fun n -> Bundle.Ipn (n, 0L)) $ source_node) in
7272- Cmd.v info
7373- Term.(const contacts $ source $ dest $ output_file $ Vlog.setup "borctl")
7474-7575-(* Policy command *)
7676-let policy_cmd =
7777- let doc = "Manage routing policies." in
7878- let info = Cmd.info "policy" ~doc in
7979- Cmd.v info Term.(ret (const (fun () -> `Help (`Pager, None)) $ const ()))
8080-8181-(* Bundles command *)
8282-let bundles source dest filter output () =
8383- let record = Admin.Query (Admin.Query_bundles { filter }) in
8484- let bundle = make_admin_bundle ~source ~dest record in
8585- write_bundle bundle output;
8686- Log.info (fun m -> m "Generated bundles query bundle")
8787-8888-let filter_arg =
8989- let doc = "Filter expression for bundle listing." in
9090- Arg.(
9191- value & opt (some string) None & info [ "f"; "filter" ] ~docv:"FILTER" ~doc)
9292-9393-let bundles_cmd =
9494- let doc = "List stored bundles." in
9595- let info = Cmd.info "bundles" ~doc in
9696- let dest = Term.(const (fun n -> Bundle.Ipn (n, 0L)) $ node_id) in
9797- let source = Term.(const (fun n -> Bundle.Ipn (n, 0L)) $ source_node) in
9898- Cmd.v info
9999- Term.(
100100- const bundles $ source $ dest $ filter_arg $ output_file
101101- $ Vlog.setup "borctl")
102102-103103-(* Decode command - decode and display an admin bundle *)
104104-let decode file () =
105105- let data =
106106- match file with
107107- | None ->
108108- let buf = Buffer.create 4096 in
109109- (try
110110- while true do
111111- Buffer.add_channel buf stdin 4096
112112- done
113113- with End_of_file -> ());
114114- Buffer.contents buf
115115- | Some f ->
116116- let ic = open_in_bin f in
117117- let len = in_channel_length ic in
118118- let data = really_input_string ic len in
119119- close_in ic;
120120- data
121121- in
122122- match Bundle.decode data with
123123- | Error e ->
124124- Log.err (fun m -> m "Failed to decode bundle: %a" Bundle.pp_error e);
125125- exit 1
126126- | Ok bundle -> (
127127- Fmt.pr "@[<v>Bundle:@, %a@]@." Bundle.pp bundle;
128128- match Admin.extract bundle with
129129- | Error e -> Log.err (fun m -> m "Not an admin bundle: %s" e)
130130- | Ok record -> Fmt.pr "@[<v>Admin record:@, %a@]@." Admin.pp record)
131131-132132-let input_file =
133133- let doc = "Input file (default: stdin)." in
134134- Arg.(value & opt (some string) None & info [ "i"; "input" ] ~docv:"FILE" ~doc)
135135-136136-let decode_cmd =
137137- let doc = "Decode and display an admin bundle." in
138138- let info = Cmd.info "decode" ~doc in
139139- Cmd.v info Term.(const decode $ input_file $ Vlog.setup "borctl")
140140-141141-(* Main *)
142142-let info =
143143- let doc = "Control CLI for borealis DTN daemon" in
144144- let man =
145145- [
146146- `S Manpage.s_description;
147147- `P
148148- "borctl generates admin bundles for controlling borealis nodes. These \
149149- bundles can be injected into the network for delivery.";
150150- `S Manpage.s_commands;
151151- `P "$(b,borctl status) - Query node status";
152152- `P "$(b,borctl contacts) - Query contact plan";
153153- `P "$(b,borctl bundles) - List stored bundles";
154154- `P "$(b,borctl decode) - Decode admin bundle";
155155- `S Manpage.s_see_also;
156156- `P "$(b,borealis)(1) - DTN daemon.";
157157- ]
158158- in
159159- Cmd.info "borctl" ~version:"0.1.0" ~doc ~man
160160-161161-let default = Term.(ret (const (fun () -> `Help (`Pager, None)) $ const ()))
162162-163163-let () =
164164- let cmd =
165165- Cmd.group info ~default
166166- [ status_cmd; contacts_cmd; policy_cmd; bundles_cmd; decode_cmd ]
167167- in
168168- exit (Cmd.eval cmd)
+288-32
bin/borealis.ml
···33 SPDX-License-Identifier: ISC
44 ---------------------------------------------------------------------------*)
5566-(** Borealis DTN daemon. *)
66+(** Borealis DTN daemon and control CLI. *)
7788open Cmdliner
99···11111212module Log = (val Logs.src_log log_src)
13131414+(* ============================================================================
1515+ Daemon (run)
1616+ ============================================================================ *)
1717+1418(* Default policy: forward via CGR if route exists, otherwise store *)
1519let default_policy =
1620 Policy.if_pred Predicate.is_admin
···1923 (Temporal.route_exists (Bundle.Ipn (0L, 0L)))
2024 Policy.forward_route Policy.store_until_route)
21252222-let run node_id listen_port () =
2626+let run node_id listen_port config_file () =
2327 Eio_main.run @@ fun env ->
2424- let net = Eio.Stdenv.net env in
2525- let sw = Eio.Stdenv.fs env in
2626- ignore (net, sw);
2727- let config = Engine.make_config ~node_id in
2828- let contact_plan = Cgr.Contact_plan.empty in
2828+ Eio.Switch.run @@ fun sw ->
2929+ let clock = Eio.Stdenv.clock env in
3030+ let config, contact_plan, peers =
3131+ match config_file with
3232+ | Some file -> (
3333+ match Config.load file with
3434+ | Ok c ->
3535+ let plan = Config.to_contact_plan c in
3636+ (Engine.make_config ~node_id:c.node_id, plan, c.peers)
3737+ | Error e ->
3838+ Log.err (fun m -> m "Failed to load config: %s" e);
3939+ (Engine.make_config ~node_id, Cgr.Contact_plan.empty, []))
4040+ | None -> (Engine.make_config ~node_id, Cgr.Contact_plan.empty, [])
4141+ in
2942 let engine = Engine.create ~config ~policy:default_policy ~contact_plan in
3030- Log.info (fun m ->
3131- m "Borealis starting on node %Ld, listening on port %d" node_id
3232- listen_port);
4343+ let daemon = Daemon.create ~engine in
4444+ List.iter
4545+ (fun (p : Config.peer) ->
4646+ let node = Cgr.Node.v (Int64.to_string p.node_id) in
4747+ let address = Config.peer_address p in
4848+ Daemon.add_peer daemon ~node ~address)
4949+ peers;
5050+ let net = Eio.Stdenv.net env in
5151+ Log.app (fun m ->
5252+ m "Borealis starting: node=%Ld port=%d peers=%d" node_id listen_port
5353+ (List.length peers));
3354 Log.info (fun m -> m "Local EID: %a" Bundle.pp_eid config.local_eid);
3434- Log.info (fun m -> m "Admin EID: %a" Bundle.pp_eid config.admin_eid);
3535-3636- (* Main loop - for now just log stats periodically *)
3737- let rec loop () =
3838- Eio.Time.sleep (Eio.Stdenv.clock env) 10.0;
3939- let stats = Engine.stats engine in
4040- Log.info (fun m -> m "Stats: %a" Engine.pp_stats stats);
4141- let expired =
4242- Engine.cleanup_expired engine ~current_time:(Unix.gettimeofday ())
4343- in
4444- if expired > 0 then
4545- Log.info (fun m -> m "Cleaned up %d expired bundles" expired);
4646- loop ()
4747- in
4848- loop ()
5555+ Daemon.run daemon ~sw ~net ~clock ~port:listen_port
49565050-(* CLI *)
5151-let node_id =
5757+let node_id_opt =
5258 let doc = "Node ID (IPN scheme node number)." in
5359 Arg.(value & opt int64 1L & info [ "n"; "node-id" ] ~docv:"NODE" ~doc)
5460···5662 let doc = "TCP port to listen on for TCPCL connections." in
5763 Arg.(value & opt int 4556 & info [ "p"; "port" ] ~docv:"PORT" ~doc)
58646565+let config_file =
6666+ let doc = "Configuration file." in
6767+ Arg.(
6868+ value & opt (some string) None & info [ "c"; "config" ] ~docv:"FILE" ~doc)
6969+5970let run_cmd =
6071 let doc = "Run the borealis DTN daemon." in
6172 let info = Cmd.info "run" ~doc in
6262- Cmd.v info Term.(const run $ node_id $ listen_port $ Vlog.setup "borealis")
7373+ Cmd.v info
7474+ Term.(
7575+ const run $ node_id_opt $ listen_port $ config_file
7676+ $ Vlog.setup "borealis")
7777+7878+(* ============================================================================
7979+ Inject (send a test bundle)
8080+ ============================================================================ *)
8181+8282+let inject source_node dest_node payload dest_host dest_port () =
8383+ Eio_main.run @@ fun env ->
8484+ Eio.Switch.run @@ fun sw ->
8585+ let net = Eio.Stdenv.net env in
8686+ let clock = Eio.Stdenv.clock env in
8787+ let source = Bundle.Ipn (source_node, 1L) in
8888+ let destination = Bundle.Ipn (dest_node, 1L) in
8989+ let now = Eio.Time.now clock in
9090+ let timestamp : Bundle.timestamp =
9191+ { time = Int64.of_float (now *. 1000.); seq = 0L }
9292+ in
9393+ let primary : Bundle.primary_block =
9494+ {
9595+ version = 7;
9696+ flags = Bundle.bundle_flags_default;
9797+ crc_type = Bundle.Crc_none;
9898+ destination;
9999+ source;
100100+ report_to = source;
101101+ creation_timestamp = timestamp;
102102+ lifetime = 86400L;
103103+ fragment_offset = None;
104104+ total_adu_length = None;
105105+ }
106106+ in
107107+ let payload_block : Bundle.canonical_block =
108108+ {
109109+ block_type = Payload;
110110+ block_number = 1;
111111+ flags = Bundle.block_flags_default;
112112+ crc_type = Bundle.Crc_none;
113113+ data = Payload_data payload;
114114+ }
115115+ in
116116+ let bundle : Bundle.t = { primary; blocks = [ payload_block ] } in
117117+ let addr = `Tcp (Eio.Net.Ipaddr.of_raw dest_host, dest_port) in
118118+ Log.info (fun m -> m "Connecting to %s:%d..." dest_host dest_port);
119119+ let conn = Tcpcl_adapter.connect ~sw ~net ~local_eid:source ~addr in
120120+ if Tcpcl_adapter.is_connected conn then (
121121+ match Tcpcl_adapter.send_bundle conn bundle with
122122+ | Ok () ->
123123+ Fmt.pr "✓ Bundle injected: %Ld → %Ld (%d bytes)@." source_node dest_node
124124+ (String.length payload);
125125+ Tcpcl_adapter.close_connection conn
126126+ | Error e ->
127127+ Fmt.pr "✗ Failed to send: %s@." e;
128128+ exit 1)
129129+ else (
130130+ Fmt.pr "✗ Failed to connect@.";
131131+ exit 1)
132132+133133+let source_node =
134134+ let doc = "Source node ID." in
135135+ Arg.(
136136+ required & opt (some int64) None & info [ "s"; "source" ] ~docv:"NODE" ~doc)
137137+138138+let dest_node =
139139+ let doc = "Destination node ID." in
140140+ Arg.(
141141+ required & opt (some int64) None & info [ "d"; "dest" ] ~docv:"NODE" ~doc)
142142+143143+let payload_arg =
144144+ let doc = "Bundle payload." in
145145+ Arg.(
146146+ value & opt string "Hello DTN!" & info [ "m"; "message" ] ~docv:"MSG" ~doc)
147147+148148+let dest_host =
149149+ let doc = "Destination host." in
150150+ Arg.(value & opt string "127.0.0.1" & info [ "H"; "host" ] ~docv:"HOST" ~doc)
151151+152152+let dest_port_arg =
153153+ let doc = "Destination port." in
154154+ Arg.(value & opt int 4556 & info [ "P"; "dest-port" ] ~docv:"PORT" ~doc)
155155+156156+let inject_cmd =
157157+ let doc = "Inject a test bundle into the network." in
158158+ let info = Cmd.info "inject" ~doc in
159159+ Cmd.v info
160160+ Term.(
161161+ const inject $ source_node $ dest_node $ payload_arg $ dest_host
162162+ $ dest_port_arg $ Vlog.setup "borealis")
163163+164164+(* ============================================================================
165165+ Control CLI
166166+ ============================================================================ *)
167167+168168+let target_node =
169169+ let doc = "Target node ID (IPN scheme node number)." in
170170+ Arg.(value & opt int64 1L & info [ "n"; "node" ] ~docv:"NODE" ~doc)
171171+172172+let admin_source_node =
173173+ let doc = "Source node ID for admin bundles." in
174174+ Arg.(value & opt int64 0L & info [ "s"; "source" ] ~docv:"SOURCE" ~doc)
175175+176176+let output_file =
177177+ let doc = "Output file for admin bundle (default: stdout)." in
178178+ Arg.(
179179+ value & opt (some string) None & info [ "o"; "output" ] ~docv:"FILE" ~doc)
180180+181181+let make_admin_bundle ~source ~dest record =
182182+ Eio_main.run @@ fun env ->
183183+ let clock = Eio.Stdenv.clock env in
184184+ let now = Eio.Time.now clock in
185185+ let timestamp : Bundle.timestamp =
186186+ { time = Int64.of_float (now *. 1000.); seq = 0L }
187187+ in
188188+ Admin.make_bundle ~source ~destination:dest ~timestamp record
189189+190190+let write_bundle bundle output =
191191+ let data = Bundle.encode bundle in
192192+ match output with
193193+ | None -> print_string data
194194+ | Some file ->
195195+ let oc = open_out_bin file in
196196+ output_string oc data;
197197+ close_out oc;
198198+ Fmt.pr "✓ Wrote bundle to %s@." file
199199+200200+let status source dest output () =
201201+ let record = Admin.Query Admin.Query_status in
202202+ let bundle = make_admin_bundle ~source ~dest record in
203203+ write_bundle bundle output
204204+205205+let status_cmd =
206206+ let doc = "Query node status." in
207207+ let info = Cmd.info "status" ~doc in
208208+ let dest = Term.(const (fun n -> Bundle.Ipn (n, 0L)) $ target_node) in
209209+ let source = Term.(const (fun n -> Bundle.Ipn (n, 0L)) $ admin_source_node) in
210210+ Cmd.v info
211211+ Term.(const status $ source $ dest $ output_file $ Vlog.setup "borealis")
212212+213213+let contacts source dest output () =
214214+ let record = Admin.Query Admin.Query_contacts in
215215+ let bundle = make_admin_bundle ~source ~dest record in
216216+ write_bundle bundle output
217217+218218+let contacts_cmd =
219219+ let doc = "Query contact plan." in
220220+ let info = Cmd.info "contacts" ~doc in
221221+ let dest = Term.(const (fun n -> Bundle.Ipn (n, 0L)) $ target_node) in
222222+ let source = Term.(const (fun n -> Bundle.Ipn (n, 0L)) $ admin_source_node) in
223223+ Cmd.v info
224224+ Term.(const contacts $ source $ dest $ output_file $ Vlog.setup "borealis")
225225+226226+let policy_deploy_cmd =
227227+ let doc = "Deploy a policy file." in
228228+ let info = Cmd.info "deploy" ~doc in
229229+ Cmd.v info Term.(ret (const (fun () -> `Help (`Pager, None)) $ const ()))
230230+231231+let policy_cmd =
232232+ let doc = "Manage routing policies." in
233233+ let info = Cmd.info "policy" ~doc in
234234+ let default =
235235+ Term.(ret (const (fun () -> `Help (`Pager, None)) $ const ()))
236236+ in
237237+ Cmd.group info ~default [ policy_deploy_cmd ]
238238+239239+let bundles source dest filter output () =
240240+ let record = Admin.Query (Admin.Query_bundles { filter }) in
241241+ let bundle = make_admin_bundle ~source ~dest record in
242242+ write_bundle bundle output
243243+244244+let filter_arg =
245245+ let doc = "Filter expression for bundle listing." in
246246+ Arg.(
247247+ value & opt (some string) None & info [ "f"; "filter" ] ~docv:"FILTER" ~doc)
248248+249249+let bundles_list_cmd =
250250+ let doc = "List stored bundles." in
251251+ let info = Cmd.info "list" ~doc in
252252+ let dest = Term.(const (fun n -> Bundle.Ipn (n, 0L)) $ target_node) in
253253+ let source = Term.(const (fun n -> Bundle.Ipn (n, 0L)) $ admin_source_node) in
254254+ Cmd.v info
255255+ Term.(
256256+ const bundles $ source $ dest $ filter_arg $ output_file
257257+ $ Vlog.setup "borealis")
258258+259259+let bundles_cmd =
260260+ let doc = "Manage stored bundles." in
261261+ let info = Cmd.info "bundles" ~doc in
262262+ let default =
263263+ Term.(ret (const (fun () -> `Help (`Pager, None)) $ const ()))
264264+ in
265265+ Cmd.group info ~default [ bundles_list_cmd ]
266266+267267+let decode file () =
268268+ Eio_main.run @@ fun env ->
269269+ let fs = Eio.Stdenv.fs env in
270270+ let data =
271271+ match file with
272272+ | None ->
273273+ let buf = Buffer.create 4096 in
274274+ (try
275275+ while true do
276276+ Buffer.add_channel buf stdin 4096
277277+ done
278278+ with End_of_file -> ());
279279+ Buffer.contents buf
280280+ | Some f -> Eio.Path.load Eio.Path.(fs / f)
281281+ in
282282+ match Bundle.decode data with
283283+ | Error e ->
284284+ Fmt.pr "✗ Failed to decode bundle: %a@." Bundle.pp_error e;
285285+ exit 1
286286+ | Ok bundle -> (
287287+ Fmt.pr "@[<v>Bundle:@, %a@]@." Bundle.pp bundle;
288288+ match Admin.extract bundle with
289289+ | Error e -> Log.warn (fun m -> m "Not an admin bundle: %s" e)
290290+ | Ok record -> Fmt.pr "@[<v>Admin record:@, %a@]@." Admin.pp record)
291291+292292+let input_file =
293293+ let doc = "Input file (default: stdin)." in
294294+ Arg.(value & opt (some string) None & info [ "i"; "input" ] ~docv:"FILE" ~doc)
295295+296296+let decode_cmd =
297297+ let doc = "Decode and display an admin bundle." in
298298+ let info = Cmd.info "decode" ~doc in
299299+ Cmd.v info Term.(const decode $ input_file $ Vlog.setup "borealis")
300300+301301+(* ============================================================================
302302+ Main
303303+ ============================================================================ *)
6330464305let info =
65306 let doc =
···72313 "Borealis is a Delay-Tolerant Networking daemon with an embedded \
73314 policy language for software-defined satellite networking.";
74315 `S Manpage.s_commands;
7575- `P "Use $(b,borealis run) to start the daemon.";
7676- `S Manpage.s_see_also;
7777- `P "$(b,borctl)(1) - Control CLI for borealis.";
316316+ `P "$(b,borealis run) - Start the daemon";
317317+ `P "$(b,borealis inject) - Inject a test bundle";
318318+ `P "$(b,borealis status) - Query node status";
319319+ `P "$(b,borealis contacts) - Query contact plan";
320320+ `P "$(b,borealis policy) - Manage routing policies";
321321+ `P "$(b,borealis bundles) - Manage stored bundles";
322322+ `P "$(b,borealis decode) - Decode admin bundles";
78323 ]
79324 in
80325 Cmd.info "borealis" ~version:"0.1.0" ~doc ~man
···82327let default = Term.(ret (const (fun () -> `Help (`Pager, None)) $ const ()))
8332884329let () =
8585- let cmd = Cmd.group info ~default [ run_cmd ] in
330330+ let cmd =
331331+ Cmd.group info ~default
332332+ [
333333+ run_cmd;
334334+ inject_cmd;
335335+ status_cmd;
336336+ contacts_cmd;
337337+ policy_cmd;
338338+ bundles_cmd;
339339+ decode_cmd;
340340+ ]
341341+ in
86342 exit (Cmd.eval cmd)