1 /** This is commandline tool of the ACME v2 client
2  *
3  * This tool implements a ACME V2 compatible client, which allows to setup
4  * an account on the LetsEncrypt ACME server, to open an order for a new
5  * certificate, to setup the challanges and finally downloads the certificate.
6  *
7  * See:
8  *   RFC8555 - Describes the ACME Protokol, version 2
9  */
10 module app;
11 
12 /* Imports */
13 
14 import std.file;
15 import std.getopt;
16 import std.json;
17 import std.stdio;
18 
19 import acme;
20 
21 /* Decoded Commandline Options */
22 
23 string argServerUrl;         /// Alternate server URL
24 string argPrivateKeyFile;    /// The path to the private key for the ACME account
25 string argDomainKeyFile;     /// The path to the private key for the certs and csr
26 string argOutputFile;        /// The output path for the downloaded cert.
27 string argChallangeScript;   /// Name of challange script to call
28 string[] argDomainNames;     /// The list of domain names
29 string[] argContacts;        /// The list of account names
30 /// Supported key sizes
31 enum ArgRSABitsEnum {
32 	rsa2048 = 2048,
33 	rsa4096 = 4096
34 }
35 ArgRSABitsEnum argRSABits;   /// Select the number of bit by the enum name
36 bool argVerbose;             /// Verbosity mode?
37 bool argForceUpdate;         /// Force Cert update
38 bool argUseStaging;          /// Use staging server
39 bool argTosAgree;            /// Agree to Terms of Service
40 
41 /** Long Help text and example */
42 enum helpLongText = q"(
43 Example:
44   $ ./acme-lw-d -k key.pem -p domain.key -o domain.pem \
45        -d your-domain.net -d www.your-domain.net \
46        -c "mailto:webmaster@domain.net" \
47        -w "./examples/setupChallange.sh" \
48        -y -v -b {rsa2048|rs4096}
49 
50   RS keys will be created on first run and stored on disk. They are reused
51   when existing.
52 
53   The setup-challange script is called with the challange type, the filename
54    and token. Right new, only http challange is supported (FIXME).
55 )";
56 
57 
58 /** Call-out to setup the ACME
59  *
60  * Params:
61  *   domain - domain identifier
62  *   url - the url to prepare
63  *   keyAuthorization - the token to return
64  * Returns:
65  *   A shell return value (0 means success)
66  */
67 private
68 int handleChallenge(string domain, string url, string keyAuthorization)
69 {
70 	import std.format : format;
71 	string cmd = format("%s %s %s %s", argChallangeScript, domain, url, keyAuthorization);
72 	writefln("Running command '%s'", cmd); stdout.flush;
73 
74 	import std.process : executeShell;
75 	auto rc = executeShell(cmd);
76 	writeln(rc.output);
77 	writefln("Command returned status %d (sucess=%s)", rc.status, rc.status == 0 ? true : false);
78 	return rc.status;
79 }
80 
81 /** Programm Main
82  *
83  * Param:
84  *   args - array of command line args
85  * Return:
86  *   shell error code
87  */
88 int main(string[] args)
89 {
90 	if (args.length <= 1) args ~= "-h";
91 	auto helpInformation = getopt(
92 		args,
93 		std.getopt.config.required,
94 		"key|k",     "The path to private key of ACME account. (PEM file)", &argPrivateKeyFile,
95 		std.getopt.config.required,
96 		"domainkey|p",     "The path to your private key for X509 certificates (PEM file)", &argDomainKeyFile,
97 		std.getopt.config.required,
98 		"domain|d",  "A domain name. Can be given multiple times. First entry will be subject name.", &argDomainNames,
99 		std.getopt.config.required,
100 		"contact|c", "A contact for the account. Can be given multiple times.", &argContacts,
101 		std.getopt.config.required,
102 		"output|o",  "The output file for the PEM encoded X509 cert", &argOutputFile,
103 		std.getopt.config.required,
104 		"setupchallange|w",  "Programm to call to setup a challange", &argChallangeScript,
105 		"bits|b",    "RSA bits to use for keys. Used on new key creation", &argRSABits,
106 		"agree|y",   "Agree to TermsOfService, when creating the account.", &argTosAgree,
107 		"staging|s", "Use the staging server for initial testing or developing", &argUseStaging,
108 		"server",    "Alternate ACME server directory url", &argServerUrl,
109 		"force|f",   "Force certificate update", &argForceUpdate,
110 		"verbose|v", "Verbose output", &argVerbose);
111 	if (helpInformation.helpWanted)
112 	{
113 		defaultGetoptPrinter("Usage: acme_client <options>",
114 			helpInformation.options);
115 		writeln(helpLongText);
116 		return 1;
117 	}
118 	assert(argPrivateKeyFile !is null, "The path should be set?!");
119 	assert(argDomainKeyFile !is null, "The path should be set?!");
120 	assert(argDomainNames.length >= 1, "No domain names found?!");
121 	assert(argContacts.length >= 1, "No contacts found?!");
122 
123 	if (argUseStaging) {
124 		writeln("Note: Running against staging environment!");
125 		useStagingServer = true;
126 	} else {
127 		writeln("Note: Running against production environment!");
128 		useStagingServer = false;
129 	}
130 
131 	/* -- Read the keys from disk ---------------------------------------- */
132 	string privateKeyData;
133 	if (exists(argPrivateKeyFile)) {
134 		privateKeyData = std.file.readText(argPrivateKeyFile);
135 		if (argVerbose) writefln("Read private key for ACME account from %s.", argPrivateKeyFile);
136 	} else {
137 		import acme.openssl_helpers : openSSL_CreatePrivateKey;
138 		privateKeyData = openSSL_CreatePrivateKey(argRSABits).idup;
139 		std.file.write(argPrivateKeyFile, privateKeyData);
140 		if (argVerbose) writeln("Created private key for ACME account.");
141 	}
142 
143 	string domainKeyData;
144 	if (exists(argDomainKeyFile)) {
145 		domainKeyData = std.file.readText(argDomainKeyFile);
146 		if (argVerbose) writefln("Read private key for csr from %s.", argDomainKeyFile);
147 	}
148 	else {
149 		import acme.openssl_helpers : openSSL_CreatePrivateKey;
150 		domainKeyData = openSSL_CreatePrivateKey(argRSABits).idup;
151 		std.file.write(argDomainKeyFile, domainKeyData);
152 		if (argVerbose) writeln("Created private key for ACME account.");
153 	}
154 
155 	/* -- Test for existing certificat, test remaining lifetime ---------- */
156 
157 	if (argOutputFile.exists && argOutputFile.isFile)
158 	{
159 		bool updateCert;
160 		writefln("Files '%s' and '%s' have been read.", argOutputFile, argDomainKeyFile);
161 		auto certificatePEM = std.file.readText(argOutputFile);
162 		Certificate certificate = Certificate(certificatePEM, domainKeyData);
163 		import std.datetime : DateTime, SysTime, Clock, dur;
164 		auto expdate = certificate.getExpiry();
165 		writeln("Certificate expires on " ~ expdate.toSimpleString);
166 		SysTime today = Clock.currTime();
167 		if (!argForceUpdate && cast(DateTime)today < (expdate - dur!"weeks"(4)) )
168 		{
169 			writeln("No need to update the certificate. Use --force flag.");
170 		}
171 		else {
172 			writefln("%s to update the certificate.", argForceUpdate ? "Forced" : "Need");
173 			updateCert = true;
174 		}
175 		if (!argForceUpdate)
176 			return 0;
177 	}
178 
179 	/* -- ACME V2 process starts below ----------------------------------- */
180 
181 	int exitStatus = -1;
182 	try
183 	{
184 		curlBeVerbose = argVerbose;
185 
186 		/* --- Create the ACME client object ----------------------------- */
187 		AcmeClient acmeClient = new AcmeClient(privateKeyData, argVerbose);
188 
189 		if (argServerUrl)
190 			acmeClient.acmeRes.directoryUrl = argServerUrl;
191 
192 		acmeClient.setupClient();
193 		writeln( "URL for ACME directory : ", acmeClient.acmeRes.directoryUrl);
194 		if (argVerbose) {
195 			writeln( acmeClient.acmeRes.directoryJson.toPrettyString() );
196 		}
197 		/* --- Create a new account/Use existing account  ----------------- */
198 		const bool nwaccrc = acmeClient.createNewAccount(argContacts, argTosAgree);
199 		if (!nwaccrc) {
200 			stdout.writeln("Failed to create new or obtain exiting account.");
201 			return exitStatus;
202 		}
203 
204 		/* --- Issue a new cert process ----------------------------------- */
205 		Certificate certificate;
206 		certificate = acmeClient.issueCertificate(domainKeyData, argDomainNames, &handleChallenge);
207 
208 		/* --- Write out file --------------------------------------------- */
209 		std.file.write(argOutputFile, certificate.fullchain);
210 		std.file.write(argDomainKeyFile, certificate.privkey);
211 		writefln( "Files '%s' and '%s' have been written.", argOutputFile, argDomainKeyFile);
212 
213 		/* Get the expiry date from cert */
214 		auto expdate = certificate.getExpiryDisplay();
215 		writeln( "Certificate expires on " ~ expdate);
216 
217 		exitStatus = 0;
218 	}
219 	catch (AcmeException e)
220 	{
221 		writeln( "Failed with error: " ~ e.msg );
222 	}
223 	return exitStatus;
224 }
225