summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorViacheslav Hletenko <v.gletenko@vyos.io>2026-04-21 18:01:27 +0300
committerGitHub <noreply@github.com>2026-04-21 18:01:27 +0300
commit5297e6827bf70b581ade41824fa1f347f4beee9d (patch)
treed0c424ba5f5ccd55dfc860a8018825202f4a8e2e
parent9c8029128d2672f8dd388b31ac831d0497ce8595 (diff)
parent42a3eb4751e11991a47aa1036bf60597f3937fd3 (diff)
downloadvyos-1x-5297e6827bf70b581ade41824fa1f347f4beee9d.tar.gz
vyos-1x-5297e6827bf70b581ade41824fa1f347f4beee9d.zip
Merge pull request #5136 from c-po/vyos-utils-merge
T8532: merge unrelated histories of vyos-utils and vyos-1x repo
-rw-r--r--Makefile7
-rw-r--r--debian/control1
-rwxr-xr-xdebian/rules9
-rw-r--r--debian/vyos-1x.install2
-rw-r--r--src/ocaml/.gitignore1
-rw-r--r--src/ocaml/Makefile7
-rw-r--r--src/ocaml/completion/list_interfaces/func.ml1
-rw-r--r--src/ocaml/completion/list_interfaces/func.mli1
-rw-r--r--src/ocaml/completion/list_interfaces/iface.c38
-rw-r--r--src/ocaml/completion/list_interfaces/list_interfaces.ml143
-rw-r--r--src/ocaml/dune47
-rw-r--r--src/ocaml/dune-project2
-rw-r--r--src/ocaml/validate_value.ml112
-rw-r--r--src/ocaml/validators/file_path.ml55
-rw-r--r--src/ocaml/validators/numeric.ml246
-rw-r--r--src/ocaml/validators/url.ml149
-rw-r--r--src/ocaml/vyos-1x.opam21
-rw-r--r--src/ocaml/vyos_op_run.ml532
18 files changed, 1372 insertions, 2 deletions
diff --git a/Makefile b/Makefile
index df89b555e..0d295fac6 100644
--- a/Makefile
+++ b/Makefile
@@ -77,8 +77,12 @@ op_mode_definitions: $(op_xml_obj)
vyshim:
$(MAKE) -C $(SHIM_DIR)
+.PHONY: ocaml
+ocaml: libvyosconfig
+ $(MAKE) -C src/ocaml
+
.PHONY: all
-all: clean copyright libvyosconfig pylint interface_definitions op_mode_definitions test j2lint vyshim generate-configd-include-json generate-activation-scripts-json
+all: clean copyright libvyosconfig pylint interface_definitions op_mode_definitions test j2lint vyshim generate-configd-include-json generate-activation-scripts-json ocaml
.PHONY: copyright
copyright:
@@ -93,6 +97,7 @@ clean:
rm -rf $(TMPL_DIR)
rm -rf $(OP_TMPL_DIR)
$(MAKE) -C $(SHIM_DIR) clean
+ $(MAKE) -C src/ocaml clean
.PHONY: test
test: generate-configd-include-json
diff --git a/debian/control b/debian/control
index a98d0bd34..ba059ae0b 100644
--- a/debian/control
+++ b/debian/control
@@ -72,7 +72,6 @@ Depends:
vyatta-biosdevname,
vyatta-cfg,
vyos-http-api-tools,
- vyos-utils,
## End of Fundamentals
## Python libraries used in multiple modules and scripts
python3,
diff --git a/debian/rules b/debian/rules
index 9649a6a36..332a32ecf 100755
--- a/debian/rules
+++ b/debian/rules
@@ -22,6 +22,7 @@ VYCONF_DEF := etc/vyos
VYCONF_REFTREE_DIR := $(VYOS_LIBEXEC_DIR)/vyconf/reftree
OCAML_DIR := /opt/opam/${OCAML_VERSION}
+VYOS_OCAML_TOOLS := src/ocaml/_build/install/default/bin
MIGRATION_SCRIPTS_DIR := opt/vyatta/etc/config-migrate/migrate
ACTIVATION_SCRIPTS_DIR := usr/libexec/vyos/activate
@@ -190,6 +191,14 @@ override_dh_auto_install:
cp $(OCAML_DIR)/bin/vyconf_cli_compat $(DIR_LIBVYOSCONFIG)/$(VYCONF_BIN)
cp libvyosconfig/_build/libvyosconfig.so $(DIR_LIBVYOSCONFIG)/$(VYOS_LIB_DIR)/libvyosconfig.so.0
+ # OCaml binaries from former vyos-utils package
+ cp $(VYOS_OCAML_TOOLS)/numeric $(DIR)/$(VYOS_LIBEXEC_DIR)/validators
+ cp $(VYOS_OCAML_TOOLS)/validate-value $(DIR)/$(VYOS_LIBEXEC_DIR)
+ cp $(VYOS_OCAML_TOOLS)/file-path $(DIR)/$(VYOS_LIBEXEC_DIR)/validators
+ cp $(VYOS_OCAML_TOOLS)/url $(DIR)/$(VYOS_LIBEXEC_DIR)/validators
+ cp $(VYOS_OCAML_TOOLS)/list_interfaces $(DIR)/$(VYOS_LIBEXEC_DIR)/completion
+ cp $(VYOS_OCAML_TOOLS)/vyos-op-run $(DIR)/$(VYOS_BIN_DIR)
+
override_dh_installsystemd:
dh_installsystemd -pvyos-1x --name vyos-router vyos-router.service
dh_installsystemd -pvyos-1x --name vyos vyos.target
diff --git a/debian/vyos-1x.install b/debian/vyos-1x.install
index 7732cc039..55369aebe 100644
--- a/debian/vyos-1x.install
+++ b/debian/vyos-1x.install
@@ -29,6 +29,7 @@ usr/bin/vyos-config-to-commands
usr/bin/vyos-config-to-json
usr/bin/vyos-commands-to-config
usr/bin/vyos-hostsd-client
+usr/bin/vyos-op-run
usr/lib
usr/libexec/vyos/activate
usr/libexec/vyos/completion
@@ -38,6 +39,7 @@ usr/libexec/vyos/op_mode
usr/libexec/vyos/services
usr/libexec/vyos/system
usr/libexec/vyos/validators
+usr/libexec/vyos/validate-value
usr/libexec/vyos/vyconf
usr/libexec/vyos/*.py
usr/libexec/vyos/*.sh
diff --git a/src/ocaml/.gitignore b/src/ocaml/.gitignore
new file mode 100644
index 000000000..69fa449dd
--- /dev/null
+++ b/src/ocaml/.gitignore
@@ -0,0 +1 @@
+_build/
diff --git a/src/ocaml/Makefile b/src/ocaml/Makefile
new file mode 100644
index 000000000..a1e42c914
--- /dev/null
+++ b/src/ocaml/Makefile
@@ -0,0 +1,7 @@
+all:
+ eval $$(opam env --root=/opt/opam --set-root); dune build
+
+clean:
+ eval $$(opam env --root=/opt/opam --set-root); dune clean
+
+.PHONY: all clean
diff --git a/src/ocaml/completion/list_interfaces/func.ml b/src/ocaml/completion/list_interfaces/func.ml
new file mode 100644
index 000000000..13e1860d9
--- /dev/null
+++ b/src/ocaml/completion/list_interfaces/func.ml
@@ -0,0 +1 @@
+external list_interfaces: unit -> string list = "interface_list"
diff --git a/src/ocaml/completion/list_interfaces/func.mli b/src/ocaml/completion/list_interfaces/func.mli
new file mode 100644
index 000000000..b16d37354
--- /dev/null
+++ b/src/ocaml/completion/list_interfaces/func.mli
@@ -0,0 +1 @@
+external list_interfaces : unit -> string list = "interface_list"
diff --git a/src/ocaml/completion/list_interfaces/iface.c b/src/ocaml/completion/list_interfaces/iface.c
new file mode 100644
index 000000000..bf2f0250e
--- /dev/null
+++ b/src/ocaml/completion/list_interfaces/iface.c
@@ -0,0 +1,38 @@
+/*
+ * Simple wrapper of getifaddrs for OCaml list of interfaces
+ */
+#include <ifaddrs.h>
+#include <caml/mlvalues.h>
+#include <caml/memory.h>
+#include <caml/alloc.h>
+
+CAMLprim value interface_list(value unit) {
+ struct ifaddrs *ifaddr;
+ struct ifaddrs *ifa;
+
+ CAMLparam1( unit );
+ CAMLlocal2( cli, cons );
+
+ cli = Val_emptylist;
+
+ if (getifaddrs(&ifaddr) == -1) {
+ CAMLreturn(cli);
+ }
+ for (ifa = ifaddr; ifa != NULL; ifa = ifa->ifa_next) {
+ if (ifa->ifa_name == NULL)
+ continue;
+
+ CAMLlocal1( ml_s );
+ cons = caml_alloc(2, 0);
+
+ ml_s = caml_copy_string(ifa->ifa_name);
+ Store_field( cons, 0, ml_s );
+ Store_field( cons, 1, cli );
+
+ cli = cons;
+ }
+
+ freeifaddrs(ifaddr);
+
+ CAMLreturn(cli);
+}
diff --git a/src/ocaml/completion/list_interfaces/list_interfaces.ml b/src/ocaml/completion/list_interfaces/list_interfaces.ml
new file mode 100644
index 000000000..b76afd4c1
--- /dev/null
+++ b/src/ocaml/completion/list_interfaces/list_interfaces.ml
@@ -0,0 +1,143 @@
+(*
+ *)
+let intf_types = ref []
+let broadcast = ref false
+let bridgeable = ref false
+let bondable = ref false
+let no_vlan = ref false
+
+let args = [
+ ("--type", Arg.String (fun s -> intf_types := (s :: !intf_types)), "List interfaces of specified type");
+ ("--broadcast", Arg.Unit (fun () -> broadcast := true), "List broadcast interfaces");
+ ("--bridgeable", Arg.Unit (fun () -> bridgeable := true), "List bridgeable interfaces");
+ ("--bondable", Arg.Unit (fun () -> bondable := true), "List bondable interfaces");
+ ("--no-vlan-subinterfaces", Arg.Unit (fun () -> no_vlan := true), "List only parent interfaces");
+]
+let usage = Printf.sprintf "Usage: %s [OPTIONS] <number>" Sys.argv.(0)
+
+let () = Arg.parse args (fun _ -> ()) usage
+
+let type_to_prefix it =
+ match it with
+ | "" -> ""
+ | "bonding" -> "bond"
+ | "bridge" -> "br"
+ | "dummy" -> "dum"
+ | "ethernet" -> "eth"
+ | "geneve" -> "gnv"
+ | "input" -> "ifb"
+ | "l2tpeth" -> "l2tpeth"
+ | "loopback" -> "lo"
+ | "macsec" -> "macsec"
+ | "openvpn" -> "vtun"
+ | "pppoe" -> "pppoe"
+ | "pseudo-ethernet" -> "peth"
+ | "sstpc" -> "sstpc"
+ | "tunnel" -> "tun"
+ | "virtual-ethernet" -> "veth"
+ | "vti" -> "vti"
+ | "vxlan" -> "vxlan"
+ | "wireguard" -> "wg"
+ | "wireless" -> "wlan"
+ | "wwan" -> "wwan"
+ | _ -> ""
+
+(* filter_section to match the constraint of python.vyos.ifconfig.section
+ *)
+let rx = Pcre2.regexp {|\d(\d|v|\.)*$|}
+
+let filter_section s =
+ let r = Pcre2.qreplace_first ~rex:rx ~templ:"" s in
+ match r with
+ |"bond"|"br"|"dum"|"eth"|"gnv"|"ifb"|"l2tpeth"|"lo"|"macsec" -> true
+ |"peth"|"pppoe"|"sstpc"|"tun"|"veth"|"vti"|"vtun"|"vxlan"|"wg"|"wlan"|"wwan" -> true
+ | _ -> false
+
+let filter_from_prefix p s =
+ let pattern = Printf.sprintf "^%s(.*)$" p
+ in
+ try
+ let _ = Pcre2.exec ~pat:pattern s in
+ true
+ with Not_found -> false
+
+let filter_from_type it =
+ let pre = type_to_prefix it in
+ match pre with
+ | "" -> None
+ | _ -> Some (filter_from_prefix pre)
+
+let filter_broadcast s =
+ let pattern = {|^(bond|br|tun|vtun|eth|gnv|peth|macsec|veth|vxlan|wwan|wlan)(.*)$|}
+ in
+ try
+ let _ = Pcre2.exec ~pat:pattern s in
+ true
+ with Not_found -> false
+
+let filter_bridgeable s =
+ let pattern = {|^(bond|eth|gnv|l2tpeth|lo|tun|veth|vtun|vxlan|wlan)(.*)$|}
+ in
+ try
+ let _ = Pcre2.exec ~pat:pattern s in
+ true
+ with Not_found -> false
+
+let filter_bondable s =
+ let pattern = {|^(eth|lan|eno|ens)[A-Za-z0-9_-]*$|}
+ in
+ try
+ let _ = Pcre2.exec ~pat:pattern s in
+ true
+ with Not_found -> false
+
+let filter_no_vlan s =
+ let pattern = {|^([^.]+)(\.\d+)+$|}
+ in
+ try
+ let _ = Pcre2.exec ~pat:pattern s in
+ false
+ with Not_found -> true
+
+let get_interfaces_by_type out intf_type =
+ let fltr =
+ if String.length(intf_type) > 0 then
+ filter_from_type intf_type
+ else None
+ in
+ let l = Func.list_interfaces () in
+ let res = List.sort_uniq compare l in
+ let res =
+ if !broadcast then List.filter filter_broadcast res
+ else res
+ in
+ let res =
+ if !bridgeable then List.filter filter_bridgeable res
+ else res
+ in
+ let res =
+ if !bondable then List.filter filter_bondable res
+ else res
+ in
+ let res =
+ if !no_vlan then List.filter filter_no_vlan res
+ else res
+ in
+ let add_out =
+ let res = List.filter filter_section res in
+ match fltr with
+ | Some f -> List.filter f res
+ | None -> res
+ in List.append out add_out
+
+let get_interfaces =
+ let types = List.rev !intf_types in
+ if types <> [] then
+ List.fold_left get_interfaces_by_type [] types
+ else
+ get_interfaces_by_type [] ""
+
+let () =
+ let res = get_interfaces in
+ List.iter (Printf.printf "%s ") res;
+ Printf.printf "\n"
diff --git a/src/ocaml/dune b/src/ocaml/dune
new file mode 100644
index 000000000..6dc9c8e7e
--- /dev/null
+++ b/src/ocaml/dune
@@ -0,0 +1,47 @@
+(include_subdirs unqualified)
+
+(executable
+ (name numeric)
+ (public_name numeric)
+ (modules numeric)
+ (libraries pcre2))
+
+(executable
+ (name url)
+ (public_name url)
+ (modules url)
+ (libraries pcre2))
+
+(executable
+ (name file_path)
+ (public_name file-path)
+ (modules file_path)
+ (libraries fileutils))
+
+(executable
+ (name validate_value)
+ (public_name validate-value)
+ (modules validate_value)
+ (libraries pcre2 unix containers))
+
+(executable
+ (name list_interfaces)
+ (public_name list_interfaces)
+ (modules func list_interfaces)
+ (libraries pcre2)
+ (foreign_stubs
+ (language c)
+ (names iface)))
+
+(executable
+ (name vyos_op_run)
+ (public_name vyos-op-run)
+ (modules vyos_op_run)
+ (libraries
+ logs
+ logs.fmt
+ fmt.tty
+ yojson
+ mustache
+ pcre2
+ unix))
diff --git a/src/ocaml/dune-project b/src/ocaml/dune-project
new file mode 100644
index 000000000..621644ea3
--- /dev/null
+++ b/src/ocaml/dune-project
@@ -0,0 +1,2 @@
+(lang dune 2.0)
+(name vyos-1x)
diff --git a/src/ocaml/validate_value.ml b/src/ocaml/validate_value.ml
new file mode 100644
index 000000000..ce4755f2c
--- /dev/null
+++ b/src/ocaml/validate_value.ml
@@ -0,0 +1,112 @@
+type argopt = RegexOpt of string | ExecOpt of string | GroupSeparator
+type check = Regex of string | Exec of string | Group of check list
+
+let options = ref []
+let checks = ref []
+let value = ref ""
+let silent = ref false
+
+let buf = Buffer.create 4096
+
+let rec validate_value buf value_constraint value =
+ match value_constraint with
+ | Group l ->
+ List.for_all (fun c -> validate_value buf c value) l
+ | Regex s ->
+ (try let _ = Pcre2.exec ~pat:(Printf.sprintf "^%s$" s) value in true
+ with Not_found -> false)
+ | Exec c ->
+ (* XXX: Unix.open_process_in is "shelling out", which is a bad idea on multiple levels,
+ especially when the input comes directly from the user...
+ We should do something about it.
+ *)
+ let cmd = Printf.sprintf "%s \'%s\' 2>&1" c value in
+ let chan = Unix.open_process_in cmd in
+ let out = try CCIO.read_all chan with _ -> "" in
+ let result = Unix.close_process_in chan in
+ match result with
+ | Unix.WEXITED 0 -> true
+ | Unix.WEXITED 127 ->
+ let () = Printf.printf "Could not execute validator %s" c in
+ false
+ | _ ->
+ let () = Buffer.add_string buf out; Buffer.add_string buf "\n" in
+ false
+
+let args = [
+ ("--regex", Arg.String (fun s -> options := (RegexOpt s) :: !options), "Check the value against a regex");
+ ("--exec", Arg.String (fun s -> options := (ExecOpt s) :: !options), "Check the value against an external command");
+ ("--grp", Arg.Unit (fun () -> options := (GroupSeparator) :: !options), "Group following arguments, combining results with logical and");
+ ("--silent", Arg.Unit (fun () -> silent := true), "Suppress individual validator output");
+ ("--value", Arg.String (fun s -> value := s), "Value to check");
+]
+let usage = Printf.sprintf "Usage: %s [OPTIONS] <number>" Sys.argv.(0)
+
+let () = Arg.parse args (fun _ -> ()) usage
+
+let find_next_group n l =
+ let rec aux i = function
+ | [] -> List.length l
+ | h::t ->
+ if i > n && h = GroupSeparator then i
+ else aux (i+1) t
+ in aux 0 l
+
+let get_next_range =
+ let n = ref (-1) in
+ let f () =
+ let i = !n and j = (n := find_next_group !n !options; !n) in
+ (i, j) in
+ f
+
+let option_to_check opt =
+ match opt with
+ | RegexOpt s -> Regex s
+ | ExecOpt s -> Exec s
+ | GroupSeparator -> raise (Invalid_argument "GroupSeparator in isolation has no corresponding check")
+
+let read_initial_options j =
+ if j > 0 then
+ let initial_options = List.filteri (fun i _ -> i < j) !options in
+ ignore (List.map (fun c -> checks := (option_to_check c) :: !checks) initial_options); ()
+ else ()
+
+let read_group_options i j =
+ if i < j then
+ let group_options = List.filteri (fun k _ -> i < k && k < j) !options in
+ let l = List.map (fun c -> option_to_check c) group_options in
+ checks := (Group l) :: !checks; ()
+ else ()
+
+let read_options () =
+ options := List.rev(!options);
+
+ let (_, j) = get_next_range () in
+ read_initial_options j;
+
+ let quit_loop = ref false in
+ while not !quit_loop do
+ let i, j = get_next_range () in
+ if i < j then
+ read_group_options i j
+ else
+ quit_loop := true
+ done
+
+let validate =
+ read_options ();
+ let value = !value in
+ let checks = !checks in
+ match checks with
+ | [] -> false
+ | _ ->
+ List.exists (fun c -> validate_value buf c value) checks
+
+let _ =
+ if validate then exit 0 else
+ (* If we got this far, value validation failed.
+ Show the user output from the validators.
+ *)
+ if not !silent then Buffer.contents buf |> print_endline
+ else ();
+ exit 1
diff --git a/src/ocaml/validators/file_path.ml b/src/ocaml/validators/file_path.ml
new file mode 100644
index 000000000..ea3068c1f
--- /dev/null
+++ b/src/ocaml/validators/file_path.ml
@@ -0,0 +1,55 @@
+type opts = {
+ must_be_file : bool;
+ parent : string option;
+ lookup_path : string option;
+ strict : bool;
+}
+
+let default_opts = {
+ must_be_file = true;
+ parent = None;
+ lookup_path = None;
+ strict = false
+}
+
+let opts = ref default_opts
+
+let path_arg = ref ""
+
+let args = [
+ ("--file", Arg.Unit (fun () -> opts := {!opts with must_be_file=true}), "Path must point to a file and not a directory (default)");
+ ("--directory", Arg.Unit (fun () -> opts := {!opts with must_be_file=false}), "Path must point to a directory");
+ ("--parent-dir", Arg.String (fun s -> opts := {!opts with parent=(Some s)}), "Path must be inside specific parent directory");
+ ("--lookup-path", Arg.String (fun s -> opts := {!opts with lookup_path=(Some s)}), "Prefix path argument with lookup path");
+ ("--strict", Arg.Unit (fun () -> opts := {!opts with strict=true}), "Treat warnings as errors");
+]
+let usage = Printf.sprintf "Usage: %s [OPTIONS] <path>" Sys.argv.(0)
+
+let () = if Array.length Sys.argv = 1 then (Arg.usage args usage; exit 1)
+let () = Arg.parse args (fun s -> path_arg := s) usage
+
+let fail msg =
+ let () = print_endline msg in
+ exit 1
+
+let () =
+ let opts = !opts in
+ let path =
+ match opts.lookup_path with
+ | None -> !path_arg
+ | Some lookup_path -> FilePath.concat lookup_path !path_arg
+ in
+ (* First, check if the file/dir path exists at all. *)
+ let exists = FileUtil.test FileUtil.Exists path in
+ if not exists then Printf.ksprintf fail {|Incorrect path %s: no such file or directory|} path else
+ (* If yes, check if it's of the correct type: file or directory. *)
+ let is_file = FileUtil.test FileUtil.Is_file path in
+ if ((not is_file) && opts.must_be_file) then Printf.ksprintf fail {|%s is a directory, not a file|} path else
+ if (is_file && (not opts.must_be_file)) then Printf.ksprintf fail {|%s is a file, not a directory|} path else
+ match opts.parent with
+ | None ->
+ exit 0
+ | Some parent ->
+ if not (FilePath.is_subdir (FilePath.reduce path) (FilePath.reduce parent)) then
+ let msg = Printf.sprintf {|Path %s is not under %s directory|} path parent in
+ if opts.strict then fail msg else Printf.printf "Warning: %s\n" msg
diff --git a/src/ocaml/validators/numeric.ml b/src/ocaml/validators/numeric.ml
new file mode 100644
index 000000000..8e4becb56
--- /dev/null
+++ b/src/ocaml/validators/numeric.ml
@@ -0,0 +1,246 @@
+type numeric_str = Number_string of string | Range_string of string
+type numeric_val = Number_float of float | Range_float of float * float
+
+type options = {
+ positive: bool;
+ nonnegative: bool;
+ allow_float: bool;
+ ranges: string list;
+ not_ranges: string list;
+ not_values: string list;
+ relative: bool;
+ allow_range: bool;
+ require_range: bool;
+ parse_hex: bool;
+ parse_oct: bool;
+ parse_bin: bool;
+ parse_dec: bool;
+}
+
+let default_opts = {
+ positive = false;
+ nonnegative = false;
+ allow_float = false;
+ ranges = [];
+ not_ranges = [];
+ not_values = [];
+ relative = false;
+ allow_range = false;
+ require_range = false;
+ parse_hex = false;
+ parse_oct = false;
+ parse_bin = false;
+ parse_dec = false;
+}
+
+let opts = ref default_opts
+
+let number_arg = ref ""
+
+let args = [
+ ("--non-negative", Arg.Unit (fun () -> opts := {!opts with nonnegative=true}), "Check if the number is non-negative (>= 0)");
+ ("--positive", Arg.Unit (fun () -> opts := {!opts with positive=true}), "Check if the number is positive (> 0)");
+ ("--range", Arg.String (fun s -> let optsv = !opts in opts := {optsv with ranges=(s :: optsv.ranges)}), "Check if the number or range is within a range (inclusive)");
+ ("--not-range", Arg.String (fun s -> let optsv = !opts in opts := {optsv with not_ranges=(s :: optsv.not_ranges)}), "Check if the number or range is not within a range (inclusive)");
+ ("--not-value", Arg.String (fun s -> let optsv = !opts in opts := {optsv with not_values=(s :: optsv.not_values)}), "Check if the number does not equal a specific value");
+ ("--float", Arg.Unit (fun () -> opts := {!opts with allow_float=true}), "Allow floating-point numbers");
+ ("--relative", Arg.Unit (fun () -> opts := {!opts with relative=true}), "Allow relative increment/decrement (+/-N)");
+ ("--allow-range", Arg.Unit (fun () -> opts := {!opts with allow_range=true}), "Allow the argument to be a range rather than a single number");
+ ("--require-range", Arg.Unit (fun () -> opts := {!opts with require_range=true; allow_range=true}), "Require the argument to be a range rather than a single number");
+ ("--hex", Arg.Unit (fun () -> opts := {!opts with parse_hex=true}), "Parse hexadecimal integers as valid numbers, complete with 0x-prefix");
+ ("--octal", Arg.Unit (fun () -> opts := {!opts with parse_oct=true}), "Parse octal integers as valid numbers, complete with 0o-prefix");
+ ("--binary", Arg.Unit (fun () -> opts := {!opts with parse_bin=true}), "Parse binary integers as valid numbers, complete with 0b-prefix");
+ ("--decimal", Arg.Unit (fun () -> opts := {!opts with parse_dec=true}), "Continue to parse decimal numbers even when other radixes are requested, no prefix required");
+ ("--", Arg.Rest (fun s -> number_arg := s), "Interpret next item as an argument");
+]
+let usage = Printf.sprintf "Usage: %s [OPTIONS] <number>|<range>" Sys.argv.(0)
+
+let () = if Array.length Sys.argv = 1 then (Arg.usage args usage; exit 1)
+let () = Arg.parse args (fun s -> number_arg := s) usage
+
+let check_nonnegative opts m =
+ if opts.nonnegative then
+ match m with
+ | Number_float n ->
+ if (n < 0.0) then
+ failwith "Number should be non-negative."
+ | Range_float _ ->
+ failwith "option '--non-negative' does not apply to a range value"
+
+let check_positive opts m =
+ if opts.positive then
+ match m with
+ | Number_float n ->
+ if (n <= 0.0) then
+ failwith "Number should be positive"
+ | Range_float _ ->
+ failwith "option '--positive does' not apply to a range value"
+
+let looks_like_decimal value =
+ try let _ = Pcre2.exec ~pat:"^(\\-?)[0-9]+(\\.[0-9]+)?$" value in true
+ with Not_found -> false
+
+let looks_like_hex value =
+ try let _ = Pcre2.exec ~pat:"^(\\-?)0[xX][0-9a-fA-F]+$" value in true
+ with Not_found -> false
+
+let looks_like_octal value =
+ try let _ = Pcre2.exec ~pat:"^(\\-?)0[oO][0-7]+$" value in true
+ with Not_found -> false
+
+let looks_like_binary value =
+ try let _ = Pcre2.exec ~pat:"^(\\-?)0[bB][0-1]+$" value in true
+ with Not_found -> false
+
+let is_relative value =
+ try let _ = Pcre2.exec ~pat:"^[+-](0[xboXBO])?[0-9a-fA-F]+$" value in true
+ with Not_found -> false
+
+let number_string_drop_modifier value =
+ String.sub value 1 (String.length value - 1)
+
+let get_relative opts t =
+ if opts.relative then
+ match t with
+ | Number_string s ->
+ if not (is_relative s) then
+ failwith "Value is not a relative increment/decrement"
+ else Number_string (number_string_drop_modifier s)
+ | Range_string _ ->
+ failwith "increment/decrement does not apply to a range value"
+ else t
+
+let number_of_string opts s =
+ if (opts.allow_float && not opts.parse_dec) then
+ failwith "Only decimal numbers may be floating point"
+ else if (opts.parse_hex && (looks_like_hex s)) ||
+ (opts.parse_oct && (looks_like_octal s)) ||
+ (opts.parse_bin && (looks_like_binary s)) then
+ (* float_of_string won't deal with octal or binary and hex-floats are just weird.
+ Easier to separate non-decimal parsing this way.
+ *)
+ let n = int_of_string_opt s in
+ match n with
+ | Some n ->
+ float_of_int n
+ | None ->
+ Printf.ksprintf failwith "'%s' is not a valid non-decimal number" s
+ else if (opts.parse_dec && (looks_like_decimal s)) then
+ let n = float_of_string_opt s in
+ match n with
+ | Some n ->
+ (* If floats are explicitly allowed, just return the number. *)
+ if opts.allow_float then n
+ (* If floats are not explicitly allowed, check if the argument has a decimal separator in it.
+ If the argument string contains a dot but float_of_string didn't dislike it,
+ it's a valid number but not an integer.
+ *)
+ else if not (String.contains s '.') then n
+ (* If float_of_string returned None, the argument string is just garbage rather than a number. *)
+ else Printf.ksprintf failwith "'%s' is not a valid integer number" s
+ | None ->
+ Printf.ksprintf failwith "'%s' is not a valid number" s
+ else Printf.ksprintf failwith "'%s' is not a valid number" s
+
+let range_of_string opts s =
+ let param_opts = { opts with parse_dec = true } in
+ let rs = String.split_on_char '-' s |> List.map String.trim |> List.map (number_of_string param_opts) in
+ match rs with
+ | [l; r] -> (l, r)
+ | exception (Failure msg) ->
+ (* Some of the numbers in the range are bad. *)
+ Printf.ksprintf failwith "'%s' is not a valid number range: %s" s msg
+ | _ ->
+ (* The range itself if malformed, like 1-10-20. *)
+ Printf.ksprintf failwith "'%s' is not a valid number range" s
+
+let value_in_ranges ranges n =
+ let in_range (l, r) n = (n >= l) && (n <= r) in
+ List.fold_left (fun acc r -> acc || (in_range r n)) false ranges
+
+let value_not_in_ranges ranges n =
+ let in_range (l, r) n = (n >= l) && (n <= r) in
+ List.fold_left (fun acc r -> acc && (not (in_range r n))) true ranges
+
+let check_ranges opts m =
+ if opts.ranges <> [] then
+ let ranges = List.map (range_of_string opts) opts.ranges in
+ match m with
+ | Number_float n ->
+ if not (value_in_ranges ranges n) then
+ Printf.ksprintf failwith "Number is not in any of allowed ranges"
+ | Range_float (i, j) ->
+ if (not (value_in_ranges ranges i) ||
+ not (value_in_ranges ranges j)) then
+ Printf.ksprintf failwith "Range is not in any of allowed ranges"
+
+
+let check_not_ranges opts m =
+ if opts.not_ranges <> [] then
+ let ranges = List.map (range_of_string opts) opts.not_ranges in
+ match m with
+ | Number_float n ->
+ if not (value_not_in_ranges ranges n) then
+ Printf.ksprintf failwith "Number is in one of excluded ranges"
+ | Range_float (i, j) ->
+ if (not (value_not_in_ranges ranges i) ||
+ not (value_not_in_ranges ranges j)) then
+ Printf.ksprintf failwith "Range is in one of excluded ranges"
+
+let check_not_values opts m =
+ let param_opts = { opts with parse_dec = true } in
+ let excluded_values = List.map (number_of_string param_opts) opts.not_values in
+ if excluded_values = [] then () else
+ match m with
+ | Range_float _ -> Printf.ksprintf failwith "--not-value cannot be used with ranges"
+ | Number_float num ->
+ begin
+ let res = List.find_opt ((=) num) excluded_values in
+ match res with
+ | None -> ()
+ | Some _ -> Printf.ksprintf failwith "Value is excluded by --not-value"
+ end
+
+let check_argument_type opts m =
+ match m with
+ | Number_float _ ->
+ if opts.require_range then Printf.ksprintf failwith "Value must be a range, not a number"
+ else ()
+ | Range_float _ ->
+ if opts.allow_range then ()
+ else Printf.ksprintf failwith "Value must be a number, not a range"
+
+let is_range_val s =
+ try let _ = Pcre2.exec ~pat:"^(0[xboXBO])?[0-9a-fA-F]+-(0[xboXBO])?[0-9a-fA-F]+$" s in true
+ with Not_found -> false
+
+let var_numeric_str s =
+ match is_range_val s with
+ | true -> Range_string s
+ | false -> Number_string s
+
+let check_default_radix opts =
+ if (not opts.parse_hex && not opts.parse_oct && not opts.parse_bin) then
+ {opts with parse_dec=true}
+ else opts
+
+let () = try
+ let s = var_numeric_str !number_arg in
+ let opts = check_default_radix !opts in
+ let s = get_relative opts s in
+ let n =
+ match s with
+ | Number_string r -> Number_float (number_of_string opts r)
+ | Range_string r -> let i, j = range_of_string opts r in
+ Range_float (i, j)
+ in
+ check_argument_type opts n;
+ check_nonnegative opts n;
+ check_positive opts n;
+ check_not_values opts n;
+ check_ranges opts n;
+ check_not_ranges opts n
+with (Failure err) ->
+ print_endline err;
+ exit 1
+
diff --git a/src/ocaml/validators/url.ml b/src/ocaml/validators/url.ml
new file mode 100644
index 000000000..7ba5dcf6c
--- /dev/null
+++ b/src/ocaml/validators/url.ml
@@ -0,0 +1,149 @@
+(* Extract and validate the scheme part.
+ As per the RFC:
+
+ Scheme names consist of a sequence of characters. The lower case
+ letters "a"--"z", digits, and the characters plus ("+"), period
+ ("."), and hyphen ("-") are allowed. For resiliency, programs
+ interpreting URLs should treat upper case letters as equivalent to
+ lower case in scheme names (e.g., allow "HTTP" as well as "http").
+ *)
+let split_scheme url =
+ let aux url =
+ let res = Pcre2.exec ~pat:{|^([a-zA-Z0-9\.\-]+):(.*)$|} url in
+ let scheme = Pcre2.get_substring res 1 in
+ let uri = Pcre2.get_substring res 2 in
+ (String.lowercase_ascii scheme, uri)
+ in
+ try Ok (aux url)
+ with Not_found -> Error (Printf.sprintf {|"%s" is not a valid URL|} url)
+
+let is_scheme_allowed allowed_schemes scheme =
+ match List.find_opt ((=) scheme) allowed_schemes with
+ | Some _ -> Ok ()
+ | None -> Error (Printf.sprintf {|URL scheme "%s:" is not allowed|} scheme)
+
+let regex_matches regex s =
+ try
+ let _ = Pcre2.exec ~rex:regex s in
+ true
+ with Not_found -> false
+
+let host_path_format =
+ Pcre2.regexp
+ {|^//(?:[^/?#]+(?::[^/?#]*)?@)?([a-zA-Z0-9\-\._~]+|\[[a-zA-Z0-9:\.]+\])(?::([0-9]+))?(/.*)?$|}
+
+let host_name_format = Pcre2.regexp {|^[a-zA-Z0-9]+([\-\._~]{1}[a-zA-Z0-9]+)*$|}
+let ipv4_addr_format = Pcre2.regexp {|^(([1-9]\d{0,2}|0)\.){3}([1-9]\d{0,2}|0)$|}
+let ipv6_addr_format = Pcre2.regexp {|^\[([a-z0-9:\.]+|[A-Z0-9:\.]+)\]$|}
+
+let is_port s =
+ try
+ let n = int_of_string s in
+ if n > 0 && n < 65536 then true
+ else false
+ with Failure _ -> false
+
+let is_ipv4_octet s =
+ try
+ let n = int_of_string s in
+ if n >= 0 && n < 256 then true
+ else false
+ with Failure _ -> false
+
+let is_ipv6_segment s =
+ try
+ let n = int_of_string ("0x" ^ s) in
+ if n >= 0 && n < 65536 then true
+ else false
+ with Failure _ -> false
+
+let is_ipv4_addr s =
+ let res = Pcre2.exec ~rex:ipv4_addr_format s in
+ let ipv4_addr_str = Pcre2.get_substring res 0 in
+ let ipv4_addr_l = String.split_on_char '.' ipv4_addr_str in
+ List.for_all is_ipv4_octet ipv4_addr_l
+
+let is_ipv6_pure_addr s =
+ let ipv6_addr_l = String.split_on_char ':' s in
+ if List.length ipv6_addr_l > 8 || List.length ipv6_addr_l < 3 then false
+ else
+ let seg_str_l = List.filter (fun s -> String.length s > 0) ipv6_addr_l in
+ List.for_all is_ipv6_segment seg_str_l
+
+let is_ipv6_dual_addr s =
+ let ipv6_addr_l = List.rev (String.split_on_char ':' s) in
+ match ipv6_addr_l with
+ | [] -> false
+ | h::t ->
+ if not (is_ipv4_addr h) then false
+ else
+ if List.length t > 6 || List.length t < 2 then false
+ else
+ let seg_str_l = List.filter (fun s -> String.length s > 0) t in
+ List.for_all is_ipv6_segment seg_str_l
+
+let is_ipv6_addr s =
+ let res = Pcre2.exec ~rex:ipv6_addr_format s in
+ let ipv6_addr_str = Pcre2.get_substring res 1 in
+ try
+ let typo = Pcre2.exec ~pat:{|:::|} ipv6_addr_str in
+ match typo with
+ | _ -> false
+ with Not_found ->
+ is_ipv6_pure_addr ipv6_addr_str || is_ipv6_dual_addr ipv6_addr_str
+
+let host_path_matches s =
+ try
+ let res = Pcre2.exec ~rex:host_path_format s in
+ let substr = Pcre2.get_substrings ~full_match:false res in
+ let port_str = Array.get substr 1 in
+ if String.length port_str > 0 && not (is_port port_str) then false
+ else
+ let host = Array.get substr 0 in
+ match host with
+ | host when regex_matches ipv6_addr_format host -> is_ipv6_addr host
+ | host when regex_matches ipv4_addr_format host -> is_ipv4_addr host
+ | host when regex_matches host_name_format host -> true
+ | _ -> false
+ with Not_found -> false
+
+let validate_uri scheme uri =
+ if host_path_matches uri then Ok ()
+ else Error (Printf.sprintf {|"%s" is not a valid URI for the %s URL scheme|} uri scheme)
+
+let validate_url allowed_schemes url =
+ let (let*) = Result.bind in
+ let* scheme, uri = split_scheme url in
+ let* () = is_scheme_allowed allowed_schemes scheme in
+ let* () = validate_uri scheme uri in
+ Ok ()
+
+let file_transport_schemes = ["http"; "https"; "ftp"; "sftp"; "scp"; "tftp"]
+
+let message_schemes = ["mailto"; "tel"; "sms"]
+
+let allowed_schemes = ref []
+let url = ref ""
+
+let args = [
+ ("--scheme",
+ Arg.String (fun s -> allowed_schemes := s :: !allowed_schemes),
+ "Allow only specified schemes");
+ ("--file-transport",
+ Arg.Unit (fun () -> allowed_schemes := (List.append !allowed_schemes file_transport_schemes)),
+ "Allow only file transport protocols (HTTP/S, FTP, SCP/SFTP, TFTP)");
+ ("--", Arg.Rest (fun s -> url := s), "Interpret next item as an argument");
+]
+
+let usage = Printf.sprintf "Usage: %s [OPTIONS] <URL>" Sys.argv.(0)
+
+let () =
+ let () = Arg.parse args (fun s -> url := s) usage in
+ (* Force all allowed scheme named to lowercase for ease of comparison. *)
+ let allowed_schemes = List.map String.lowercase_ascii !allowed_schemes in
+ let res = validate_url allowed_schemes !url in
+ match res with
+ | Ok () -> ()
+ | Error msg ->
+ let () = Printf.fprintf stdout "%s" msg in
+ exit 1
diff --git a/src/ocaml/vyos-1x.opam b/src/ocaml/vyos-1x.opam
new file mode 100644
index 000000000..9da287652
--- /dev/null
+++ b/src/ocaml/vyos-1x.opam
@@ -0,0 +1,21 @@
+opam-version: "2.0"
+name: "vyos-1x"
+version: "0.0.4"
+synopsis: "VyOS utils"
+description: """
+A collection of validators and completion helpers.
+"""
+maintainer: "Daniil Baturin <daniil@baturin.org>"
+authors: "VyOS maintainers and contributors <maintainers@vyos.net>"
+license: "MIT"
+homepage: "https://github.com/vyos/vyos-1x"
+bug-reports: "https://vyos.dev"
+dev-repo: "git+https://github.com/vyos/vyos-1x/"
+build: [
+ ["dune" "subst"] {pinned}
+ ["dune" "build" "-p" name]
+]
+depends: [
+ "ocamlfind" {build}
+ "dune" {build & >= "2.0"}
+]
diff --git a/src/ocaml/vyos_op_run.ml b/src/ocaml/vyos_op_run.ml
new file mode 100644
index 000000000..aa88d42fd
--- /dev/null
+++ b/src/ocaml/vyos_op_run.ml
@@ -0,0 +1,532 @@
+(*
+ * vyos-op-run: the wrapper for executing operational mode commands.
+ *
+ * Copyright VyOS maintainers and contributors <maintainers@vyos.io>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 or later as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *)
+
+(* Global constants *)
+let op_def_file = "/usr/share/vyos/op_cache.json"
+let permissions_file = "/etc/vyos/operators.json"
+let vyos_admin_group_name = "vyattacfg"
+
+(* List of commands that operators are unconditionally denied to execute. *)
+let admin_only_commands = [
+ (* Configuration mode operations *)
+ ["configure"];
+ ["commit"];
+ ["commit-confirm"];
+ ["confirm"];
+ (* XXX: executing a shell through a wrapper that does setuid 0
+ provides a ready shell escape and defeats the purpose.
+ We cannot allow operator-level users to execute shells
+ in VRFs and network namespaces
+ at least until we find a way to drop privileges
+ after attaching to the VRF/netns but before executing the commands.
+ *)
+ ["execute"; "shell"];
+]
+
+(* Execution options *)
+type options = {
+ (* The option not to actually run the command, just print it *)
+ dry_run: bool;
+
+ (* Enable debug output *)
+ debug: bool;
+
+ (* The original VyOS command,
+ like "show interfaces ethernet",
+ for debugging and for substitutions of $@/$*
+ *)
+ vyos_command: string;
+}
+
+let default_options = {
+ dry_run = false;
+ debug = false;
+ vyos_command = "<VyOS command is undefined>";
+}
+
+(* Exceptions and helpers *)
+exception Invalid_command of string
+let invalid_command msg = raise (Invalid_command msg)
+
+exception Internal_error of string
+let internal_error msg = raise (Internal_error msg)
+
+exception Command_error of string
+let command_error msg = raise (Command_error msg)
+
+exception Permission_error
+let permission_error () = raise Permission_error
+
+exception Incomplete_command
+
+(* Logging setup routines *)
+let get_color_style () =
+ let no_color = Sys.getenv_opt "NO_COLOR" |> Option.is_some in
+ (* Logs always go to stderr, so we don't check if stdout is a TTY. *)
+ let interactive = Unix.isatty (Unix.descr_of_out_channel stderr) in
+ if interactive && (not no_color) then `Ansi_tty else `None
+
+let setup_logging debug =
+ let level =
+ if debug then Logs.Debug
+ else Logs.Warning
+ in
+ let style = get_color_style () in
+ Logs.set_level (Some level);
+ Fmt_tty.setup_std_outputs ~style_renderer:style ();
+ Logs.set_reporter @@ Logs.format_reporter ();
+ (* Enable exception tracing if debug=true,
+ by default it's disabled in the OCaml runtime *)
+ if debug then Printexc.record_backtrace true
+
+(* JSON data helpers *)
+let get_string_field name obj =
+ let open Yojson.Safe.Util in
+ member name obj |> to_string
+
+let read_command_definitions () =
+ let () = Logs.debug @@ fun m -> m "Reading command definitions from %s" op_def_file in
+ let ic = open_in op_def_file in
+ let data = Yojson.Safe.from_channel ic in
+ let () = close_in ic in
+ data
+
+let read_permissions () =
+ let () = Logs.debug @@ fun m -> m "Reading user permissions from %s" permissions_file in
+ let ic = open_in permissions_file in
+ let data = Yojson.Safe.from_channel ic in
+ let () = close_in ic in
+ data
+
+let find_child_node op_node word =
+ let open Yojson.Safe.Util in
+ let res = member word op_node in
+ match res with
+ | (`Assoc _) as d -> Some d
+ | `Null -> None
+ | _ ->
+ Printf.ksprintf internal_error {|Child node "%s" is not an object!|} word
+
+let get_node_data op_node =
+ let open Yojson.Safe.Util in
+ let res = member "__node_data" op_node in
+ match res with
+ | (`Assoc _) as d -> d
+ | `Null ->
+ Printf.ksprintf internal_error "Op node has no data!\n"
+ | _ ->
+ Printf.ksprintf internal_error "Op node data is not an object!"
+
+let get_path node_data =
+ let open Yojson.Safe.Util in
+ member "path" node_data |> convert_each to_string
+
+let get_node_type node_data =
+ let open Yojson.Safe.Util in
+ let res = member "node_type" node_data in
+ match res with
+ | `String _type -> _type
+ | `Null ->
+ Printf.ksprintf internal_error "Op node has no type!"
+ | _ ->
+ Printf.ksprintf internal_error "Op node data is not a string!"
+
+let get_command_opt ?(field_name="command") node_data =
+ let open Yojson.Safe.Util in
+ let res = member field_name node_data in
+ match res with
+ | `String cmd -> Some cmd
+ | `Null -> None
+ | _ -> Printf.ksprintf internal_error "command must be a string"
+
+let get_command ?(field_name="command") node_data =
+ let res = get_command_opt ~field_name:field_name node_data in
+ match res with
+ | Some cmd -> cmd
+ | None -> Printf.ksprintf internal_error "node is expected to have a command"
+
+let get_virtual_tag_node node =
+ let open Yojson.Safe.Util in
+ let res = member "__virtual_tag" node in
+ match res with
+ | `Null -> None
+ | _ -> Some res
+
+(* Command permission checks *)
+
+let rec permission_matches perm cmd =
+ match perm, cmd with
+ | [], _ ->
+ (* If all terms of the permission matched
+ all words of the command, the command is allowed --
+ we follow the implicit approach
+ "every permission includes all sub-commands"
+ *)
+ true
+ | _, [] ->
+ (* If the command is shorter than the permission spec,
+ it means the permission is more specific.
+ E.g., 'show interfaces ethernet' permission
+ should reject attempts to run 'show interfaces',
+ since its intent is to allow access only to Ethernet. *)
+ false
+ | (p :: ps), (c :: cs) ->
+ (* Permission term can be either a command word
+ or a special token '*' that matches any command. *)
+ if (p = c) || (p = "*") then permission_matches ps cs
+ else false
+
+let group_perms_match perms group cmd =
+ let get_group_perms perms g =
+ let perms = Yojson.Safe.Util.path
+ ["groups"; g; "command_policy"; "allow"] perms
+ in
+ match perms with
+ | Some v ->
+ (try
+ v |>
+ Yojson.Safe.Util.to_list |>
+ List.map (fun j -> Yojson.Safe.Util.to_list j |> List.map Yojson.Safe.Util.to_string)
+ with _ ->
+ Printf.ksprintf internal_error
+ "Command policy for group %s is not a list of string lists" g)
+ | None -> Printf.ksprintf internal_error
+ "Configuration does not define command policy for group %s" g
+ in
+ let rec perm_list_matches ps cmd =
+ match ps with
+ | [] -> false
+ | p :: ps ->
+ if permission_matches p cmd then true
+ else perm_list_matches ps cmd
+ in
+ let group_perms = get_group_perms perms group in
+ perm_list_matches group_perms cmd
+
+let is_admin () =
+ (* If executed by root, skip all permission checks *)
+ if Unix.getuid () = 0 then
+ let () = Logs.debug @@ fun m -> m "The user is root, permission checks will be skipped" in
+ true
+ else begin
+ (* Otherwise, check if the user is a VyOS admin *)
+ let admin_group = Unix.getgrnam vyos_admin_group_name in
+ let user_groups = Unix.getgroups () in
+ match (Array.find_opt ((=) admin_group.gr_gid) user_groups) with
+ | Some _ ->
+ let () = Logs.debug @@ fun m -> m "The user is a VyOS admin, permission checks will be skipped" in
+ true
+ | None ->
+ let () = Logs.debug @@ fun m -> m "The user does not have VyOS admin permissions" in
+ false
+ end
+
+let has_unsafe_characters cmd =
+ (* XXX: this function is highly restrictive now,
+ until we are completely certain that shell escape
+ cannot happen down the line inside VyOS op mode scripts.
+ Alphanumeric characters, hyphens, dots, and whitespace
+ should allow operator users to use most commands
+ that take interface names, FQDNs, and config entities
+ like IPsec peer names.
+ Notable exceptions are:
+ - 'show bgp regexp': regexes naturally require '$' and other
+ patently shell-unsafe characters.
+ - 'monitor traffic interface eth0 filter':
+ PCAP filters use '!', '&&' and '||',
+ although people can use 'and', 'or', 'not'
+ to get around the restriction.
+ - 'add system image': requires non-alphanumeric characters
+ for URLs.
+ *)
+ let () = Logs.debug @@ fun m -> m "Checking the command for unsafe characters" in
+ try
+ let _ = Pcre2.exec ~pat:{|[^a-zA-Z0-9_\-\.\s]|} cmd in
+ let () =
+ Printf.fprintf stderr "Command [%s] contains special characters \
+ that operator-level users are not allowed to use\n" cmd
+ in
+ true
+ with Not_found -> false
+
+let is_admin_only_command cmd =
+ let rec prefix_matches prefix target =
+ match prefix, target with
+ | [], _ ->
+ (* The target matched every word of the prefix,
+ so it's a match.
+ *)
+ true
+ | _, [] ->
+ (* The target is shorter than the prefix,
+ so it's not a match.
+ *)
+ false
+ | (p :: ps), (t :: ts) ->
+ if p = t then prefix_matches ps ts
+ else false
+ in
+ let () = Logs.debug @@ fun m -> m "Checking if the command is admin-only" in
+ let res = List.find_opt (fun p -> prefix_matches p cmd) admin_only_commands in
+ match res with
+ | None -> false
+ | Some _ ->
+ let () = Logs.debug @@ fun m -> m "Commandis reserved for admins" in
+ true
+
+let check_command_permissions perms cmd =
+ let rec aux perms groups cmd =
+ match groups with
+ | [] -> permission_error ()
+ | g :: gs ->
+ if group_perms_match perms g cmd then ()
+ else aux perms gs cmd
+ in
+ let () = Logs.debug @@ fun m -> m "Checking if the user is allowed to execute the command" in
+ (* VyOS admins can execute any commands without restrictions *)
+ if is_admin () then () else
+ (* Operators are not allowed to execute commands
+ with potentially unsafe characters in them *)
+ if has_unsafe_characters (String.concat " " cmd) then permission_error () else
+ (* Some commands are unconditionally denied to operators *)
+ if is_admin_only_command cmd then permission_error () else
+ (* Operator level users must always be in groups
+ with defined command policies
+ *)
+ let username = Unix.getlogin () in
+ let groups = Yojson.Safe.Util.path ["users"; username] perms in
+ match groups with
+ | None | Some (`List []) ->
+ Printf.ksprintf internal_error "User %s is not assigned to any operator group" username
+ | Some gs ->
+ let group_list =
+ (try
+ gs |>
+ Yojson.Safe.Util.to_list |>
+ List.map Yojson.Safe.Util.to_string
+ with _ ->
+ Printf.ksprintf internal_error "The groups field for user %s is not a list of strings"
+ username)
+ in
+ aux perms group_list cmd
+
+(* Command rendering and execution *)
+let render_command opts env command_tmpl =
+ let () = Logs.debug @@ fun m -> m "Command template: %s" command_tmpl in
+ let command_tmpl = (Mustache.of_string command_tmpl) in
+ let command = Mustache.render command_tmpl (`O env) in
+ let vyos_command = opts.vyos_command in
+ Pcre2.replace ~pat:{|\$[@*]|} ~templ:vyos_command command
+
+let run_external_command opts env command_tmpl =
+ let cmd = render_command opts env command_tmpl in
+ if opts.dry_run then Printf.printf "%s\n%!" cmd else
+ (* Get the user database entry to populate the basic environment from:
+ we cannot trust an unprivileged user to supply $SHELL
+ or allow them to impersonate someone else by setting custom $LOGNAME, etc.
+ *)
+ let user_pw_entry = Unix.getpwuid @@ Unix.getuid () in
+ let make_var name value = Printf.sprintf "%s=%s" name value in
+ let env = [|
+ (* A knowingly safe executable lookup path.
+ Since we do not use /usr/local, we do not need to include that.
+ Executables in VyOS-specific directories are referred to by absolute paths
+ in the operational command JSON cache,
+ so we don't need to include those, either.
+ *)
+ make_var "PATH" "/usr/sbin:/usr/bin:/sbin:/bin";
+ (* Standard UNIX variables *)
+ make_var "HOME" user_pw_entry.pw_dir;
+ make_var "USER" user_pw_entry.pw_name;
+ make_var "LOGNAME" user_pw_entry.pw_name;
+ make_var "SHELL" user_pw_entry.pw_shell;
+ (* VyOS-specific variables *)
+ make_var "vyos_data_dir" "/usr/share/vyos";
+ make_var "vyos_validators_dir" "/usr/libexec/vyos/validators";
+ make_var "vyos_completion_dir" "/usr/libexec/vyos/completion";
+ make_var "vyos_libexec_dir" "/usr/libexec/vyos";
+ make_var "vyos_op_scripts_dir" "/usr/libexec/vyos/op_mode";
+ |]
+ in
+ let shell = "/bin/sh" in
+ (* We use execve with an absolute path to Bourne shell rather than execvpe
+ so that a user trying to do PATH=/bad/place vyos-op-run
+ cannot achieve anything with that trick.
+ *)
+ let () = Logs.debug @@ fun m -> m "Executing Unix command: %s" cmd in
+ let res = Unix.execve shell [|shell; "-c"; cmd|] env in
+ match res with
+ | Unix.WEXITED 0 -> ()
+ | _ ->
+ (* Many op mode commands return non-zero exit codes on benign errors
+ such as an unconfigured subsystem,
+ so we shouldn't show this to the user by default.
+ *)
+ Logs.debug @@ fun m -> m "Execution of command '%s' failed" cmd
+
+(* Command lookup *)
+let rec run_vyos_command opts ?(env=[]) ?(parent="") node cmd_words =
+ match cmd_words with
+ | w :: ws ->
+ let () = Logs.debug @@ fun m -> m "Looking up node '%s'" w in
+ let res = find_child_node node w in
+ begin match res with
+ | Some child_node ->
+ (* It's a normal, fixed command word *)
+ run_vyos_command opts ~env:env ~parent:w child_node ws
+ | None ->
+ (* It's either an argument of a tag node
+ or an incorrect command word *)
+ let node_data = get_node_data node in
+ let node_type = get_string_field "node_type" node_data in
+ let virtual_tag_node = get_virtual_tag_node node in
+ match node_type, virtual_tag_node with
+ | "tagNode", None ->
+ (* It's a simple tag node *)
+ let env = (Printf.sprintf "%s-tag_value" parent, `String w) :: env in
+ begin match ws with
+ | [] ->
+ let command = get_command node_data in
+ run_external_command opts env command
+ | _ as ws ->
+ run_vyos_command opts ~env:env ~parent:w node ws
+ end
+ | "node", Some vtn ->
+ (* It's a command that can be used either by itself or with an argument. *)
+ let env = (Printf.sprintf "%s-tag_value" parent, `String w) :: env in
+ begin match ws with
+ | [] ->
+ let vtn_data = get_node_data vtn in
+ let command = get_command vtn_data in
+ run_external_command opts env command
+ | _ ->
+ (* In the case of a virtual tag node, we take the parent (for variable substitution purposes)
+ from the upper level.
+ *)
+ run_vyos_command opts ~env:env ~parent:parent vtn ws
+ end
+ | "node", None | "leafNode", None ->
+ let path = get_path node_data in
+ Printf.ksprintf invalid_command {|"%s" is not a valid argument for command [%s]|}
+ w (String.concat " " path)
+ | _, _ ->
+ Printf.ksprintf internal_error
+ {|Node with type "%s" must not have a <virtualTagNode> child|}
+ node_type
+ end
+ | _ ->
+ let node_data = get_node_data node in
+ let node_type = get_node_type node_data in
+ let command =
+ begin match node_type with
+ | "node" | "leafNode" ->
+ get_command_opt node_data
+ | "tagNode" ->
+ (* If it's a tag node but there's no argument,
+ we need to check if that tag node has standalone behavior attached to it.
+ *)
+ get_command_opt ~field_name:"standalone_command" node_data
+ | "virtualTagNode" ->
+ None
+ | _ -> Printf.ksprintf internal_error {|Invalid node type "%s"|} node_type
+ end
+ in
+ begin match command with
+ | Some command ->
+ run_external_command opts env command
+ | None ->
+ raise Incomplete_command
+ end
+
+(* Command line argument parsing *)
+let usage_msg = Printf.sprintf {|Usage: %s [OPTIONS] <command>
+
+%s is the VyOS operational command wrapper.
+It is used by the CLI and can be used
+for running operational commands from scripts.
+
+Options:
+|} Sys.argv.(0) Sys.argv.(0)
+
+let get_args () =
+ let opts = ref default_options in
+ let args = ref [] in
+ let add_positional_arg arg =
+ args := arg :: !args
+ in
+ let arg_spec = Arg.align [
+ ("--dry-run",
+ Arg.Unit (fun () -> opts := {!opts with dry_run=true}),
+ "Show the command instead of executing it");
+ ("--debug",
+ Arg.Unit (fun () -> opts := {!opts with debug=true}),
+ "Enable debug output");
+ ]
+ in
+ let () = Arg.parse arg_spec add_positional_arg usage_msg in
+ let args = List.rev !args in
+ ({!opts with vyos_command=(String.concat " " args)}, args)
+
+let () =
+ let debug =
+ (* For simplicity, we check for the existence
+ of the VYOS_DEBUG environment variable,
+ rather than for specific values.
+ *)
+ match Unix.getenv "VYOS_DEBUG" with
+ | _ -> true
+ | exception Not_found -> false
+ in
+ let options, args = get_args () in
+ (* If debug is not enabled by the environment variable,
+ take it from command line options --
+ it may be enabled there.
+ *)
+ let () = if debug then print_endline "Debug is enabled by the env var" in
+ let debug = if debug then true else options.debug in
+ let () = setup_logging debug in
+ let op_defs = read_command_definitions () in
+ let permissions = read_permissions () in
+ let () = Logs.debug @@ fun m -> m "Executing VyOS command [%s]" options.vyos_command in
+ try
+ check_command_permissions permissions args;
+ Unix.setuid 0;
+ run_vyos_command options ~env:[] ~parent:"" op_defs args
+ with
+ | Permission_error ->
+ Printf.fprintf stderr "You do not have a permission to execute VyOS command [%s]\n"
+ options.vyos_command;
+ exit 1
+ | Invalid_command msg ->
+ Printf.fprintf stderr "Invalid command [%s]: %s\n" options.vyos_command msg;
+ exit 1
+ | Command_error msg ->
+ Printf.fprintf stderr "%s\n" msg;
+ | Incomplete_command ->
+ Printf.fprintf stderr "Incomplete command: %s\n" options.vyos_command;
+ exit 2
+ | Sys_error msg ->
+ Printf.fprintf stderr "System error: %s" msg;
+ exit 255
+ | Unix.Unix_error (err, func, _) ->
+ Printf.fprintf stderr "Failed to execute Unix call %s: %s" func (Unix.error_message err);
+ exit 255
+ | Internal_error msg ->
+ Printf.fprintf stderr "Internal error: %s\n" msg;
+ exit 255
+