2018-05-16 11:23:32 +09:00

2561 lines
88 KiB
Erlang

%%% @doc
%%% ZX
%%%
%%% A general dependency and packaging tool that works together with the zomp
%%% package manager. Given a project directory with a standard layout, zx can:
%%% - Initialize your project for packaging and semver tracking under zomp.
%%% - Add dependencies (recursively) defined in any zomp repository realm.
%%% - Update dependencies (recursively) defined in any zomp repository realm.
%%% - Remove dependencies.
%%% - Update, upgrade or run any application from source that zomp tracks.
%%% - Locally install packages from files and locally stored public keys.
%%% - Build and run a local project from source using zomp dependencies.
%%% @end
-module(zx).
-behavior(application).
-author("Craig Everett <zxq9@zxq9.com>").
-copyright("Craig Everett <zxq9@zxq9.com>").
-license("GPL-3.0").
-export([do/1]).
-export([subscribe/1, unsubscribe/0]).
-export([start/0, stop/0]).
-export([start/2, stop/1]).
-export_type([serial/0, package_id/0, package/0, realm/0, name/0, version/0,
identifier/0,
option/0,
host/0,
key_id/0, key_name/0, key_data/0,
user/0, username/0, lower0_9/0, label/0,
package_meta/0]).
-include("zx_logger.hrl").
%%% Type Definitions
-type serial() :: integer().
-type package_id() :: {realm(), name(), version()}.
-type package() :: {realm(), name()}.
-type realm() :: lower0_9().
-type name() :: lower0_9().
-type version() :: {Major :: non_neg_integer() | z,
Minor :: non_neg_integer() | z,
Patch :: non_neg_integer() | z}.
-type identifier() :: realm() | package() | package_id().
-type option() :: {string(), term()}.
-type host() :: {string() | inet:ip_address(), inet:port_number()}.
-type key_id() :: {realm(), key_name()}.
-type key_name() :: lower0_9().
-type key_data() :: {ID :: key_id(),
Public :: binary() | <<>>,
Private :: binary() | <<>>}.
-type user_id() :: {realm(), user_name()}.
-type user_name() :: label().
-type contact_info() :: {Type :: string(), Data :: string()}.
-type user_data() :: {ID :: user_id(),
RealName :: string(),
Contact :: contact_info(),
KeyData :: [key_data()]}.
-type lower0_9() :: [$a..$z | $0..$9 | $_].
-type label() :: [$a..$z | $0..$9 | $_ | $- | $.].
-type package_meta() :: #{package_id := package_id(),
deps := [package_id()],
type := app | lib}.
%%% Command Dispatch
-spec do(Args) -> no_return()
when Args :: [string()].
%% Dispatch work functions based on the nature of the input arguments.
do(["help"]) ->
usage_exit(0);
do(["run", PackageString | Args]) ->
run(PackageString, Args);
do(["runlocal" | ArgV]) ->
run_local(ArgV);
do(["init", "app", PackageString]) ->
initialize(app, PackageString);
do(["init", "lib", PackageString]) ->
initialize(lib, PackageString);
do(["install", PackageFile]) ->
assimilate(PackageFile);
do(["set", "dep", PackageString]) ->
set_dep(PackageString);
do(["set", "version", VersionString]) ->
set_version(VersionString);
do(["list", "realms"]) ->
list_realms();
do(["list", "packages", Realm]) ->
list_packages(Realm);
do(["list", "versions", PackageName]) ->
list_versions(PackageName);
do(["list", "pending", PackageName]) ->
list_pending(PackageName);
do(["list", "resigns", Realm]) ->
list_resigns(Realm);
do(["add", "realm", RealmFile]) ->
add_realm(RealmFile);
do(["add", "package", PackageName]) ->
add_package(PackageName);
do(["add", "packager", Package, UserName]) ->
add_packager(Package, UserName);
do(["add", "maintainer", Package, UserName]) ->
add_maintainer(Package, UserName);
do(["review", PackageString]) ->
review(PackageString);
do(["approve", PackageString]) ->
PackageID = zx_lib:package_id(PackageString),
approve(PackageID);
do(["reject", PackageString]) ->
PackageID = zx_lib:package_id(PackageString),
reject(PackageID);
do(["resign", PackageString]) ->
resign(PackageString);
do(["drop", "dep", PackageString]) ->
PackageID = zx_lib:package_id(PackageString),
drop_dep(PackageID);
do(["drop", "key", KeyID]) ->
drop_key(KeyID);
do(["drop", "realm", Realm]) ->
drop_realm(Realm);
do(["verup", Level]) ->
verup(Level);
do(["package"]) ->
{ok, TargetDir} = file:get_cwd(),
package(TargetDir);
do(["package", TargetDir]) ->
case filelib:is_dir(TargetDir) of
true ->
package(TargetDir);
false ->
ok = log(error, "Target directory ~tp does not exist!", [TargetDir]),
halt(22)
end;
do(["submit", PackageFile]) ->
submit(PackageFile);
do(["dialyze"]) ->
dialyze();
do(["create", "user", Realm, Name]) ->
create_user(Realm, Name);
do(["create", "keypair"]) ->
create_keypair();
do(["create", "plt"]) ->
create_plt();
do(["create", "realm"]) ->
create_realm();
do(["create", "realmfile", Realm]) ->
create_realmfile(Realm);
do(["create", "sysop"]) ->
create_sysop();
do(_) ->
usage_exit(22).
%%% Daemon Controls
-spec subscribe(package()) -> ok | {error, Reason :: term()}.
%% @doc
%% Initiates the zx_daemon and instructs it to subscribe to a package.
%%
%% Any events in the Zomp network that apply to the subscribed package will be
%% forwarded to the process that originally called subscribe/1. How the original
%% caller reacts to these notifications is up to the author -- not reply or "ack"
%% is expected.
%%
%% Package subscriptions can be used as the basis for user notification of updates,
%% automatic upgrade restarts, package catalog tracking, etc.
subscribe(Package) ->
case application:start(?MODULE, normal) of
ok -> zx_daemon:subscribe(Package);
Error -> Error
end.
-spec unsubscribe() -> ok | {error, Reason :: term()}.
%% @doc
%% Unsubscribes from package updates.
unsubscribe() ->
zx_daemon:unsubscribe().
%%% Application Start/Stop
-spec start() -> ok | {error, Reason :: term()}.
%% @doc
%% An alias for `application:ensure_started(zx)', meaning it is safe to call this
%% function more than once, or within a system where you are unsure whether zx is
%% already running (perhaps due to complex dependencies that require zx already).
%% In the typical case this function does not ever need to be called, because the
%% zx_daemon is always started in the background whenever an application is started
%% using the command `zx run [app_id]'.
%% @equiv application:ensure_started(zx).
start() ->
application:ensure_started(zx).
-spec stop() -> ok | {error, Reason :: term()}.
%% @doc
%% A safe wrapper for `application:stop(zx)'. Similar to `ensure_started/1,2', returns
%% `ok' in the case that zx is already stopped.
stop() ->
case application:stop(zx) of
ok -> ok;
{error, {not_started, zx}} -> ok;
Error -> Error
end.
%%% Application Callbacks
-spec start(StartType, StartArgs) -> Result
when StartType :: normal,
StartArgs :: [],
Result :: {ok, pid()}.
%% @private
%% Application callback. Not to be called directly.
start(normal, []) ->
ok = application:ensure_started(inets),
zx_daemon:start_link().
-spec stop(term()) -> ok.
%% @private
%% Application callback. Not to be called directly.
stop(_) ->
ok.
%%% Execution of application
-spec run(Identifier, RunArgs) -> no_return()
when Identifier :: string(),
RunArgs :: [string()].
%% @private
%% Given a program Identifier and a list of Args, attempt to locate the program and its
%% dependencies and run the program. This implies determining whether the program and
%% its dependencies are installed, available, need to be downloaded, or are inaccessible
%% given the current system condition (they could also be bogus, of course). The
%% Identifier must be a valid package string of the form `realm-appname[-version]'
%% where the `realm()' and `name()' must follow Zomp package naming conventions and the
%% version should be represented as a semver in string form (where ommitted elements of
%% the version always default to whatever is most current).
%%
%% Once the target program is running, this process, (which will run with the registered
%% name `zx') will sit in an `exec_wait' state, waiting for either a direct message from
%% a child program or for calls made via zx_lib to assist in environment discovery.
%%
%% If there is a problem anywhere in the locating, discovery, building, and loading
%% procedure the runtime will halt with an error message.
run(Identifier, RunArgs) ->
ok = file:set_cwd(zx_lib:zomp_home()),
ok = start(),
FuzzyID =
case zx_lib:package_id(Identifier) of
{ok, Fuzzy} ->
Fuzzy;
{error, invalid_package_string} ->
error_exit("Bad package string: ~ts", [Identifier], ?LINE)
end,
{ok, PackageID} = ensure_installed(FuzzyID),
ok = build(PackageID),
Dir = zx_lib:package_dir(PackageID),
{ok, Meta} = zx_lib:read_project_meta(Dir),
execute(PackageID, Meta, Dir, RunArgs).
%%% Execution of local project
-spec run_local(RunArgs) -> no_return()
when RunArgs :: [term()].
%% @private
%% Execute a local project from source from the current directory, satisfying dependency
%% requirements via the locally installed zomp lib cache. The project must be
%% initialized as a zomp project (it must have a valid `zomp.meta' file).
%%
%% The most common use case for this function is during development. Using zomp support
%% via the local lib cache allows project authors to worry only about their own code
%% and use zx commands to add or drop dependencies made available via zomp.
run_local(RunArgs) ->
Meta = zx_lib:read_project_meta(),
PackageID = maps:get(package_id, Meta),
ok = build(),
{ok, Dir} = file:get_cwd(),
ok = file:set_cwd(zx_lib:zomp_home()),
ok = start(),
execute(PackageID, Meta, Dir, RunArgs).
-spec execute(PackageID, Meta, Dir, RunArgs) -> no_return()
when PackageID :: package_id(),
Meta :: package_meta(),
Dir :: file:filename(),
RunArgs :: [string()].
%% @private
%% Execution prep common to all packages.
execute(PackageID, Meta, Dir, RunArgs) ->
PackageString = zx_lib:package_string(PackageID),
ok = log(info, "Preparing ~ts...", [PackageString]),
Type = maps:get(type, Meta),
Deps = maps:get(deps, Meta),
case zx_daemon:fetch(Deps) of
{{ok, _}, {error, []}} ->
ok = lists:foreach(fun install/1, Deps),
ok = lists:foreach(fun build/1, Deps),
execute(Type, PackageID, Dir, Meta, RunArgs);
{{ok, _}, {error, Errors}} ->
error_exit("Failed package fetches: ~tp", [Errors], ?LINE)
end.
-spec execute(Type, PackageID, Meta, Dir, RunArgs) -> no_return()
when Type :: app | lib,
PackageID :: package_id(),
Meta :: package_meta(),
Dir :: file:filename(),
RunArgs :: [string()].
%% @private
%% Gets all the target application's ducks in a row and launches them, then enters
%% the exec_wait/1 loop to wait for any queries from the application.
execute(app, PackageID, Meta, Dir, RunArgs) ->
PackageString = zx_lib:package_string(PackageID),
ok = log(info, "Starting ~ts.", [PackageString]),
Name = element(2, PackageID),
AppMod = list_to_atom(Name),
ok = zx_daemon:pass_meta(Meta, Dir),
ok = ensure_all_started(AppMod),
ok = pass_argv(AppMod, RunArgs),
log(info, "Launcher complete.");
execute(lib, PackageID, _, _, _) ->
Message = "Lib ~ts is available on the system, but is not a standalone app.",
{ok, PackageString} = zx_lib:package_string(PackageID),
ok = log(info, Message, [PackageString]),
halt(0).
-spec ensure_all_started(AppMod) -> ok
when AppMod :: module().
%% @private
%% Wrap a call to application:ensure_all_started/1 to selectively provide output
%% in the case any dependencies are actually started by the call. Might remove this
%% depending on whether SASL winds up becoming a standard part of the system and
%% whether it becomes common for dependencies to all signal their own start states
%% somehow.
ensure_all_started(AppMod) ->
case application:ensure_all_started(AppMod) of
{ok, []} -> ok;
{ok, Apps} -> log(info, "Started ~tp", [Apps])
end.
-spec pass_argv(AppMod, Args) -> ok
when AppMod :: module(),
Args :: [string()].
%% @private
%% Check whether the AppMod:accept_argv/1 is implemented. If so, pass in the
%% command line arguments provided.
pass_argv(AppMod, Args) ->
case lists:member({accept_argv, 1}, AppMod:module_info(exports)) of
true -> AppMod:accept_argv(Args);
false -> ok
end.
%%% Project initialization
-spec initialize(Type, PackageString) -> no_return()
when Type :: app | lib,
PackageString :: string().
%% @private
%% Initialize an application in the local directory based on the PackageID provided.
%% This function does not care about the name of the current directory and leaves
%% providing a complete, proper and accurate PackageID.
%% This function will check the current `lib/' directory for zomp-style dependencies.
%% If this is not the intended function or if there are non-compliant directory names
%% in `lib/' then the project will need to be rearranged to become zomp compliant or
%% the `deps' section of the resulting meta file will need to be manually updated.
initialize(Type, PackageString) ->
PackageID =
case zx_lib:package_id(PackageString) of
{ok, ID} ->
ID;
{error, invalid_package_string} ->
error_exit("Invalid package string.", ?LINE)
end,
ok = log(info, "Initializing ~s...", [PackageString]),
MetaList =
[{package_id, PackageID},
{deps, []},
{type, Type}],
Meta = maps:from_list(MetaList),
ok = zx_lib:write_project_meta(Meta),
ok = log(info, "Project ~tp initialized.", [PackageString]),
Message =
"NOTICE:~n"
" This project is currently listed as having no dependencies.~n"
" If this is not true then run `zx set dep DepID` for each current dependency.~n"
" (run `zx help` for more information on usage)~n",
ok = io:format(Message),
halt(0).
%%% Add a package from a local file
-spec assimilate(PackageFile) -> PackageID
when PackageFile :: file:filename(),
PackageID :: package_id().
%% @private
%% Receives a path to a file containing package data, examines it, and copies it to a
%% canonical location under a canonical name, returning the PackageID of the package
%% contents.
assimilate(PackageFile) ->
Files = extract_zrp_or_die(PackageFile),
{ok, CWD} = file:get_cwd(),
ok = file:set_cwd(zx_lib:zomp_home()),
{"zomp.meta", MetaBin} = lists:keyfind("zomp.meta", 1, Files),
Meta = binary_to_term(MetaBin),
PackageID = maps:get(package_id, Meta),
TgzFile = zx_lib:namify_tgz(PackageID),
{TgzFile, TgzData} = lists:keyfind(TgzFile, 1, Files),
{KeyID, Signature} = maps:get(sig, Meta),
{ok, PubKey} = loadkey(public, KeyID),
ok =
case public_key:verify(TgzData, sha512, Signature, PubKey) of
true ->
ZrpPath = filename:join("zrp", zx_lib:namify_zrp(PackageID)),
file:copy(PackageFile, ZrpPath);
false ->
error_exit("Bad package signature: ~ts", [PackageFile], ?LINE)
end,
ok = file:set_cwd(CWD),
Message = "~ts is now locally available.",
{ok, PackageString} = zx_lib:package_string(PackageID),
ok = log(info, Message, [PackageString]),
halt(0).
%%% Set dependency
-spec set_dep(Identifier :: string()) -> no_return().
%% @private
%% Set a specific dependency in the current project. If the project currently has a
%% dependency on the same package then the version of that dependency is updated to
%% reflect that in the PackageString argument. The AppString is permitted to be
%% incomplete. Incomplete elements of the VersionString (if included) will default to
%% the latest version available at the indicated level.
set_dep(Identifier) ->
{ok, {Realm, Name, FuzzyVersion}} = zx_lib:package_id(Identifier),
Version =
case FuzzyVersion of
{z, z, z} ->
ok = start(),
{ok, V} = zx_daemon:query_latest({Realm, Name}),
V;
{X, Y, Z} when is_integer(X), is_integer(Y), is_integer(Z) ->
{X, Y, Z};
_ ->
ok = start(),
{ok, V} = zx_daemon:query_latest({Realm, Name, FuzzyVersion}),
V
end,
set_dep({Realm, Name}, Version).
set_dep({Realm, Name}, Version) ->
PackageID = {Realm, Name, Version},
{ok, Meta} = zx_lib:read_project_meta(),
Deps = maps:get(deps, Meta),
case lists:member(PackageID, Deps) of
true ->
{ok, PackageString} = zx_lib:package_string(PackageID),
ok = log(info, "~ts is already a dependency", [PackageString]),
halt(0);
false ->
set_dep(PackageID, Deps, Meta)
end.
-spec set_dep(PackageID, Deps, Meta) -> no_return()
when PackageID :: package_id(),
Deps :: [package_id()],
Meta :: [term()].
%% @private
%% Given the PackageID, list of Deps and the current contents of the project Meta, add
%% or update Deps to include (or update) Deps to reflect a dependency on PackageID, if
%% such a dependency is not already present. Then write the project meta back to its
%% file and exit.
set_dep(PackageID = {Realm, Name, NewVersion}, Deps, Meta) ->
ExistingPackageIDs = fun({R, N, _}) -> {R, N} == {Realm, Name} end,
NewDeps =
case lists:partition(ExistingPackageIDs, Deps) of
{[{Realm, Name, OldVersion}], Rest} ->
Message = "Updating dep ~ts to ~ts",
{ok, OldPS} = zx_lib:package_string({Realm, Name, OldVersion}),
{ok, NewPS} = zx_lib:package_string({Realm, Name, NewVersion}),
ok = log(info, Message, [OldPS, NewPS]),
[PackageID | Rest];
{[], Deps} ->
{ok, PackageString} = zx_lib:package_string(PackageID),
ok = log(info, "Adding dep ~ts", [PackageString]),
[PackageID | Deps]
end,
NewMeta = maps:put(deps, NewDeps, Meta),
ok = zx_lib:write_project_meta(NewMeta),
halt(0).
-spec ensure_installed(PackageID) -> Result | no_return()
when PackageID :: package_id(),
Result :: {ok, ActualID :: package_id()}.
%% @private
%% Given a PackageID, check whether it is installed on the system, and if not, ensure
%% that the package is either in the cache or can be downloaded. If all attempts at
%% locating or acquiring the package fail, then exit with an error.
ensure_installed(PackageID = {Realm, Name, Version}) ->
case resolve_installed_version(PackageID) of
exact -> {ok, PackageID};
{ok, Installed} -> {ok, {Realm, Name, Installed}};
not_found -> fetch_one({Realm, Name, Version})
end.
-spec fetch_one(PackageID) -> {ok, ActualID} | no_return()
when PackageID :: package_id(),
ActualID :: package_id().
%% @private
%% A helper function to deal with the special case of downloading and installing a
%% single primary application package with a possibly incomplete version designator.
%% All other fetches are for arbitrarily long lists of package IDs with complete
%% version numbers (dependency fetches).
fetch_one(PackageID) ->
case zx_daemon:fetch([PackageID]) of
{{ok, [ActualID]}, {error, []}} ->
ok = install(ActualID),
{ok, ActualID};
{{ok, []}, {error, [{PackageID, Reason}]}} ->
error_exit("Package fetch failed with: ~tp", [Reason], ?LINE)
end.
-spec resolve_installed_version(PackageID) -> Result
when PackageID :: package_id(),
Result :: not_found
| exact
| {ok, Installed :: version()}.
%% @private
%% Resolve the provided PackageID to the latest matching installed package directory
%% version if one exists, returning a value that indicates whether an exact match was
%% found (in the case of a full version input), a version matching a partial version
%% input was found, or no match was found at all.
resolve_installed_version({Realm, Name, Version}) ->
PackageDir = filename:join(["lib", Realm, Name]),
case filelib:is_dir(PackageDir) of
true -> resolve_installed_version(PackageDir, Version);
false -> not_found
end.
resolve_installed_version(PackageDir, Version) ->
DirStrings = filelib:wildcard("*", PackageDir),
Versions = lists:fold(fun tuplize/2, [], DirStrings),
zx_lib:find_latest_compatible(Version, Versions).
tuplize(String, Acc) ->
case zx_lib:string_to_version(String) of
{ok, Version} -> [Version | Acc];
_ -> Acc
end.
%%% Set version
-spec set_version(VersionString) -> no_return()
when VersionString :: string().
%% @private
%% Convert a version string to a new version, sanitizing it in the process and returning
%% a reasonable error message on bad input.
set_version(VersionString) ->
NewVersion =
case zx_lib:string_to_version(VersionString) of
{ok, {_, _, z}} ->
Message = "'set version' arguments must be complete, ex: 1.2.3",
error_exit(Message, ?LINE);
{ok, Version} ->
Version;
{error, invalid_version_string} ->
Message = "Invalid version string: ~tp",
error_exit(Message, [VersionString], ?LINE)
end,
{ok, Meta} = zx_lib:read_project_meta(),
{Realm, Name, OldVersion} = maps:get(package_id, Meta),
update_version(Realm, Name, OldVersion, NewVersion, Meta).
-spec update_version(Realm, Name, OldVersion, NewVersion, OldMeta) -> no_return()
when Realm :: realm(),
Name :: name(),
OldVersion :: version(),
NewVersion :: version(),
OldMeta :: package_meta().
%% @private
%% Update a project's `zomp.meta' file by either incrementing the indicated component,
%% or setting the version number to the one specified in VersionString.
%% This part of the procedure updates the meta and does the final write, if the write
%% turns out to be possible. If successful it will indicate to the user what was
%% changed.
update_version(Realm, Name, OldVersion, NewVersion, OldMeta) ->
PackageID = {Realm, Name, NewVersion},
NewMeta = maps:put(package_id, PackageID, OldMeta),
ok = zx_lib:write_project_meta(NewMeta),
OldVS = zx_lib:version_to_string(OldVersion),
NewVS = zx_lib:version_to_string(NewVersion),
ok = log(info, "Version changed from ~s to ~s.", [OldVS, NewVS]),
halt(0).
%%% List Functions
-spec list_realms() -> no_return().
%% @private
%% List all currently configured realms. The definition of a "configured realm" is a
%% realm for which a .realm file exists in $ZOMP_HOME. The realms will be printed to
%% stdout and the program will exit.
list_realms() ->
Pattern = filename:join(zx_lib:zomp_home(), "*.realm"),
RealmFiles = filelib:wildcard(Pattern),
Realms = [filename:basename(RF, ".realm") || RF <- RealmFiles],
ok = lists:foreach(fun(R) -> io:format("~ts~n", [R]) end, Realms),
halt(0).
-spec list_packages(realm()) -> no_return().
%% @private
%% Contact the indicated realm and query it for a list of registered packages and print
%% them to stdout.
list_packages(Realm) ->
ok = start(),
case zx_daemon:list_packages(Realm) of
{ok, []} ->
ok = log(info, "Realm ~tp has no packages available.", [Realm]),
halt(0);
{ok, Packages} ->
Print = fun({R, N}) -> io:format("~ts-~ts~n", [R, N]) end,
ok = lists:foreach(Print, Packages),
halt(0);
{error, bad_realm} ->
error_exit("Bad realm name.", ?LINE);
{error, no_realm} ->
error_exit("Realm \"~ts\" is not configured.", ?LINE);
{error, network} ->
Message = "Network issues are preventing connection to the realm.",
error_exit(Message, ?LINE)
end.
-spec list_versions(PackageName :: string()) -> no_return().
%% @private
%% List the available versions of the package indicated. The user enters a string-form
%% package name (such as "otpr-zomp") and the return values will be full package strings
%% of the form "otpr-zomp-1.2.3", one per line printed to stdout.
list_versions(PackageName) ->
Package = {Realm, Name} =
case zx_lib:package_id(PackageName) of
{ok, {R, N, {z, z, z}}} ->
{R, N};
{error, invalid_package_string} ->
error_exit("~tp is not a valid package name.", [PackageName], ?LINE)
end,
ok = start(),
case zx_daemon:list_versions(Package) of
{ok, []} ->
Message = "Package ~ts has no versions available.",
ok = log(info, Message, [PackageName]),
halt(0);
{ok, Versions} ->
Print =
fun(Version) ->
{ok, PackageString} = zx_lib:package_string({Realm, Name, Version}),
io:format("~ts~n", [PackageString])
end,
ok = lists:foreach(Print, Versions),
halt(0);
{error, bad_realm} ->
error_exit("Bad realm name.", ?LINE);
{error, bad_package} ->
error_exit("Bad package name.", ?LINE);
{error, network} ->
Message = "Network issues are preventing connection to the realm.",
error_exit(Message, ?LINE)
end.
-spec list_pending(PackageName :: string()) -> no_return().
%% @private
%% List the versions of a package that are pending review. The package name is input by
%% the user as a string of the form "otpr-zomp" and the output is a list of full
%% package IDs, printed one per line to stdout (like "otpr-zomp-3.2.2").
list_pending(PackageName) ->
Package = {Realm, Name} =
case zx_lib:package_id(PackageName) of
{ok, {R, N, {z, z, z}}} ->
{R, N};
{error, invalid_package_string} ->
error_exit("~tp is not a valid package name.", [PackageName], ?LINE)
end,
ok = start(),
case zx_daemon:list_pending(Package) of
{ok, []} ->
Message = "Package ~ts has no versions pending.",
ok = log(info, Message, [PackageName]),
halt(0);
{ok, Versions} ->
Print =
fun(Version) ->
{ok, PackageString} = zx_lib:package_string({Realm, Name, Version}),
io:format("~ts~n", [PackageString])
end,
ok = lists:foreach(Print, Versions),
halt(0);
{error, bad_realm} ->
error_exit("Bad realm name.", ?LINE);
{error, bad_package} ->
error_exit("Bad package name.", ?LINE);
{error, network} ->
Message = "Network issues are preventing connection to the realm.",
error_exit(Message, ?LINE)
end.
-spec list_resigns(realm()) -> no_return().
%% @private
%% List the package ids of all packages waiting in the resign queue for the given realm,
%% printed to stdout one per line.
list_resigns(Realm) ->
ok = start(),
case zx_daemon:list_resigns(Realm) of
{ok, []} ->
Message = "No packages pending signature in ~tp.",
ok = log(info, Message, [Realm]),
halt(0);
{ok, PackageIDs} ->
Print =
fun(PackageID) ->
{ok, PackageString} = zx_lib:package_string(PackageID),
io:format("~ts~n", [PackageString])
end,
ok = lists:foreach(Print, PackageIDs),
halt(0);
{error, bad_realm} ->
error_exit("Bad realm name.", ?LINE);
{error, no_realm} ->
error_exit("Realm \"~ts\" is not configured.", ?LINE);
{error, network} ->
Message = "Network issues are preventing connection to the realm.",
error_exit(Message, ?LINE)
end.
%%% Add realm
-spec add_realm(Path) -> no_return()
when Path :: file:filename().
%% @private
%% Add a .realm file to $ZOMP_HOME from a location in the filesystem.
%% Print the SHA512 of the .realm file for the user so they can verify that the file
%% is authentic. This implies, of course, that .realm maintainers are going to
%% post SHA512 sums somewhere visible.
add_realm(Path) ->
case file:read_file(Path) of
{ok, Data} ->
Digest = crypto:hash(sha512, Data),
Text = integer_to_list(binary:decode_unsigned(Digest, big), 16),
ok = log(info, "SHA512 of ~ts: ~ts", [Path, Text]),
add_realm(Path, Data);
{error, enoent} ->
ok = log(warning, "FAILED: ~ts does not exist.", [Path]),
halt(1);
{error, eisdir} ->
ok = log(warning, "FAILED: ~ts is a directory, not a realm file.", [Path]),
halt(1)
end.
-spec add_realm(Path, Data) -> no_return()
when Path :: file:filename(),
Data :: binary().
add_realm(Path, Data) ->
case erl_tar:extract({binary, Data}, [compressed, {cwd, zx_lib:zomp_home()}]) of
ok ->
{Realm, _} = string:take(filename:basename(Path), ".", true),
ok = log(info, "Realm ~ts is now visible to this system.", [Realm]),
halt(0);
{error, invalid_tar_checksum} ->
error_exit("~ts is not a valid realm file.", [Path], ?LINE);
{error, eof} ->
error_exit("~ts is not a valid realm file.", [Path], ?LINE)
end.
-spec add_package(PackageName) -> no_return()
when PackageName :: package().
%% @private
%% A sysop command that adds a package to a realm operated by the caller.
add_package(PackageName) ->
ok = file:set_cwd(zx_lib:zomp_home()),
case zx_lib:package_id(PackageName) of
{ok, {Realm, Name, {z, z, z}}} ->
add_package(Realm, Name);
_ ->
error_exit("~tp is not a valid package name.", [PackageName], ?LINE)
end.
-spec add_package(Realm, Name) -> no_return()
when Realm :: realm(),
Name :: name().
add_package(Realm, Name) ->
Socket = connect_auth_or_die(Realm),
ok = send(Socket, {add_package, {Realm, Name}}),
ok = recv_or_die(Socket),
ok = log(info, "\"~ts-~ts\" added successfully.", [Realm, Name]),
halt(0).
add_packager(Package, UserName) ->
ok = log(info, "Would add ~ts to packagers for ~160tp now.", [UserName, Package]),
halt(0).
add_maintainer(Package, UserName) ->
ok = log(info, "Would add ~ts to maintainer for ~160tp now.", [UserName, Package]),
halt(0).
review(PackageString) ->
PackageID = {Realm, _, _} = zx_lib:package_id(PackageString),
Socket = connect_auth_or_die(Realm),
ok = send(Socket, {review, PackageID}),
{ok, ZrpBin} = recv_or_die(Socket),
ok = send(Socket, ok),
ok = disconnect(Socket),
{ok, Files} = erl_tar:extract({binary, ZrpBin}, [memory]),
{"zomp.meta", MetaBin} = lists:keyfind("zomp.meta", 1, Files),
Meta = binary_to_term(MetaBin, [safe]),
PackageID = maps:get(package_id, Meta),
{KeyID, Signature} = maps:get(sig, Meta),
{ok, PubKey} = loadkey(public, KeyID),
TgzFile = PackageString ++ ".tgz",
{TgzFile, TgzData} = lists:keyfind(TgzFile, 1, Files),
ok = verify(TgzData, Signature, PubKey),
ok =
case file:make_dir(PackageString) of
ok ->
log(info, "Will unpack to directory ./~ts", [PackageString]);
{error, Error} ->
Message = "Creating dir ./~ts failed with ~ts. Aborting.",
ok = log(error, Message, [PackageString, Error]),
halt(1)
end,
ok = erl_tar:extract({binary, TgzData}, [compressed, {cwd, PackageString}]),
ok = log(info, "Unpacked and awaiting inspection."),
halt(0).
approve(PackageID = {Realm, _, _}) ->
Socket = connect_auth_or_die(Realm),
ok = send(Socket, {approve, PackageID}),
ok = recv_or_die(Socket),
ok = log(info, "ok"),
halt(0).
reject(PackageID = {Realm, _, _}) ->
Socket = connect_auth_or_die(Realm),
ok = send(Socket, {reject, PackageID}),
ok = recv_or_die(Socket),
ok = log(info, "ok"),
halt(0).
resign(PackageString) ->
PackageID = {Realm, _, _} = zx_lib:package_id(PackageString),
RealmConf = load_realm_conf(Realm),
{package_keys, PackageKeys} = lists:keyfind(package_keys, 1, RealmConf),
KeySelection = [{K, {R, K}} || {R, K} <- [element(1, K) || K <- PackageKeys]],
PackageKeyID = select(KeySelection),
{ok, PackageKey} = loadkey(private, PackageKeyID),
Socket = connect_auth_or_die(Realm),
ok = send(Socket, {resign, PackageID}),
{ok, ZrpBin} = recv_or_die(Socket),
{ok, Files} = erl_tar:extract({binary, ZrpBin}, [memory]),
{"zomp.meta", MetaBin} = lists:keyfind("zomp.meta", 1, Files),
Meta = binary_to_term(MetaBin, [safe]),
PackageID = maps:get(package_id, Meta),
{KeyID, Signature} = maps:get(sig, Meta),
{ok, PubKey} = loadkey(public, KeyID),
TgzFile = PackageString ++ ".tgz",
{TgzFile, TgzData} = lists:keyfind(TgzFile, 1, Files),
ok = verify(TgzData, Signature, PubKey),
ReSignature = public_key:sign(TgzData, sha512, PackageKey),
FinalMeta = maps:put(sig, {PackageKeyID, ReSignature}, Meta),
NewMetaBin = term_to_binary(FinalMeta),
NewFiles = lists:keystore("zomp.meta", 1, Files, {"zomp.meta", NewMetaBin}),
ResignedZrp = PackageString ++ ".zrp.resign",
ok = erl_tar:create(ResignedZrp, NewFiles),
{ok, ResignedBin} = file:read_file(ResignedZrp),
ok = gen_tcp:send(Socket, ResignedBin),
ok = recv_or_die(Socket),
ok = file:delete(ResignedZrp),
ok = recv_or_die(Socket),
ok = disconnect(Socket),
ok = log(info, "Resigned ~ts", [PackageString]),
halt(0).
%%% Drop dependency
-spec drop_dep(package_id()) -> no_return().
%% @private
%% Remove the indicate dependency from the local project's zomp.meta record.
drop_dep(PackageID) ->
{ok, PackageString} = zx_lib:package_string(PackageID),
{ok, Meta} = zx_lib:read_project_meta(),
Deps = maps:get(deps, Meta),
case lists:member(PackageID, Deps) of
true ->
NewDeps = lists:delete(PackageID, Deps),
NewMeta = maps:put(deps, NewDeps, Meta),
ok = zx_lib:write_project_meta(NewMeta),
Message = "~ts removed from dependencies.",
ok = log(info, Message, [PackageString]),
halt(0);
false ->
ok = log(info, "~ts not found in dependencies.", [PackageString]),
halt(0)
end.
%%% Drop key
-spec drop_key(key_id()) -> no_return().
%% @private
%% Given a KeyID, remove the related public and private keys from the keystore, if they
%% exist. If not, exit with a message that no keys were found, but do not return an
%% error exit value (this instruction is idempotent if used in shell scripts).
drop_key({Realm, KeyName}) ->
ok = file:set_cwd(zx_lib:zomp_home()),
KeyGlob = KeyName ++ ".{key,pub},der",
Pattern = filename:join([zx_lib:zomp_home(), "key", Realm, KeyGlob]),
case filelib:wildcard(Pattern) of
[] ->
ok = log(warning, "Key ~ts/~ts not found", [Realm, KeyName]),
halt(0);
Files ->
ok = lists:foreach(fun file:delete/1, Files),
ok = log(info, "Keyset ~ts/~ts removed", [Realm, KeyName]),
halt(0)
end.
%%% Drop realm
-spec drop_realm(realm()) -> no_return().
drop_realm(Realm) ->
ok = file:set_cwd(zx_lib:zomp_home()),
RealmConf = zx_lib:realm_conf(Realm),
case filelib:is_regular(RealmConf) of
true ->
Message =
"~n"
" WARNING: Are you SURE you want to remove realm ~ts?~n"
" (Only \"Y\" will confirm this action.)~n",
ok = io:format(Message, [Realm]),
case get_input() of
"Y" ->
ok = file:delete(RealmConf),
ok = drop_prime(Realm),
ok = clear_keys(Realm),
ok = log(info, "All traces of realm ~ts have been removed."),
halt(0);
_ ->
ok = log(info, "Aborting."),
halt(0)
end;
false ->
ok = log(warning, "Realm conf ~ts not found.", [RealmConf]),
clear_keys(Realm)
end.
-spec drop_prime(realm()) -> ok.
drop_prime(Realm) ->
Path = "zomp.conf",
case file:consult(Path) of
{ok, Conf} ->
{managed, Primes} = lists:keyfind(managed, 1, Conf),
NewPrimes = lists:delete(Realm, Primes),
NewConf = lists:keystore(managed, 1, Primes, {managed, NewPrimes}),
ok = zx_lib:write_terms(Path, NewConf),
log(info, "Ensuring ~ts is not a prime in ~ts", [Realm, Path]);
{error, enoent} ->
ok
end.
-spec clear_keys(realm()) -> ok.
clear_keys(Realm) ->
KeyDir = filename:join([zx_lib:zomp_home(), "key", Realm]),
case filelib:is_dir(KeyDir) of
true -> rm_rf(KeyDir);
false -> log(warning, "Keydir ~ts not found", [KeyDir])
end.
%%% Update version
-spec verup(Level) -> no_return()
when Level :: string().
%% @private
%% Convert input string arguments to acceptable atoms for use in update_version/1.
verup("major") -> version_up(major);
verup("minor") -> version_up(minor);
verup("patch") -> version_up(patch);
verup(_) -> usage_exit(22).
-spec version_up(Level) -> no_return()
when Level :: major
| minor
| patch.
%% @private
%% Update a project's `zomp.meta' file by either incrementing the indicated component,
%% or setting the version number to the one specified in VersionString.
%% This part of the procedure guards for the case when the zomp.meta file cannot be
%% read for some reason.
version_up(Arg) ->
{ok, Meta} = zx_lib:read_project_meta(),
PackageID = maps:get(package_id, Meta),
version_up(Arg, PackageID, Meta).
-spec version_up(Level, PackageID, Meta) -> no_return()
when Level :: major
| minor
| patch
| version(),
PackageID :: package_id(),
Meta :: [{atom(), term()}].
%% @private
%% Update a project's `zomp.meta' file by either incrementing the indicated component,
%% or setting the version number to the one specified in VersionString.
%% This part of the procedure does the actual update calculation, to include calling to
%% convert the VersionString (if it is passed) to a `version()' type and check its
%% validity (or halt if it is a bad string).
version_up(major, {Realm, Name, OldVersion = {Major, _, _}}, OldMeta) ->
NewVersion = {Major + 1, 0, 0},
update_version(Realm, Name, OldVersion, NewVersion, OldMeta);
version_up(minor, {Realm, Name, OldVersion = {Major, Minor, _}}, OldMeta) ->
NewVersion = {Major, Minor + 1, 0},
update_version(Realm, Name, OldVersion, NewVersion, OldMeta);
version_up(patch, {Realm, Name, OldVersion = {Major, Minor, Patch}}, OldMeta) ->
NewVersion = {Major, Minor, Patch + 1},
update_version(Realm, Name, OldVersion, NewVersion, OldMeta).
%%% Package generation
-spec package(TargetDir) -> no_return()
when TargetDir :: file:filename().
%% @private
%% Turn a target project directory into a package, prompting the user for appropriate
%% key selection or generation actions along the way.
package(TargetDir) ->
ok = log(info, "Packaging ~ts", [TargetDir]),
{ok, Meta} = zx_lib:read_project_meta(TargetDir),
{Realm, _, _} = maps:get(package_id, Meta),
KeyDir = filename:join([zx_lib:zomp_home(), "key", Realm]),
ok = force_dir(KeyDir),
Pattern = KeyDir ++ "/*.key.der",
case [filename:basename(F, ".key.der") || F <- filelib:wildcard(Pattern)] of
[] ->
ok = log(info, "Need to generate key"),
KeyID = prompt_keygen(),
{ok, _, _} = generate_rsa(KeyID),
package(KeyID, TargetDir);
[KeyName] ->
KeyID = {Realm, KeyName},
ok = log(info, "Using key: ~ts/~ts", [Realm, KeyName]),
package(KeyID, TargetDir);
KeyNames ->
KeyName = select_string(KeyNames),
package({Realm, KeyName}, TargetDir)
end.
-spec package(KeyID, TargetDir) -> no_return()
when KeyID :: key_id(),
TargetDir :: file:filename().
%% @private
%% Accept a KeyPrefix for signing and a TargetDir containing a project to package and
%% build a zrp package file ready to be submitted to a repository.
package(KeyID, TargetDir) ->
{ok, Meta} = zx_lib:read_project_meta(TargetDir),
PackageID = maps:get(package_id, Meta),
true = element(1, PackageID) == element(1, KeyID),
{ok, PackageString} = zx_lib:package_string(PackageID),
ZrpFile = PackageString ++ ".zrp",
TgzFile = PackageString ++ ".tgz",
ok = halt_if_exists(ZrpFile),
ok = remove_binaries(TargetDir),
{ok, Everything} = file:list_dir(TargetDir),
DotFiles = filelib:wildcard(".*", TargetDir),
Ignores = ["lib" | DotFiles],
Targets = lists:subtract(Everything, Ignores),
{ok, CWD} = file:get_cwd(),
ok = file:set_cwd(TargetDir),
ok = build(),
Modules =
[filename:basename(M, ".beam") || M <- filelib:wildcard("*.beam", "ebin")],
ok = remove_binaries("."),
ok = erl_tar:create(filename:join(CWD, TgzFile), Targets, [compressed]),
ok = file:set_cwd(CWD),
{ok, Key} = loadkey(private, KeyID),
{ok, TgzBin} = file:read_file(TgzFile),
Sig = public_key:sign(TgzBin, sha512, Key),
Add = fun({K, V}, M) -> maps:put(K, V, M) end,
FinalMeta = lists:foldl(Add, Meta, [{modules, Modules}, {sig, {KeyID, Sig}}]),
ok = file:write_file("zomp.meta", term_to_binary(FinalMeta)),
ok = erl_tar:create(ZrpFile, ["zomp.meta", TgzFile]),
ok = file:delete(TgzFile),
ok = file:delete("zomp.meta"),
ok = log(info, "Wrote archive ~ts", [ZrpFile]),
halt(0).
-spec remove_binaries(TargetDir) -> ok
when TargetDir :: file:filename().
%% @private
%% Procedure to delete all .beam and .ez files from a given directory starting at
%% TargetDir. Called as part of the pre-packaging sanitization procedure.
remove_binaries(TargetDir) ->
Beams = filelib:wildcard("**/*.{beam,ez}", TargetDir),
case [filename:join(TargetDir, Beam) || Beam <- Beams] of
[] ->
ok;
ToDelete ->
ok = log(info, "Removing: ~tp", [ToDelete]),
lists:foreach(fun file:delete/1, ToDelete)
end.
%%% Package submission
-spec submit(PackageFile) -> no_return()
when PackageFile :: file:filename().
%% @private
%% Submit a package to the appropriate "prime" server for the given realm.
submit(PackageFile) ->
Files = extract_zrp_or_die(PackageFile),
{ok, PackageData} = file:read_file(PackageFile),
{"zomp.meta", MetaBin} = lists:keyfind("zomp.meta", 1, Files),
Meta = binary_to_term(MetaBin),
{Realm, Package, Version} = maps:get(package_id, Meta),
{ok, Socket} = connect_auth(Realm),
ok = send(Socket, {submit, {Realm, Package, Version}}),
ok = recv_or_die(Socket),
ok = gen_tcp:send(Socket, PackageData),
ok = log(info, "Done sending contents of ~tp", [PackageFile]),
Outcome = recv_or_die(Socket),
log(info, "Response: ~tp", [Outcome]),
ok = disconnect(Socket),
halt(0).
%%% Authenticated communication with prime
-spec send(Socket, Message) -> ok
when Socket :: gen_tcp:socket(),
Message :: term().
%% @private
%% Wrapper for the procedure necessary to send an internal message over the wire.
send(Socket, Message) ->
Bin = term_to_binary(Message),
gen_tcp:send(Socket, Bin).
-spec recv_or_die(Socket) -> Result | no_return()
when Socket :: gen_tcp:socket(),
Result :: ok | {ok, term()}.
recv_or_die(Socket) ->
receive
{tcp, Socket, Bin} ->
case binary_to_term(Bin) of
ok ->
ok;
{ok, Response} ->
{ok, Response};
{error, Reason} ->
error_exit("Action failed with: ~tp", [Reason], ?LINE);
Unexpected ->
error_exit("Unexpected message: ~tp", [Unexpected], ?LINE)
end;
{tcp_closed, Socket} ->
error_exit("Lost connection to node unexpectedly.", ?LINE)
after 5000 ->
error_exit("Connection timed out.", ?LINE)
end.
-spec connect_auth_or_die(realm()) -> gen_tcp:socket() | no_return().
connect_auth_or_die(Realm) ->
case connect_auth(Realm) of
{ok, Socket} ->
Socket;
Error ->
M1 = "Connection failed to realm prime with ~160tp.",
ok = log(warning, M1, [Error]),
halt(1)
end.
-spec connect_auth(Realm) -> Result
when Realm :: realm(),
Result :: {ok, gen_tcp:socket()}
| {error, Reason :: term()}.
%% @private
%% Connect to one of the servers in the realm constellation.
connect_auth(Realm) ->
RealmConf = load_realm_conf(Realm),
{User, KeyID, Key} = prep_auth(Realm, RealmConf),
{prime, {Host, Port}} = lists:keyfind(prime, 1, RealmConf),
Options = [{packet, 4}, {mode, binary}, {active, true}],
case gen_tcp:connect(Host, Port, Options, 5000) of
{ok, Socket} ->
ok = log(info, "Connected to ~tp prime.", [Realm]),
connect_auth(Socket, Realm, User, KeyID, Key);
Error = {error, E} ->
ok = log(warning, "Connection problem: ~tp", [E]),
{error, Error}
end.
-spec connect_auth(Socket, Realm, User, KeyID, Key) -> Result
when Socket :: gen_tcp:socket(),
Realm :: realm(),
User :: user(),
KeyID :: key_id(),
Key :: term(),
Result :: {ok, gen_tcp:socket()}
| {error, timeout}.
%% @private
%% Send a protocol ID string to notify the server what we're up to, disconnect
%% if it does not return an "OK" response within 5 seconds.
connect_auth(Socket, Realm, User, KeyID, Key) ->
ok = gen_tcp:send(Socket, <<"OTPR AUTH 1">>),
receive
{tcp, Socket, Bin} ->
ok = binary_to_term(Bin, [safe]),
confirm_auth(Socket, Realm, User, KeyID, Key);
{tcp_closed, Socket} ->
ok = log(warning, "Socket closed unexpectedly."),
halt(1)
after 5000 ->
ok = log(warning, "Host realm ~160tp prime timed out.", [Realm]),
{error, auth_timeout}
end.
confirm_auth(Socket, Realm, User, KeyID, Key) ->
ok = send(Socket, {User, KeyID}),
receive
{tcp, Socket, Bin} ->
case binary_to_term(Bin, [safe]) of
{sign, Blob} ->
Sig = public_key:sign(Blob, sha512, Key),
ok = send(Socket, {signed, Sig}),
confirm_auth(Socket);
{error, not_prime} ->
M1 = "Connected node is not prime for realm ~160tp",
ok = log(warning, M1, [Realm]),
ok = disconnect(Socket),
{error, not_prime};
{error, bad_user} ->
M2 = "Bad user record ~160tp",
ok = log(warning, M2, [User]),
ok = disconnect(Socket),
{error, bad_user};
{error, unauthorized_key} ->
M3 = "Unauthorized user key ~160tp",
ok = log(warning, M3, [KeyID]),
ok = disconnect(Socket),
{error, unauthorized_key};
{error, Reason} ->
Message = "Could not begin auth exchange. Failed with ~160tp",
ok = log(warning, Message, [Reason]),
ok = disconnect(Socket),
{error, Reason}
end;
{tcp_closed, Socket} ->
ok = log(warning, "Socket closed unexpectedly."),
halt(1)
after 5000 ->
ok = log(warning, "Host realm ~tp prime timed out.", [Realm]),
{error, auth_timeout}
end.
confirm_auth(Socket) ->
receive
{tcp, Socket, Bin} ->
case binary_to_term(Bin, [safe]) of
ok -> {ok, Socket};
Other -> {error, Other}
end;
{tcp_closed, Socket} ->
ok = log(warning, "Socket closed unexpectedly."),
halt(1)
after 5000 ->
{error, timeout}
end.
-spec prep_auth(Realm, RealmConf) -> {User, KeyID, Key} | no_return()
when Realm :: realm(),
RealmConf :: [term()],
User :: user(),
KeyID :: key_id(),
Key :: term().
%% @private
%% Loads the appropriate User, KeyID and reads in a registered key for use in
%% connect_auth/4.
prep_auth(Realm, RealmConf) ->
UsersFile = filename:join(zx_lib:zomp_home(), "zomp.users"),
Users =
case file:consult(UsersFile) of
{ok, U} ->
U;
{error, enoent} ->
ok = log(warning, "You do not have any users configured."),
halt(1)
end,
{User, KeyIDs} =
case lists:keyfind(Realm, 1, Users) of
{Realm, UserName, []} ->
W = "User ~tp does not have any keys registered for realm ~tp.",
ok = log(warning, W, [UserName, Realm]),
ok = log(info, "Contact the following sysop(s) to register a key:"),
{sysops, Sysops} = lists:keyfind(sysops, 1, RealmConf),
PrintContact =
fun({_, _, Email, Name, _, _}) ->
log(info, "Sysop: ~ts Email: ~ts", [Name, Email])
end,
ok = lists:foreach(PrintContact, Sysops),
halt(1);
{Realm, UserName, KeyNames} ->
KIDs = [{Realm, KeyName} || KeyName <- KeyNames],
{{Realm, UserName}, KIDs};
false ->
Message = "You are not a user of the given realm: ~160tp.",
ok = log(warning, Message, [Realm]),
halt(1)
end,
KeyID = hd(KeyIDs),
true = ensure_keypair(KeyID),
{ok, Key} = loadkey(private, KeyID),
{User, KeyID, Key}.
-spec disconnect(gen_tcp:socket()) -> ok.
%% @private
%% Gracefully shut down a socket, logging (but sidestepping) the case when the socket
%% has already been closed by the other side.
disconnect(Socket) ->
case gen_tcp:shutdown(Socket, read_write) of
ok ->
log(info, "Disconnected from ~tp", [Socket]);
{error, Error} ->
Message = "Shutdown connection ~p failed with: ~p",
log(warning, Message, [Socket, Error])
end.
%%% Key utilities
-spec ensure_keypair(key_id()) -> true | no_return().
%% @private
%% Check if both the public and private key based on KeyID exists.
ensure_keypair(KeyID = {Realm, KeyName}) ->
case {have_public_key(KeyID), have_private_key(KeyID)} of
{true, true} ->
true;
{false, true} ->
Message = "Public key for ~tp/~tp cannot be found",
ok = log(error, Message, [Realm, KeyName]),
halt(1);
{true, false} ->
Message = "Private key for ~tp/~tp cannot be found",
ok = log(error, Message, [Realm, KeyName]),
halt(1);
{false, false} ->
Message = "Key pair for ~tp/~tp cannot be found",
ok = log(error, Message, [Realm, KeyName]),
halt(1)
end.
-spec have_public_key(key_id()) -> boolean().
%% @private
%% Determine whether the public key indicated by KeyID is in the keystore.
have_public_key({Realm, KeyName}) ->
PublicKeyFile = KeyName ++ ".pub.der",
PublicKeyPath = filename:join([zx_lib:zomp_home(), "key", Realm, PublicKeyFile]),
filelib:is_regular(PublicKeyPath).
-spec have_private_key(key_id()) -> boolean().
%% @private
%% Determine whether the private key indicated by KeyID is in the keystore.
have_private_key({Realm, KeyName}) ->
PrivateKeyFile = KeyName ++ ".key.der",
PrivateKeyPath = filename:join([zx_lib:zomp_home(), "key", Realm, PrivateKeyFile]),
filelib:is_regular(PrivateKeyPath).
%%% Key generation
-spec prompt_keygen() -> key_id().
%% @private
%% Prompt the user for a valid KeyPrefix to use for naming a new RSA keypair.
prompt_keygen() ->
Message =
"~n Enter a name for your new keys.~n~n"
" Valid names must start with a lower-case letter, and can include~n"
" only lower-case letters, numbers, and periods, but no series of~n"
" consecutive periods. (That is: [a-z0-9\\.])~n~n"
" To designate the key as realm-specific, enter the realm name and~n"
" key name separated by a space.~n~n"
" Example: some.realm my.key~n",
ok = io:format(Message),
Input = get_input(),
{Realm, KeyName} =
case string:lexemes(Input, " ") of
[R, K] -> {R, K};
[K] -> {"otpr", K}
end,
case {zx_lib:valid_lower0_9(Realm), zx_lib:valid_label(KeyName)} of
{true, true} ->
{Realm, KeyName};
{false, true} ->
ok = io:format("Bad realm name ~tp. Try again.~n", [Realm]),
prompt_keygen();
{true, false} ->
ok = io:format("Bad key name ~tp. Try again.~n", [KeyName]),
prompt_keygen();
{false, false} ->
ok = io:format("NUTS! Both key and realm names are illegal. Try again.~n"),
prompt_keygen()
end.
-spec create_keypair() -> no_return().
%% @private
%% Execute the key generation procedure for 16k RSA keys once and then terminate.
create_keypair() ->
ok = file:set_cwd(zx_lib:zomp_home()),
KeyID = prompt_keygen(),
case generate_rsa(KeyID) of
{ok, _, _} -> halt(0);
Error -> error_exit("create_keypair/0 error: ~tp", [Error], ?LINE)
end.
-spec generate_rsa(KeyID) -> Result
when KeyID :: key_id(),
Result :: {ok, KeyFile, PubFile}
| {error, keygen_fail},
KeyFile :: file:filename(),
PubFile :: file:filename().
%% @private
%% Generate an RSA keypair and write them in der format to the current directory, using
%% filenames derived from Prefix.
%% NOTE: The current version of this command is likely to only work on a unix system.
generate_rsa({Realm, KeyName}) ->
KeyDir = filename:join([zx_lib:zomp_home(), "key", Realm]),
ok = force_dir(KeyDir),
PemFile = filename:join(KeyDir, KeyName ++ ".pub.pem"),
KeyFile = filename:join(KeyDir, KeyName ++ ".key.der"),
PubFile = filename:join(KeyDir, KeyName ++ ".pub.der"),
ok = lists:foreach(fun halt_if_exists/1, [PemFile, KeyFile, PubFile]),
ok = log(info, "Generating ~p and ~p. Please be patient...", [KeyFile, PubFile]),
ok = gen_p_key(KeyFile),
ok = der_to_pem(KeyFile, PemFile),
{ok, PemBin} = file:read_file(PemFile),
[PemData] = public_key:pem_decode(PemBin),
Pub = public_key:pem_entry_decode(PemData),
PubDer = public_key:der_encode('RSAPublicKey', Pub),
ok = file:write_file(PubFile, PubDer),
case check_key(KeyFile, PubFile) of
true ->
ok = file:delete(PemFile),
ok = log(info, "~ts and ~ts agree", [KeyFile, PubFile]),
ok = log(info, "Wrote private key to: ~ts.", [KeyFile]),
ok = log(info, "Wrote public key to: ~ts.", [PubFile]),
{ok, KeyFile, PubFile};
false ->
ok = lists:foreach(fun file:delete/1, [PemFile, KeyFile, PubFile]),
ok = log(error, "Something has gone wrong."),
{error, keygen_fail}
end.
-spec halt_if_exists(file:filename()) -> ok | no_return().
%% @private
%% A helper function to guard against overwriting an existing file. Halts execution if
%% the file is found to exist.
halt_if_exists(Path) ->
case filelib:is_file(Path) of
true -> error_exit("~ts already exists! Halting.", [Path], ?LINE);
false -> ok
end.
-spec gen_p_key(KeyFile) -> ok
when KeyFile :: file:filename().
%% @private
%% Format an openssl shell command that will generate proper 16k RSA keys.
gen_p_key(KeyFile) ->
Command =
io_lib:format("~ts genpkey"
" -algorithm rsa"
" -out ~ts"
" -outform DER"
" -pkeyopt rsa_keygen_bits:16384",
[openssl(), KeyFile]),
Out = os:cmd(Command),
io:format(Out).
-spec der_to_pem(KeyFile, PemFile) -> ok
when KeyFile :: file:filename(),
PemFile :: file:filename().
%% @private
%% Format an openssl shell command that will convert the given keyfile to a pemfile.
%% The reason for this conversion is to sidestep some formatting weirdness that OpenSSL
%% injects into its generated DER formatted key output (namely, a few empty headers)
%% which Erlang's ASN.1 defintion files do not take into account. A conversion to PEM
%% then a conversion back to DER (via Erlang's ASN.1 module) resolves this in a reliable
%% way.
der_to_pem(KeyFile, PemFile) ->
Command =
io_lib:format("~ts rsa"
" -inform DER"
" -in ~ts"
" -outform PEM"
" -pubout"
" -out ~ts",
[openssl(), KeyFile, PemFile]),
Out = os:cmd(Command),
io:format(Out).
-spec check_key(KeyFile, PubFile) -> Result
when KeyFile :: file:filename(),
PubFile :: file:filename(),
Result :: true | false.
%% @private
%% Compare two keys for pairedness.
check_key(KeyFile, PubFile) ->
{ok, KeyBin} = file:read_file(KeyFile),
{ok, PubBin} = file:read_file(PubFile),
Key = public_key:der_decode('RSAPrivateKey', KeyBin),
Pub = public_key:der_decode('RSAPublicKey', PubBin),
TestMessage = <<"Some test data to sign.">>,
Signature = public_key:sign(TestMessage, sha512, Key),
public_key:verify(TestMessage, sha512, Signature, Pub).
-spec openssl() -> Executable | no_return()
when Executable :: file:filename().
%% @private
%% Attempt to locate the installed openssl executable for use in shell commands.
%% Halts execution with an error message if the executable cannot be found.
openssl() ->
OpenSSL =
case os:type() of
{unix, _} -> "openssl";
{win32, _} -> "openssl.exe"
end,
ok =
case os:find_executable(OpenSSL) of
false ->
ok = log(error, "OpenSSL could not be found in this system's PATH."),
ok = log(error, "Install OpenSSL and then retry."),
error_exit("Missing system dependenct: OpenSSL", ?LINE);
Path ->
log(info, "OpenSSL executable found at: ~ts", [Path])
end,
OpenSSL.
-spec loadkey(Type, KeyID) -> Result
when Type :: private | public,
KeyID :: key_id(),
Result :: {ok, DecodedKey :: term()}
| {error, Reason :: term()}.
%% @private
%% Hide the details behind reading and loading DER encoded RSA key files.
loadkey(Type, {Realm, KeyName}) ->
{DerType, Path} =
case Type of
private ->
KeyDer = KeyName ++ ".key.der",
P = filename:join([zx_lib:zomp_home(), "key", Realm, KeyDer]),
{'RSAPrivateKey', P};
public ->
PubDer = KeyName ++ ".pub.der",
P = filename:join([zx_lib:zomp_home(), "key", Realm, PubDer]),
{'RSAPublicKey', P}
end,
ok = log(info, "Loading key from file ~ts", [Path]),
case file:read_file(Path) of
{ok, Bin} -> {ok, public_key:der_decode(DerType, Bin)};
Error -> Error
end.
%%% Generate PLT
-spec create_plt() -> no_return().
%% @private
%% Generate a fresh PLT file that includes most basic core applications needed to
%% make a resonable estimate of a type system, write the name of the PLT to stdout,
%% and exit.
create_plt() ->
ok = build_plt(),
halt(0).
-spec build_plt() -> ok.
%% @private
%% Build a general plt file for Dialyzer based on the core Erland distro.
%% TODO: Make a per-package + dependencies version of this.
build_plt() ->
PLT = default_plt(),
Template =
"dialyzer --build_plt"
" --output_plt ~ts"
" --apps asn1 reltool wx common_test crypto erts eunit inets"
" kernel mnesia public_key sasl ssh ssl stdlib",
Command = io_lib:format(Template, [PLT]),
Message =
"Generating PLT file and writing to: ~tp~n"
" There will be a list of \"unknown functions\" in the final output.~n"
" Don't panic. This is normal. Turtles all the way down, after all...",
ok = log(info, Message, [PLT]),
ok = log(info, "This may take a while. Patience is a virtue."),
Out = os:cmd(Command),
log(info, Out).
-spec default_plt() -> file:filename().
default_plt() ->
filename:join(zx_lib:zomp_home(), "basic.plt").
%%% Dialyze
-spec dialyze() -> no_return().
%% @private
%% Preps a copy of this script for typechecking with Dialyzer.
%% TODO: Create a package_id() based version of this to handle dialyzation of complex
%% projects.
dialyze() ->
PLT = default_plt(),
ok =
case filelib:is_regular(PLT) of
true -> log(info, "Using PLT: ~tp", [PLT]);
false -> build_plt()
end,
TmpDir = filename:join(zx_lib:zomp_home(), "tmp"),
Me = escript:script_name(),
EvilTwin = filename:join(TmpDir, filename:basename(Me ++ ".erl")),
ok = log(info, "Temporarily reconstructing ~tp as ~tp", [Me, EvilTwin]),
Sed = io_lib:format("sed 's/^#!.*$//' ~s > ~s", [Me, EvilTwin]),
"" = os:cmd(Sed),
ok = case dialyzer:run([{init_plt, PLT}, {from, src_code}, {files, [EvilTwin]}]) of
[] ->
io:format("Dialyzer found no errors and returned no warnings! Yay!~n");
Warnings ->
Mine = [dialyzer:format_warning({Tag, {Me, Line}, Msg})
|| {Tag, {_, Line}, Msg} <- Warnings],
lists:foreach(fun io:format/1, Mine)
end,
ok = file:delete(EvilTwin),
halt(0).
%%% Create Realm & Sysop
-spec create_user(realm(), username()) -> no_return().
%% @private
%% Validate the realm and username provided, prompt the user to either select a keypair
%% to use or generate a new one, and bundle a .zuser file for conveyance of the user
%% data and his relevant keys (for import into an existing zomp server via `add'
%% command like "add packager", "add maintainer" and "add sysop".
create_user(Realm, Username) ->
Message = "Would be generating a user file for {~160tp, ~160to}.",
ok = log(info, Message, [Realm, Username]),
halt(0).
-spec create_realm() -> no_return().
%% @private
%% Prompt the user to input the information necessary to create a new zomp realm,
%% package the data appropriately for the server and deliver the final keys and
%% realm file to the user.
create_realm() ->
Instructions =
"~n"
" Enter a name for your new realm.~n"
" Names can contain only lower-case letters, numbers and the underscore.~n"
" Names must begin with a lower-case letter.~n",
ok = io:format(Instructions),
Realm = get_input(),
case zx_lib:valid_lower0_9(Realm) of
true ->
RealmFile = filename:join(zx_lib:zomp_home(), Realm ++ ".realm"),
case filelib:is_regular(RealmFile) of
false ->
create_realm(Realm);
true ->
ok = io:format("That realm already exists. Be more original.~n"),
create_realm()
end;
false ->
ok = io:format("Bad realm name \"~ts\". Try again.~n", [Realm]),
create_realm()
end.
-spec create_realm(Realm) -> no_return()
when Realm :: realm().
create_realm(Realm) ->
ExAddress = prompt_external_address(),
create_realm(Realm, ExAddress).
-spec prompt_external_address() -> Result
when Result :: inet:hostname() | inet:ip_address().
prompt_external_address() ->
Message = external_address_prompt(),
ok = io:format(Message),
case get_input() of
"" ->
ok = io:format("You need to enter an address.~n"),
prompt_external_address();
String ->
parse_address(String)
end.
-spec external_address_prompt() -> string().
external_address_prompt() ->
"~n"
" Enter a static, valid hostname or IPv4 or IPv6 address at which this host "
"can be reached from the public internet (or internal network if it will never "
"need to be reached from the internet).~n"
" DO NOT INCLUDE A PORT NUMBER IN THIS STEP~n".
-spec parse_address(string()) -> inet:hostname() | inet:ip_address().
parse_address(String) ->
case inet:parse_address(String) of
{ok, Address} -> Address;
{error, einval} -> String
end.
-spec create_realm(Realm, ExAddress) -> no_return()
when Realm :: realm(),
ExAddress :: inet:hostname() | inet:ip_address().
create_realm(Realm, ExAddress) ->
Message =
"~n"
" Enter the public (external) port number at which this service should be "
"available. (This might be different from the local port number if you are "
"forwarding ports or have a complex network layout.)~n",
ok = io:format(Message),
ExPort = prompt_port_number(11311),
create_realm(Realm, ExAddress, ExPort).
-spec create_realm(Realm, ExAddress, ExPort) -> no_return()
when Realm :: realm(),
ExAddress :: inet:hostname() | inet:ip_address(),
ExPort :: inet:port_number().
create_realm(Realm, ExAddress, ExPort) ->
Message =
"~n"
" Enter the local (internal/LAN) port number at which this service should be "
"available. (This might be different from the public port visible from the "
"internet if you are port forwarding or have a complex network layout.)~n",
ok = io:format(Message),
InPort = prompt_port_number(11311),
create_realm(Realm, ExAddress, ExPort, InPort).
-spec prompt_port_number(Current) -> Result
when Current :: inet:port_number(),
Result :: inet:port_number().
prompt_port_number(Current) ->
Instructions =
" A valid port is any number from 1 to 65535."
" [Press enter to accept the current setting: ~tw]~n",
ok = io:format(Instructions, [Current]),
case get_input() of
"" ->
Current;
S ->
try
case list_to_integer(S) of
Port when 16#ffff >= Port, Port > 0 ->
Port;
Illegal ->
Whoops = "Whoops! ~tw is out of bounds (1~65535). Try again.~n",
ok = io:format(Whoops, [Illegal]),
prompt_port_number(Current)
end
catch error:badarg ->
ok = io:format("~tp is not a port number. Try again...", [S]),
prompt_port_number(Current)
end
end.
-spec create_realm(Realm, ExAddress, ExPort, InPort) -> no_return()
when Realm :: realm(),
ExAddress :: inet:hostname() | inet:ip_address(),
ExPort :: inet:port_number(),
InPort :: inet:port_number().
create_realm(Realm, ExAddress, ExPort, InPort) ->
Instructions =
"~n"
" Enter a username for the realm sysop.~n"
" Names can contain only lower-case letters, numbers and the underscore.~n"
" Names must begin with a lower-case letter.~n",
ok = io:format(Instructions),
UserName = get_input(),
case zx_lib:valid_lower0_9(UserName) of
true ->
create_realm(Realm, ExAddress, ExPort, InPort, UserName);
false ->
ok = io:format("Bad username ~tp. Try again.~n", [UserName]),
create_realm(Realm, ExAddress, ExPort, InPort)
end.
-spec create_realm(Realm, ExAddress, ExPort, InPort, UserName) -> no_return()
when Realm :: realm(),
ExAddress :: inet:hostname() | inet:ip_address(),
ExPort :: inet:port_number(),
InPort :: inet:port_number(),
UserName :: string().
create_realm(Realm, ExAddress, ExPort, InPort, UserName) ->
Instructions =
"~n"
" Enter an email address for the realm sysop.~n"
" Valid email address rules apply though the checking done here is quite "
"minimal. Check the address you enter carefully. The only people who will "
"suffer from an invalid address are your users.~n",
ok = io:format(Instructions),
Email = get_input(),
[User, Host] = string:lexemes(Email, "@"),
case {zx_lib:valid_lower0_9(User), zx_lib:valid_label(Host)} of
{true, true} ->
create_realm(Realm, ExAddress, ExPort, InPort, UserName, Email);
{false, true} ->
Message = "The user part of the email address seems invalid. Try again.~n",
ok = io:format(Message),
create_realm(Realm, ExAddress, ExPort, InPort, UserName);
{true, false} ->
Message = "The host part of the email address seems invalid. Try again.~n",
ok = io:format(Message),
create_realm(Realm, ExAddress, ExPort, InPort, UserName);
{false, false} ->
Message = "This email address seems like its totally bonkers. Try again.~n",
ok = io:format(Message),
create_realm(Realm, ExAddress, ExPort, InPort, UserName)
end.
-spec create_realm(Realm, ExAddress, ExPort, InPort, UserName, Email) ->
no_return()
when Realm :: realm(),
ExAddress :: inet:hostname() | inet:ip_address(),
ExPort :: inet:port_number(),
InPort :: inet:port_number(),
UserName :: string(),
Email :: string().
create_realm(Realm, ExAddress, ExPort, InPort, UserName, Email) ->
Instructions =
"~n"
" Enter the real name (or whatever name people recognize) for the sysop.~n"
" There are no rules for this one. Any valid UTF-8 printables are legal.~n",
ok = io:format(Instructions),
RealName = get_input(),
create_realm(Realm, ExAddress, ExPort, InPort, UserName, Email, RealName).
-spec create_realm(Realm, ExAddress, ExPort, InPort, UserName, Email, RealName) -> no_return()
when Realm :: realm(),
ExAddress :: inet:hostname() | inet:ip_address(),
ExPort :: inet:port_number(),
InPort :: inet:port_number(),
UserName :: string(),
Email :: string(),
RealName :: string().
create_realm(Realm, ExAddress, ExPort, InPort, UserName, Email, RealName) ->
ok = io:format("~nGenerating keys. This might take a while, so settle in...~n"),
{ok, RealmKey, RealmPub} = generate_rsa({Realm, Realm ++ ".1.realm"}),
{ok, PackageKey, PackagePub} = generate_rsa({Realm, Realm ++ ".1.package"}),
{ok, SysopKey, SysopPub} = generate_rsa({Realm, UserName ++ ".1"}),
ok = log(info, "Generated 16k RSA pair ~ts ~ts", [RealmKey, RealmPub]),
ok = log(info, "Generated 16k RSA pair ~ts ~ts", [PackageKey, PackagePub]),
ok = log(info, "Generated 16k RSA pair ~ts ~ts", [SysopKey, SysopPub]),
Timestamp = calendar:now_to_universal_time(erlang:timestamp()),
{ok, RealmPubData} = file:read_file(RealmPub),
RealmPubRecord =
{{Realm, filename:basename(RealmPub, ".pub.der")},
realm,
{realm, Realm},
crypto:hash(sha512, RealmPubData),
Timestamp},
{ok, PackagePubData} = file:read_file(PackagePub),
PackagePubRecord =
{{Realm, filename:basename(PackagePub, ".pub.der")},
package,
{realm, Realm},
crypto:hash(sha512, PackagePubData),
Timestamp},
UserRecord =
{{Realm, UserName},
[filename:basename(SysopPub, ".pub.der")],
Email,
RealName},
RealmSettings =
[{realm, Realm},
{revision, 0},
{prime, {ExAddress, ExPort}},
{private, []},
{mirrors, []},
{sysops, [UserRecord]},
{realm_keys, [RealmPubRecord]},
{package_keys, [PackagePubRecord]}],
ZompSettings =
[{managed, [Realm]},
{external_address, ExAddress},
{external_port, ExPort},
{internal_port, InPort}],
{ok, CWD} = file:get_cwd(),
{ok, TempDir} = mktemp_dir("zomp"),
ok = file:set_cwd(TempDir),
KeyDir = filename:join("key", Realm),
ok = filelib:ensure_dir(KeyDir),
ok = file:make_dir(KeyDir),
KeyCopy =
fun(K) ->
{ok, _} = file:copy(K, filename:join(KeyDir, filename:basename(K))),
ok
end,
PublicZRF = filename:join(CWD, Realm ++ ".zrf"),
RealmFN = Realm ++ ".realm",
ok = zx_lib:write_terms(RealmFN, RealmSettings),
ok = KeyCopy(PackagePub),
ok = KeyCopy(RealmPub),
ok = erl_tar:create(PublicZRF, [RealmFN, "key"], [compressed]),
PrimeZRF = filename:join(CWD, Realm ++ ".zpf"),
ok = KeyCopy(SysopPub),
ok = zx_lib:write_terms("zomp.conf", ZompSettings),
ok = erl_tar:create(PrimeZRF, [RealmFN, "zomp.conf", "key"], [compressed]),
KeyBundle = filename:join(CWD, Realm ++ ".zkf"),
ok = lists:foreach(KeyCopy, [PackageKey, RealmKey, SysopKey]),
ok = erl_tar:create(KeyBundle, [KeyDir], [compressed]),
ok = file:set_cwd(CWD),
ok = rm_rf(TempDir),
Message =
"===========================================================================~n"
"DONE!~n"
"~n"
"The realm ~ts has been created and is accessible from the current system.~n"
"Three configuration bundles have been created in the current directory:~n"
"~n"
" 1. ~ts ~n"
"This is the PRIVATE realm file you will need to install on the realm's prime~n"
"node. It includes the your (the sysop's) public key.~n"
"~n"
" 2. ~ts ~n"
"This file is the PUBLIC realm file other zomp nodes and zx users will need~n"
"to access the realm. It does not include your (the sysop's) public key.~n"
"~n"
" 3. ~ts ~n"
"This is the bundle of ALL KEYS that are defined in this realm at the moment.~n"
"~n"
"Now you need to make copies of these three files and back them up.~n"
"~n"
"On the PRIME NODE you need to run `zx add realm ~ts` and follow the prompts~n"
"to cause it to begin serving that realm as prime. (Node restart required.)~n"
"~n"
"On all zx CLIENTS that want to access your new realm and on all subordinate~n"
"MIRROR NODES the command `zx add realm ~ts` will need to be run.~n"
"The method of public realm file distribution (~ts) is up to you.~n"
"~n"
"~n"
"Public & Private key installation (if you need to recover them or perform~n"
"sysop functions from another computer) is `zx add keybundle ~ts`.~n"
"===========================================================================~n",
Substitutions =
[Realm,
PrimeZRF, PublicZRF, KeyBundle,
PrimeZRF,
PublicZRF, PublicZRF,
KeyBundle],
ok = io:format(Message, Substitutions),
halt(0).
-spec create_realmfile(realm()) -> no_return().
create_realmfile(Realm) ->
RealmConf = load_realm_conf(Realm),
ok = log(info, "Realm found, creating realm file..."),
{revision, Revision} = lists:keyfind(revision, 1, RealmConf),
{realm_keys, RealmKeys} = lists:keyfind(realm_keys, 1, RealmConf),
{package_keys, PackageKeys} = lists:keyfind(package_keys, 1, RealmConf),
RealmKeyIDs = [element(1, K) || K <- RealmKeys],
PackageKeyIDs = [element(1, K) || K <- PackageKeys],
create_realmfile(Realm, Revision, RealmKeyIDs, PackageKeyIDs).
-spec create_realmfile(Realm, Revision, RealmKeyIDs, PackageKeyIDs) -> ok
when Realm :: realm(),
Revision :: non_neg_integer(),
RealmKeyIDs :: [key_id()],
PackageKeyIDs :: [key_id()].
create_realmfile(Realm, Revision, RealmKeyIDs, PackageKeyIDs) ->
{ok, CWD} = file:get_cwd(),
ok = file:set_cwd(zx_lib:zomp_home()),
KeyPath = fun({R, K}) -> filename:join(["key", R, K ++ ".pub.der"]) end,
RealmKeyPaths = lists:map(KeyPath, RealmKeyIDs),
PackageKeyPaths = lists:map(KeyPath, PackageKeyIDs),
Targets = [zx_lib:realm_conf(Realm) | RealmKeyPaths ++ PackageKeyPaths],
OutFile = filename:join(CWD, Realm ++ "." ++ integer_to_list(Revision) ++ ".zrf"),
ok = erl_tar:create(OutFile, Targets, [compressed]),
ok = log(info, "Realm conf file written to ~ts", [OutFile]),
halt(0).
-spec create_sysop() -> no_return().
create_sysop() ->
ok = log(info, "Fo' realz, yo! We be sysoppin up in hurr!"),
halt(0).
%%% Package utilities
-spec install(package_id()) -> ok.
%% @private
%% Install a package from the cache into the local system.
%% Before calling this function it must be known that:
%% - The zrp file is in the cache
%% - The zrp file is valid
%% - This function will only be called on startup by the launch process
%% - The package is not already installed
%% - If this function crashes it will completely halt the system
install(PackageID) ->
{ok, PackageString} = zx_lib:package_string(PackageID),
ok = log(info, "Installing ~ts", [PackageString]),
ZrpFile = filename:join("zrp", zx_lib:namify_zrp(PackageID)),
Files = extract_zrp_or_die(ZrpFile),
TgzFile = zx_lib:namify_tgz(PackageID),
{TgzFile, TgzData} = lists:keyfind(TgzFile, 1, Files),
{"zomp.meta", MetaBin} = lists:keyfind("zomp.meta", 1, Files),
Meta = binary_to_term(MetaBin),
{KeyID, Signature} = maps:get(sig, Meta),
{ok, PubKey} = loadkey(public, KeyID),
ok = ensure_package_dirs(PackageID),
PackageDir = filename:join("lib", PackageString),
ok = force_dir(PackageDir),
ok = verify(TgzData, Signature, PubKey),
ok = erl_tar:extract({binary, TgzData}, [compressed, {cwd, PackageDir}]),
log(info, "~ts installed", [PackageString]).
-spec verify(Data, Signature, PubKey) -> ok | no_return()
when Data :: binary(),
Signature :: binary(),
PubKey :: public_key:rsa_public_key().
%% @private
%% Verify the RSA Signature of some Data against the given PubKey or halt execution.
%% This function always assumes sha512 is the algorithm being used.
%% Should only ever be called by the initial launch process.
verify(Data, Signature, PubKey) ->
case public_key:verify(Data, sha512, Signature, PubKey) of
true -> ok;
false -> error_exit("Bad package signature!", ?LINE)
end.
-spec build(package_id()) -> ok.
%% @private
%% Given an AppID, build the project from source and add it to the current lib path.
build(PackageID) ->
{ok, CWD} = file:get_cwd(),
ok = file:set_cwd(zx_lib:package_dir(PackageID)),
ok = build(),
file:set_cwd(CWD).
-spec build() -> ok.
%% @private
%% Run any local `zxmake' script needed by the project for non-Erlang code (if present),
%% then add the local `ebin/' directory to the runtime search path, and finally build
%% the Erlang part of the project with make:all/0 according to the local `Emakefile'.
build() ->
ZxMake = "zxmake",
ok =
case filelib:is_regular(ZxMake) of
true ->
Out = os:cmd(ZxMake),
log(info, Out);
false ->
ok
end,
true = code:add_patha(filename:absname("ebin")),
up_to_date = make:all(),
ok.
%%% User menu interface (terminal)
-spec get_input() -> string().
%% @private
%% Provide a standard input prompt and newline sanitized return value.
get_input() ->
string:trim(io:get_line("(^C to quit): ")).
-spec select(Options) -> Selected
when Options :: [option()],
Selected :: term().
%% @private
%% Take a list of Options to present the user, then return the indicated option to the
%% caller once the user selects something.
select(Options) ->
Max = show(Options),
case pick(string:to_integer(io:get_line("(or ^C to quit)~n ? ")), Max) of
error ->
ok = hurr(),
select(Options);
I ->
{_, Value} = lists:nth(I, Options),
Value
end.
-spec select_string(Strings) -> Selected
when Strings :: [string()],
Selected :: string().
%% @private
%% @equiv select([{S, S} || S <- Strings])
select_string(Strings) ->
select([{S, S} || S <- Strings]).
-spec show(Options) -> Index
when Options :: [option()],
Index :: pos_integer().
%% @private
%% @equiv show(Options, 0).
show(Options) ->
show(Options, 0).
-spec show(Options, Index) -> Count
when Options :: [option()],
Index :: non_neg_integer(),
Count :: pos_integer().
%% @private
%% Display the list of options needed to the user, and return the option total count.
show([], I) ->
I;
show([{Label, _} | Rest], I) ->
Z = I + 1,
ok = io:format(" ~2w - ~ts~n", [Z, Label]),
show(Rest, Z).
-spec pick({Selection, term()}, Max) -> Result
when Selection :: error | integer(),
Max :: pos_integer(),
Result :: pos_integer() | error.
%% @private
%% Interpret a user's selection returning either a valid selection index or `error'.
pick({error, _}, _) -> error;
pick({I, _}, Max) when 0 < I, I =< Max -> I;
pick(_, _) -> error.
-spec hurr() -> ok.
%% @private
%% Present an appropriate response when the user derps on selection.
hurr() -> io:format("That isn't an option.~n").
%%% Directory & File Management
-spec mktemp_dir(Prefix) -> Result
when Prefix :: string(),
Result :: {ok, TempDir :: file:filename()}
| {error, Reason :: file:posix()}.
mktemp_dir(Prefix) ->
Rand = integer_to_list(binary:decode_unsigned(crypto:strong_rand_bytes(8)), 36),
TempPath = filename:basedir(user_cache, Prefix),
TempDir = filename:join(TempPath, Rand),
Result1 = filelib:ensure_dir(TempDir),
Result2 = file:make_dir(TempDir),
case {Result1, Result2} of
{ok, ok} -> {ok, TempDir};
{ok, Error} -> Error;
{Error, _} -> Error
end.
-spec rm_rf(file:filename()) -> ok | {error, file:posix()}.
%% @private
%% Recursively remove files and directories. Equivalent to `rm -rf'.
rm_rf(Path) ->
case filelib:is_dir(Path) of
true ->
Pattern = filename:join(Path, "**"),
Contents = lists:reverse(lists:sort(filelib:wildcard(Pattern))),
ok = lists:foreach(fun rm/1, Contents),
file:del_dir(Path);
false ->
file:delete(Path)
end.
-spec rm(file:filename()) -> ok | {error, file:posix()}.
%% @private
%% An omnibus delete helper.
rm(Path) ->
case filelib:is_dir(Path) of
true -> file:del_dir(Path);
false -> file:delete(Path)
end.
-spec ensure_package_dirs(package_id()) -> ok.
%% @private
%% Procedure to guarantee that directory locations necessary for the indicated app to
%% run have been created or halt execution.
ensure_package_dirs(PackageID = {Realm, Name, _}) ->
Package = {Realm, Name},
PackageHome = zx_lib:package_dir(PackageID),
PackageData = zx_lib:package_dir("var", Package),
PackageConf = zx_lib:package_dir("etc", Package),
Dirs = [PackageHome, PackageData, PackageConf],
ok = lists:foreach(fun force_dir/1, Dirs),
log(info, "Created dirs:~n\t~ts~n\t~ts~n\t~ts", Dirs).
-spec force_dir(Path) -> Result
when Path :: file:filename(),
Result :: ok
| {error, file:posix()}.
%% @private
%% Guarantee a directory path is created if it is possible to create or if it already
%% exists.
force_dir(Path) ->
case filelib:is_dir(Path) of
true -> ok;
false -> filelib:ensure_dir(filename:join(Path, "foo"))
end.
-spec extract_zrp_or_die(FileName) -> Files | no_return()
when FileName :: file:filename(),
Files :: [{file:filename(), binary()}].
%% @private
%% Extract a zrp archive, if possible. If not possible, halt execution with as accurate
%% an error message as can be managed.
extract_zrp_or_die(FileName) ->
case erl_tar:extract(FileName, [memory]) of
{ok, Files} ->
Files;
{error, {FileName, enoent}} ->
Message = "Can't find file ~ts.",
error_exit(Message, [FileName], ?LINE);
{error, invalid_tar_checksum} ->
Message = "~ts is not a valid zrp archive.",
error_exit(Message, [FileName], ?LINE);
{error, Reason} ->
Message = "Extracting package file failed with: ~160tp.",
error_exit(Message, [Reason], ?LINE)
end.
-spec load_realm_conf(Realm) -> RealmConf | no_return()
when Realm :: realm(),
RealmConf :: list().
%% @private
%% Load the config for the given realm or halt with an error.
load_realm_conf(Realm) ->
case zx_lib:load_realm_conf(Realm) of
{ok, C} ->
C;
{error, enoent} ->
ok = log(warning, "Realm ~tp is not configured.", [Realm]),
halt(1)
end.
%%% Usage
-spec usage_exit(Code) -> no_return()
when Code :: integer().
%% @private
%% A convenience function that will display the zx usage message before halting
%% with the provided exit code.
usage_exit(Code) ->
ok = usage(),
halt(Code).
-spec usage() -> ok.
%% @private
%% Display the zx command line usage message.
usage() ->
T = "~n"
"zx~n"
"~n"
"Usage: zx [command] [object] [args]~n"
"~n"
"Examples:~n"
" zx help~n"
" zx run PackageID [Args]~n"
" zx init Type PackageID~n"
" zx install PackageID~n"
" zx set dep PackageID~n"
" zx set version Version~n"
" zx list realms~n"
" zx list packages Realm~n"
" zx list versions PackageName~n"
" zx list pending PackageName~n"
" zx list resigns Realm~n"
" zx add realm RealmFile~n"
" zx add package PackageName~n"
" zx add packager PackageName~n"
" zx add maintainer PackageName~n"
" zx add sysop UserID~n"
" zx review PackageID~n"
" zx approve PackageID~n"
" zx reject PackageID~n"
" zx resign PackageID~n"
" zx drop dep PackageID~n"
" zx drop key Realm KeyName~n"
" zx drop realm Realm~n"
" zx verup Level~n"
" zx runlocal [Args]~n"
" zx package [Path]~n"
" zx submit Path~n"
" zx create user Realm Username~n"
" zx create keypair~n"
" zx create plt~n"
" zx create realm~n"
" zx create realmfile Realm~n"
" zx create sysop~n"
"~n"
"Where~n"
" PackageID :: A string of the form Realm-Name[-Version]~n"
" Args :: Arguments to pass to the application~n"
" Type :: The project type: a standalone \"app\" or a \"lib\"~n"
" Version :: Version string X, X.Y, or X.Y.Z: \"1\", \"1.2\", \"1.2.3\"~n"
" RealmFile :: Path to a valid .zrf realm file~n"
" Realm :: The name of a realm as a string [:a-z:]~n"
" KeyName :: The prefix of a keypair to drop~n"
" Level :: The version level, one of \"major\", \"minor\", or \"patch\"~n"
" Path :: Path to a valid project directory or .zrp file~n"
"~n",
io:format(T).
%%% Error exits
-spec error_exit(Error, Line) -> no_return()
when Error :: term(),
Line :: non_neg_integer().
%% @private
%% Format an error message in a way that makes it easy to locate.
error_exit(Error, Line) ->
error_exit(Error, [], Line).
-spec error_exit(Format, Args, Line) -> no_return()
when Format :: string(),
Args :: [term()],
Line :: non_neg_integer().
%% @private
%% Format an error message in a way that makes it easy to locate.
error_exit(Format, Args, Line) ->
File = filename:basename(?FILE),
ok = log(error, "~ts:~tp: " ++ Format, [File, Line | Args]),
halt(1).