/*
* Copyright © 2019, 2020, 2021, 2022, 2023 Peter Doornbosch
*
* This file is part of Kwik, an implementation of the QUIC protocol in Java.
*
* Kwik is free software: you can redistribute it and/or modify it under
* the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your option)
* any later version.
*
* Kwik 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 Lesser General Public License for
* more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program. If not, see .
*/
package net.luminis.quic.run;
import net.luminis.quic.*;
import net.luminis.quic.client.h09.Http09Client;
import net.luminis.quic.log.FileLogger;
import net.luminis.quic.log.Logger;
import net.luminis.quic.log.SysOutLogger;
import net.luminis.tls.NewSessionTicket;
import net.luminis.tls.TlsConstants;
import org.apache.commons.cli.*;
import java.io.*;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.net.*;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.cert.*;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.stream.Collectors;
/**
* Command line interface for Kwik client.
*/
public class KwikCli {
private static Options cmdLineOptions;
private static String DEFAULT_LOG_ARGS = "wip";
public enum HttpVersion {
HTTP09,
HTTP3
}
public static void main(String[] rawArgs) throws ParseException {
cmdLineOptions = new Options();
cmdLineOptions.addOption("l", "log", true, "logging options: [pdrcsiRSD]: " +
"(p)ackets received/sent, (d)ecrypted bytes, (r)ecovery, (c)ongestion control, (s)tats, (i)nfo, (w)arning, (R)aw bytes, (S)ecrets, (D)ebug; "
+ " default is \"" + DEFAULT_LOG_ARGS + "\", use (n)one to disable");
cmdLineOptions.addOption("h", "help", false, "show help");
cmdLineOptions.addOption("29", "use Quic version IETF_draft_29");
cmdLineOptions.addOption("30", "use Quic version IETF_draft_30");
cmdLineOptions.addOption("31", "use Quic version IETF_draft_31");
cmdLineOptions.addOption("32", "use Quic version IETF_draft_32");
cmdLineOptions.addOption("v1", "use Quic version 1");
cmdLineOptions.addOption("v2", "use Quic version 2");
cmdLineOptions.addOption("v1v2", "use Quic version 1, request version 2");
cmdLineOptions.addOption(null, "reservedVersion", false, "use reserved version to trigger version negotiation");
cmdLineOptions.addOption("A", "alpn", true, "set alpn (default is hq-xx)");
cmdLineOptions.addOption("R", "resumption key", true, "session ticket file");
cmdLineOptions.addOption("c", "connectionTimeout", true, "connection timeout in seconds");
cmdLineOptions.addOption("i", "interactive", false, "start interactive shell");
cmdLineOptions.addOption("k", "keepAlive", true, "connection keep alive time in seconds");
cmdLineOptions.addOption("L", "logFile", true, "file to write log message to");
cmdLineOptions.addOption("O", "output", true, "write server response to file");
cmdLineOptions.addOption("H", "http", true, "send HTTP GET request, arg is path, e.g. '/index.html'");
cmdLineOptions.addOption("S", "storeTickets", true, "basename of file to store new session tickets");
cmdLineOptions.addOption("T", "relativeTime", false, "log with time (in seconds) since first packet");
cmdLineOptions.addOption("Z", "use0RTT", false, "use 0-RTT if possible (requires -H)");
cmdLineOptions.addOption(null, "secrets", true, "write secrets to file (Wireshark format)");
cmdLineOptions.addOption("v", "version", false, "show Kwik version");
cmdLineOptions.addOption(null, "initialRtt", true, "custom initial RTT value (default is 500)");
cmdLineOptions.addOption(null, "chacha20", false, "use ChaCha20 as only cipher suite");
cmdLineOptions.addOption(null, "noCertificateCheck", false, "do not check server certificate");
cmdLineOptions.addOption(null, "saveServerCertificates", true, "store server certificates in given file");
cmdLineOptions.addOption(null, "quantumReadinessTest", true, "add number of random bytes to client hello");
cmdLineOptions.addOption(null, "clientCertificate", true, "certificate (file) for client authentication");
cmdLineOptions.addOption(null, "clientKey", true, "private key (file) for client certificate");
cmdLineOptions.addOption(null, "chacha20", false, "use ChaCha20 cipher suite");
cmdLineOptions.addOption(null, "aes128gcm", false, "use AEAD_AES_128_GCM cipher suite");
cmdLineOptions.addOption(null, "aes256gcm", false, "use AEAD_AES_256_GCM cipher suite");
CommandLineParser parser = new DefaultParser();
CommandLine cmd = null;
try {
cmd = parser.parse(cmdLineOptions, rawArgs);
}
catch (ParseException argError) {
System.out.println("Invalid argument: " + argError.getMessage());
usage();
System.exit(1);
}
if (cmd.hasOption("v")) {
System.out.println("Kwik build nr: " + KwikVersion.getVersion());
System.exit(0);
}
List args = cmd.getArgList();
if (args.size() == 0) {
usage();
return;
}
QuicClientConnection.Builder builder = QuicClientConnection.newBuilder();
String httpRequestPath = null;
if (args.size() == 1) {
String arg = args.get(0);
try {
if (arg.startsWith("http://") || arg.startsWith("https://")) {
try {
URL url = new URL(arg);
builder.uri(url.toURI());
if (!url.getPath().isEmpty()) {
httpRequestPath = url.getPath();
}
} catch (MalformedURLException e) {
System.out.println("Cannot parse URL '" + arg + "'");
return;
}
} else if (arg.contains(":")) {
builder.uri(new URI("//" + arg));
} else {
if (arg.matches("\\d+")) {
System.out.println("Error: invalid hostname (did you forget to specify an option argument?).");
usage();
return;
}
builder.uri(new URI("//" + arg + ":" + 443));
}
} catch (URISyntaxException invalidUri) {
System.out.println("Cannot parse URI '" + arg + "'");
return;
}
}
else if (args.size() == 2) {
try {
builder.uri(new URI("//" + args.get(0) + ":" + args.get(1)));
} catch (URISyntaxException invalidUri) {
System.out.println("Cannot parse URI '" + args.stream().collect(Collectors.joining(":")) + "'");
return;
}
}
else if (args.size() > 2) {
usage();
return;
}
processCipherArgs(cmd, builder);
if (cmd.hasOption("noCertificateCheck")) {
builder.noServerCertificateCheck();
}
String serverCertificatesFile = null;
if (cmd.hasOption("saveServerCertificates")) {
serverCertificatesFile = cmd.getOptionValue("saveServerCertificates");
}
Logger logger = null;
if (cmd.hasOption("L")) {
String logFilename = cmd.getOptionValue("L");
try {
logger = new FileLogger(new File(logFilename));
} catch (IOException fileError) {
System.err.println("Error: cannot open log file '" + logFilename + "'");
}
}
if (logger == null) {
logger = new SysOutLogger();
}
builder.logger(logger);
String logArg = DEFAULT_LOG_ARGS;
if (cmd.hasOption('l')) {
logArg = cmd.getOptionValue('l', logArg);
}
if (!logArg.contains("n")) {
if (logArg.contains("R")) {
logger.logRaw(true);
}
if (logArg.contains("r")) {
logger.logRecovery(true);
}
if (logArg.contains("c")) {
logger.logCongestionControl(true);
}
if (logArg.contains("d")) {
logger.logDecrypted(true);
}
if (logArg.contains("S")) {
logger.logSecrets(true);
}
if (logArg.contains("p")) {
logger.logPackets(true);
}
if (logArg.contains("i")) {
logger.logInfo(true);
}
if (logArg.contains("w")) {
logger.logWarning(true);
}
if (logArg.contains("s")) {
logger.logStats(true);
}
if (logArg.contains("D")) {
logger.logDebug(true);
}
}
Version quicVersion = Version.getDefault();
Version preferredVersion = null;
if (cmd.hasOption("v1v2")) {
quicVersion = Version.QUIC_version_1;
preferredVersion = Version.QUIC_version_2;
}
else if (cmd.hasOption("v2")) {
quicVersion = Version.QUIC_version_2;
}
else if (cmd.hasOption("v1")) {
quicVersion = Version.QUIC_version_1;
}
else if (cmd.hasOption("32")) {
quicVersion = Version.IETF_draft_32;
}
else if (cmd.hasOption("31")) {
quicVersion = Version.IETF_draft_31;
}
else if (cmd.hasOption("30")) {
quicVersion = Version.IETF_draft_30;
}
else if (cmd.hasOption("29")) {
quicVersion = Version.IETF_draft_29;
}
if (cmd.hasOption("reservedVersion")) {
quicVersion = Version.reserved_1;
}
builder.version(quicVersion);
builder.preferredVersion(preferredVersion);
HttpVersion httpVersion = loadHttp3ClientClass()? HttpVersion.HTTP3: HttpVersion.HTTP09;
String alpn = null;
if (cmd.hasOption("A")) {
alpn = cmd.getOptionValue("A", null);
if (alpn == null) {
usage();
System.exit(1);
}
}
else {
if (quicVersion.isV1() || quicVersion.isV2()) {
alpn = httpVersion == HttpVersion.HTTP3? "h3": "hq-interop";
}
else {
alpn = (httpVersion == HttpVersion.HTTP3? "h3-": "hq-") + quicVersion.getDraftVersion();
}
}
int connectionTimeout = 5;
if (cmd.hasOption("c")) {
try {
connectionTimeout = Integer.parseInt(cmd.getOptionValue("c", "5"));
} catch (NumberFormatException e) {
usage();
System.exit(1);
}
}
int keepAliveTime = 0;
if (cmd.hasOption("k")) {
try {
keepAliveTime = Integer.parseInt(cmd.getOptionValue("k"));
}
catch (NumberFormatException e) {
usage();
System.exit(1);
}
}
boolean useZeroRtt = false;
if (cmd.hasOption("Z")) {
useZeroRtt = true;
}
if (cmd.hasOption("H")) {
httpRequestPath = cmd.getOptionValue("H");
if (httpRequestPath == null) {
usage();
System.exit(1);
}
else {
if (! httpRequestPath.startsWith("/")) {
httpRequestPath = "/" + httpRequestPath;
}
}
}
if (useZeroRtt && httpRequestPath == null) {
usage();
System.exit(1);
}
String outputFile = null;
if (cmd.hasOption("O")) {
outputFile = cmd.getOptionValue("O");
if (outputFile == null) {
usage();
System.exit(1);
}
if (Files.exists(Paths.get(outputFile)) && !Files.isWritable(Paths.get(outputFile))) {
System.err.println("Output file '" + outputFile + "' is not writable.");
System.exit(1);
}
}
if (cmd.hasOption("secrets")) {
String secretsFile = cmd.getOptionValue("secrets");
if (secretsFile == null) {
usage();
System.exit(1);
}
if (Files.exists(Paths.get(secretsFile)) && !Files.isWritable(Paths.get(secretsFile))) {
System.err.println("Secrets file '" + secretsFile + "' is not writable.");
System.exit(1);
}
builder.secrets(Paths.get(secretsFile));
}
String newSessionTicketsFilename = null;
if (cmd.hasOption("S")) {
newSessionTicketsFilename = cmd.getOptionValue("S");
if (newSessionTicketsFilename == null) {
usage();
System.exit(1);
}
}
QuicSessionTicket sessionTicket = null;
if (cmd.hasOption("R")) {
String sessionTicketFile = null;
sessionTicketFile = cmd.getOptionValue("R");
if (sessionTicketFile == null) {
usage();
System.exit(1);
}
if (!Files.isReadable(Paths.get(sessionTicketFile))) {
System.err.println("Session ticket file '" + sessionTicketFile + "' is not readable.");
System.exit(1);
}
byte[] ticketData = new byte[0];
try {
ticketData = Files.readAllBytes(Paths.get(sessionTicketFile));
sessionTicket = QuicSessionTicket.deserialize(ticketData);
builder.sessionTicket(sessionTicket);
} catch (IOException e) {
System.err.println("Error while reading session ticket file.");
}
}
if (useZeroRtt && sessionTicket == null) {
System.err.println("Using 0-RTT requires a session ticket");
System.exit(1);
}
if (cmd.hasOption("clientCertificate") && cmd.hasOption("clientKey")) {
try {
builder.clientCertificate(readCertificate(cmd.getOptionValue("clientCertificate")));
builder.clientCertificateKey(readKey(cmd.getOptionValue("clientKey")));
}
catch (Exception e) {
System.err.println("Loading client certificate/key failed: " + e);
System.exit(1);
}
}
else if (cmd.hasOption("clientCertificate") || cmd.hasOption("clientKey")) {
System.err.println("Options --clientCertificate and --clientKey should always be used together");
System.exit(1);
}
if (cmd.hasOption("quantumReadinessTest")) {
try {
builder.quantumReadinessTest(Integer.parseInt(cmd.getOptionValue("quantumReadinessTest")));
} catch (NumberFormatException e) {
usage();
System.exit(1);
}
}
if (cmd.hasOption("T")) {
logger.useRelativeTime(true);
}
boolean interactiveMode = cmd.hasOption("i");
if (cmd.hasOption("initialRtt")) {
try {
builder.initialRtt(Integer.parseInt(cmd.getOptionValue("initialRtt")));
} catch (NumberFormatException e) {
usage();
System.exit(1);
}
}
if (httpVersion == HttpVersion.HTTP3 && useZeroRtt) {
System.out.println("0-RTT is not yet supported by this HTTP3 implementation.");
System.exit(1);
}
try {
if (interactiveMode) {
new InteractiveShell(builder, alpn, httpVersion).start();
}
else {
QuicClientConnection quicConnection = builder.build();
if (httpRequestPath != null) {
try {
HttpClient httpClient = createHttpClient(httpVersion, quicConnection, useZeroRtt);
InetSocketAddress serverAddress = quicConnection.getServerAddress();
HttpRequest request = HttpRequest.newBuilder()
.uri(new URI("https", null, serverAddress.getHostName(), serverAddress.getPort(), httpRequestPath, null, null))
.build();
Instant start, done;
long size;
String response;
if (outputFile != null) {
if (new File(outputFile).isDirectory()) {
outputFile = new File(outputFile, new File(httpRequestPath).getName()).getAbsolutePath();
}
start = Instant.now();
HttpResponse httpResponse = httpClient.send(request, HttpResponse.BodyHandlers.ofFile(Paths.get(outputFile)));
response = httpResponse.toString();
done = Instant.now();
size = Files.size(httpResponse.body());
}
else {
start = Instant.now();
HttpResponse httpResponse = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
done = Instant.now();
size = httpResponse.body().length();
response = httpResponse.toString();
// Wait a little to let logger catch up, so output is printed nicely after all the handshake logging....
try {
Thread.sleep(500);
}
catch (InterruptedException e) {}
System.out.println("Server returns: \n" + httpResponse.body());
}
Duration duration = Duration.between(start, done);
String speed = String.format("%.2f", ((float) size) / duration.toMillis() / 1000);
System.out.println(String.format("Get requested finished in %.2f sec (%s MB/s) : %s", ((float) duration.toMillis())/1000, speed, response));
}
catch (InterruptedException interruptedException) {
System.out.println("HTTP request is interrupted");
}
catch (URISyntaxException e) {
// Impossible
throw new RuntimeException();
}
}
else {
quicConnection.connect(connectionTimeout * 1000, alpn, null, null);
if (keepAliveTime > 0) {
quicConnection.keepAlive(keepAliveTime);
try {
Thread.sleep((keepAliveTime + 30) * 1000);
} catch (InterruptedException e) {}
}
}
if (serverCertificatesFile != null) {
storeServerCertificates(quicConnection, serverCertificatesFile);
}
if (newSessionTicketsFilename != null) {
storeNewSessionTickets(quicConnection, newSessionTicketsFilename);
}
quicConnection.close();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
}
System.out.println("Terminating Kwik");
}
catch (IOException e) {
System.out.println("Got IO error: " + e);
}
catch (VersionNegotiationFailure e) {
System.out.println("Client and server could not agree on a compatible QUIC version.");
}
if (!interactiveMode && httpRequestPath == null && keepAliveTime == 0) {
System.out.println("This was quick, huh? Next time, consider using --http09 or --keepAlive argument.");
}
}
private static QuicClientConnection.Builder processCipherArgs(CommandLine cmd, QuicClientConnection.Builder builder) {
List cipherOpts = List.of("aes128gcm", "aes256gcm", "chacha20");
// Process cipher options in order, as order has meaning! (preference)
List