diff options
| author | Viacheslav Hletenko <v.gletenko@vyos.io> | 2026-04-21 18:01:27 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-21 18:01:27 +0300 |
| commit | 5297e6827bf70b581ade41824fa1f347f4beee9d (patch) | |
| tree | d0c424ba5f5ccd55dfc860a8018825202f4a8e2e | |
| parent | 9c8029128d2672f8dd388b31ac831d0497ce8595 (diff) | |
| parent | 42a3eb4751e11991a47aa1036bf60597f3937fd3 (diff) | |
| download | vyos-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-- | Makefile | 7 | ||||
| -rw-r--r-- | debian/control | 1 | ||||
| -rwxr-xr-x | debian/rules | 9 | ||||
| -rw-r--r-- | debian/vyos-1x.install | 2 | ||||
| -rw-r--r-- | src/ocaml/.gitignore | 1 | ||||
| -rw-r--r-- | src/ocaml/Makefile | 7 | ||||
| -rw-r--r-- | src/ocaml/completion/list_interfaces/func.ml | 1 | ||||
| -rw-r--r-- | src/ocaml/completion/list_interfaces/func.mli | 1 | ||||
| -rw-r--r-- | src/ocaml/completion/list_interfaces/iface.c | 38 | ||||
| -rw-r--r-- | src/ocaml/completion/list_interfaces/list_interfaces.ml | 143 | ||||
| -rw-r--r-- | src/ocaml/dune | 47 | ||||
| -rw-r--r-- | src/ocaml/dune-project | 2 | ||||
| -rw-r--r-- | src/ocaml/validate_value.ml | 112 | ||||
| -rw-r--r-- | src/ocaml/validators/file_path.ml | 55 | ||||
| -rw-r--r-- | src/ocaml/validators/numeric.ml | 246 | ||||
| -rw-r--r-- | src/ocaml/validators/url.ml | 149 | ||||
| -rw-r--r-- | src/ocaml/vyos-1x.opam | 21 | ||||
| -rw-r--r-- | src/ocaml/vyos_op_run.ml | 532 |
18 files changed, 1372 insertions, 2 deletions
@@ -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 + |
