NetServices: add testserver.py and proxy.py test infrastructure
This adds the existing testing facilities to the netservices2kit. Change-Id: Idd884a800abda967bc4b65a416a5f6d927f37450
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
#include <cppunit/TestAssert.h>
|
#include <cppunit/TestAssert.h>
|
||||||
#include <cppunit/TestCaller.h>
|
#include <cppunit/TestCaller.h>
|
||||||
#include <cppunit/TestSuite.h>
|
#include <cppunit/TestSuite.h>
|
||||||
|
#include <tools/cppunit/ThreadedTestCaller.h>
|
||||||
|
|
||||||
#include <HttpFields.h>
|
#include <HttpFields.h>
|
||||||
#include <HttpRequest.h>
|
#include <HttpRequest.h>
|
||||||
@@ -316,8 +317,82 @@ HttpProtocolTest::HttpRequestStreamTest()
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* static */ void
|
||||||
|
HttpProtocolTest::AddTests(BTestSuite& parent)
|
||||||
|
{
|
||||||
|
CppUnit::TestSuite& suite = *new CppUnit::TestSuite("HttpProtocolTest");
|
||||||
|
|
||||||
|
suite.addTest(new CppUnit::TestCaller<HttpProtocolTest>(
|
||||||
|
"HttpProtocolTest::HttpFieldsTest", &HttpProtocolTest::HttpFieldsTest));
|
||||||
|
suite.addTest(new CppUnit::TestCaller<HttpProtocolTest>(
|
||||||
|
"HttpProtocolTest::HttpMethodTest", &HttpProtocolTest::HttpMethodTest));
|
||||||
|
suite.addTest(new CppUnit::TestCaller<HttpProtocolTest>(
|
||||||
|
"HttpProtocolTest::HttpRequestTest", &HttpProtocolTest::HttpRequestTest));
|
||||||
|
suite.addTest(new CppUnit::TestCaller<HttpProtocolTest>(
|
||||||
|
"HttpProtocolTest::HttpRequestStreamTest", &HttpProtocolTest::HttpRequestStreamTest));
|
||||||
|
|
||||||
|
parent.addTest("HttpProtocolTest", &suite);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// HttpIntegrationTest
|
||||||
|
|
||||||
|
|
||||||
|
HttpIntegrationTest::HttpIntegrationTest(TestServerMode mode)
|
||||||
|
: fTestServer(mode)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
HttpProtocolTest::HttpIntegrationTest()
|
HttpIntegrationTest::setUp()
|
||||||
|
{
|
||||||
|
CPPUNIT_ASSERT_EQUAL_MESSAGE(
|
||||||
|
"Starting up test server",
|
||||||
|
B_OK,
|
||||||
|
fTestServer.Start());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* static */ void
|
||||||
|
HttpIntegrationTest::AddTests(BTestSuite& parent)
|
||||||
|
{
|
||||||
|
// Http
|
||||||
|
{
|
||||||
|
CppUnit::TestSuite& suite = *new CppUnit::TestSuite("HttpIntegrationTest");
|
||||||
|
|
||||||
|
HttpIntegrationTest* httpIntegrationTest = new HttpIntegrationTest(TestServerMode::Http);
|
||||||
|
BThreadedTestCaller<HttpIntegrationTest>* testCaller
|
||||||
|
= new BThreadedTestCaller<HttpIntegrationTest>("HttpTest::", httpIntegrationTest);
|
||||||
|
|
||||||
|
// HTTP
|
||||||
|
testCaller->addThread("HostAndNetworkFailTest", &HttpIntegrationTest::HostAndNetworkFailTest);
|
||||||
|
|
||||||
|
suite.addTest(testCaller);
|
||||||
|
parent.addTest("HttpIntegrationTest", &suite);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Https
|
||||||
|
{
|
||||||
|
CppUnit::TestSuite& suite = *new CppUnit::TestSuite("HttpsIntegrationTest");
|
||||||
|
|
||||||
|
HttpIntegrationTest* httpsIntegrationTest = new HttpIntegrationTest(TestServerMode::Https);
|
||||||
|
BThreadedTestCaller<HttpIntegrationTest>* testCaller
|
||||||
|
= new BThreadedTestCaller<HttpIntegrationTest>("HttpsTest::", httpsIntegrationTest);
|
||||||
|
|
||||||
|
// HTTP
|
||||||
|
testCaller->addThread("HostAndNetworkFailTest", &HttpIntegrationTest::HostAndNetworkFailTest);
|
||||||
|
|
||||||
|
suite.addTest(testCaller);
|
||||||
|
parent.addTest("HttpsIntegrationTest", &suite);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
HttpIntegrationTest::HostAndNetworkFailTest()
|
||||||
{
|
{
|
||||||
// Test hostname resolution fail
|
// Test hostname resolution fail
|
||||||
{
|
{
|
||||||
@@ -362,23 +437,3 @@ HttpProtocolTest::HttpIntegrationTest()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* static */ void
|
|
||||||
HttpProtocolTest::AddTests(BTestSuite& parent)
|
|
||||||
{
|
|
||||||
CppUnit::TestSuite& suite = *new CppUnit::TestSuite("HttpProtocolTest");
|
|
||||||
|
|
||||||
suite.addTest(new CppUnit::TestCaller<HttpProtocolTest>(
|
|
||||||
"HttpProtocolTest::HttpFieldsTest", &HttpProtocolTest::HttpFieldsTest));
|
|
||||||
suite.addTest(new CppUnit::TestCaller<HttpProtocolTest>(
|
|
||||||
"HttpProtocolTest::HttpMethodTest", &HttpProtocolTest::HttpMethodTest));
|
|
||||||
suite.addTest(new CppUnit::TestCaller<HttpProtocolTest>(
|
|
||||||
"HttpProtocolTest::HttpRequestTest", &HttpProtocolTest::HttpRequestTest));
|
|
||||||
suite.addTest(new CppUnit::TestCaller<HttpProtocolTest>(
|
|
||||||
"HttpProtocolTest::HttpRequestStreamTest", &HttpProtocolTest::HttpRequestStreamTest));
|
|
||||||
suite.addTest(new CppUnit::TestCaller<HttpProtocolTest>(
|
|
||||||
"HttpProtocolTest::HttpIntegrationTest", &HttpProtocolTest::HttpIntegrationTest));
|
|
||||||
|
|
||||||
parent.addTest("HttpProtocolTest", &suite);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
#include <HttpSession.h>
|
#include <HttpSession.h>
|
||||||
#include <TestCase.h>
|
#include <TestCase.h>
|
||||||
#include <TestSuite.h>
|
#include <TestSuite.h>
|
||||||
|
#include <tools/cppunit/ThreadedTestCase.h>
|
||||||
|
|
||||||
|
#include "TestServer.h"
|
||||||
|
|
||||||
using BPrivate::Network::BHttpSession;
|
using BPrivate::Network::BHttpSession;
|
||||||
|
|
||||||
@@ -21,13 +24,26 @@ public:
|
|||||||
void HttpMethodTest();
|
void HttpMethodTest();
|
||||||
void HttpRequestTest();
|
void HttpRequestTest();
|
||||||
void HttpRequestStreamTest();
|
void HttpRequestStreamTest();
|
||||||
void HttpIntegrationTest();
|
|
||||||
|
static void AddTests(BTestSuite& suite);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
class HttpIntegrationTest : public BThreadedTestCase
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
HttpIntegrationTest(TestServerMode mode);
|
||||||
|
|
||||||
|
virtual void setUp() override;
|
||||||
|
|
||||||
|
|
||||||
|
void HostAndNetworkFailTest();
|
||||||
|
|
||||||
static void AddTests(BTestSuite& suite);
|
static void AddTests(BTestSuite& suite);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
BHttpSession fSession;
|
TestServer fTestServer;
|
||||||
|
BHttpSession fSession;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -2,14 +2,13 @@ SubDir HAIKU_TOP src tests kits net netservices2 ;
|
|||||||
|
|
||||||
if $(TARGET_PACKAGING_ARCH) != x86_gcc2 {
|
if $(TARGET_PACKAGING_ARCH) != x86_gcc2 {
|
||||||
# do not target the legacy platform
|
# do not target the legacy platform
|
||||||
UsePrivateHeaders netservices2 ;
|
UsePrivateHeaders netservices2 shared ;
|
||||||
|
|
||||||
SubDirC++Flags -std=gnu++17 ;
|
|
||||||
|
|
||||||
UnitTestLib netservicekit2test.so :
|
UnitTestLib netservicekit2test.so :
|
||||||
ServicesKitTestAddon.cpp
|
ServicesKitTestAddon.cpp
|
||||||
|
|
||||||
HttpProtocolTest.cpp
|
HttpProtocolTest.cpp
|
||||||
|
TestServer.cpp
|
||||||
|
|
||||||
: be libnetservices2.a $(TARGET_NETWORK_LIBS) $(HAIKU_NETAPI_LIB)
|
: be libnetservices2.a $(TARGET_NETWORK_LIBS) $(HAIKU_NETAPI_LIB)
|
||||||
[ TargetLibstdc++ ]
|
[ TargetLibstdc++ ]
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ getTestSuite()
|
|||||||
BTestSuite* suite = new BTestSuite("NetServices2Kit");
|
BTestSuite* suite = new BTestSuite("NetServices2Kit");
|
||||||
|
|
||||||
HttpProtocolTest::AddTests(*suite);
|
HttpProtocolTest::AddTests(*suite);
|
||||||
|
HttpIntegrationTest::AddTests(*suite);
|
||||||
|
|
||||||
return suite;
|
return suite;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2020 Haiku, Inc. All rights reserved.
|
||||||
|
* Distributed under the terms of the MIT License.
|
||||||
|
*
|
||||||
|
* Authors:
|
||||||
|
* Kyle Ambroff-Kao, [email protected]
|
||||||
|
*/
|
||||||
|
#include "TestServer.h"
|
||||||
|
|
||||||
|
#include <errno.h>
|
||||||
|
#include <netinet/in.h>
|
||||||
|
#include <posix/libgen.h>
|
||||||
|
#include <sstream>
|
||||||
|
#include <string>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <sys/wait.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <AutoDeleter.h>
|
||||||
|
#include <TestShell.h>
|
||||||
|
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
template <typename T>
|
||||||
|
std::string to_string(T value)
|
||||||
|
{
|
||||||
|
std::ostringstream s;
|
||||||
|
s << value;
|
||||||
|
return s.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void exec(const std::vector<std::string>& args)
|
||||||
|
{
|
||||||
|
const char** argv = new const char*[args.size() + 1];
|
||||||
|
ArrayDeleter<const char*> _(argv);
|
||||||
|
|
||||||
|
for (size_t i = 0; i < args.size(); ++i) {
|
||||||
|
argv[i] = args[i].c_str();
|
||||||
|
}
|
||||||
|
argv[args.size()] = NULL;
|
||||||
|
|
||||||
|
execv(args[0].c_str(), const_cast<char* const*>(argv));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Return the path of a file path relative to this source file.
|
||||||
|
std::string TestFilePath(const std::string& relativePath)
|
||||||
|
{
|
||||||
|
char *testFileSource = strdup(__FILE__);
|
||||||
|
MemoryDeleter _(testFileSource);
|
||||||
|
|
||||||
|
std::string testSrcDir(::dirname(testFileSource));
|
||||||
|
|
||||||
|
return testSrcDir + "/" + relativePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
RandomTCPServerPort::RandomTCPServerPort()
|
||||||
|
:
|
||||||
|
fInitStatus(B_NOT_INITIALIZED),
|
||||||
|
fSocketFd(-1),
|
||||||
|
fServerPort(0)
|
||||||
|
{
|
||||||
|
// Create socket with port 0 to get an unused one selected by the
|
||||||
|
// kernel.
|
||||||
|
int socket_fd = ::socket(AF_INET, SOCK_STREAM, 0);
|
||||||
|
if (socket_fd == -1) {
|
||||||
|
fprintf(
|
||||||
|
stderr,
|
||||||
|
"ERROR: Unable to create socket: %s\n",
|
||||||
|
strerror(errno));
|
||||||
|
fInitStatus = B_ERROR;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fSocketFd = socket_fd;
|
||||||
|
|
||||||
|
// We may quickly reclaim the same socket between test runs, so allow
|
||||||
|
// for reuse.
|
||||||
|
{
|
||||||
|
int reuse = 1;
|
||||||
|
int result = ::setsockopt(
|
||||||
|
socket_fd,
|
||||||
|
SOL_SOCKET,
|
||||||
|
SO_REUSEPORT,
|
||||||
|
&reuse,
|
||||||
|
sizeof(reuse));
|
||||||
|
if (result == -1) {
|
||||||
|
fInitStatus = errno;
|
||||||
|
fprintf(
|
||||||
|
stderr,
|
||||||
|
"ERROR: Unable to set socket options on fd %d: %s\n",
|
||||||
|
socket_fd,
|
||||||
|
strerror(fInitStatus));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind to loopback 127.0.0.1
|
||||||
|
struct sockaddr_in server_address;
|
||||||
|
server_address.sin_family = AF_INET;
|
||||||
|
server_address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||||
|
int bind_result = ::bind(
|
||||||
|
socket_fd,
|
||||||
|
reinterpret_cast<struct sockaddr*>(&server_address),
|
||||||
|
sizeof(server_address));
|
||||||
|
if (bind_result == -1) {
|
||||||
|
fInitStatus = errno;
|
||||||
|
fprintf(
|
||||||
|
stderr,
|
||||||
|
"ERROR: Unable to bind to loopback interface: %s\n",
|
||||||
|
strerror(fInitStatus));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen is apparently required before getsockname will work.
|
||||||
|
if (::listen(socket_fd, 32) == -1) {
|
||||||
|
fInitStatus = errno;
|
||||||
|
fprintf(stderr, "ERROR: listen() failed: %s\n", strerror(fInitStatus));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now get the port from the socket.
|
||||||
|
socklen_t server_address_length = sizeof(server_address);
|
||||||
|
::getsockname(
|
||||||
|
socket_fd,
|
||||||
|
reinterpret_cast<struct sockaddr*>(&server_address),
|
||||||
|
&server_address_length);
|
||||||
|
fServerPort = ntohs(server_address.sin_port);
|
||||||
|
|
||||||
|
fInitStatus = B_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
RandomTCPServerPort::~RandomTCPServerPort()
|
||||||
|
{
|
||||||
|
if (fSocketFd != -1) {
|
||||||
|
::close(fSocketFd);
|
||||||
|
fSocketFd = -1;
|
||||||
|
fInitStatus = B_NOT_INITIALIZED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
status_t RandomTCPServerPort::InitCheck() const
|
||||||
|
{
|
||||||
|
return fInitStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
int RandomTCPServerPort::FileDescriptor() const
|
||||||
|
{
|
||||||
|
return fSocketFd;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
uint16_t RandomTCPServerPort::Port() const
|
||||||
|
{
|
||||||
|
return fServerPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ChildProcess::ChildProcess()
|
||||||
|
:
|
||||||
|
fChildPid(-1)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ChildProcess::~ChildProcess()
|
||||||
|
{
|
||||||
|
if (fChildPid != -1) {
|
||||||
|
::kill(fChildPid, SIGTERM);
|
||||||
|
|
||||||
|
pid_t result = -1;
|
||||||
|
while (result != fChildPid) {
|
||||||
|
result = ::waitpid(fChildPid, NULL, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// The job of this method is to spawn a child process that will later be killed
|
||||||
|
// by the destructor.
|
||||||
|
status_t ChildProcess::Start(const std::vector<std::string>& args)
|
||||||
|
{
|
||||||
|
if (fChildPid != -1) {
|
||||||
|
return B_ALREADY_RUNNING;
|
||||||
|
}
|
||||||
|
|
||||||
|
pid_t child = ::fork();
|
||||||
|
if (child < 0)
|
||||||
|
return B_ERROR;
|
||||||
|
|
||||||
|
if (child > 0) {
|
||||||
|
fChildPid = child;
|
||||||
|
return B_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is the child process. We can exec image provided in args.
|
||||||
|
exec(args);
|
||||||
|
|
||||||
|
// If we reach this point we failed to load the Python image.
|
||||||
|
std::ostringstream ostr;
|
||||||
|
|
||||||
|
for (std::vector<std::string>::const_iterator iter = args.begin();
|
||||||
|
iter != args.end();
|
||||||
|
++iter) {
|
||||||
|
ostr << " " << *iter;
|
||||||
|
}
|
||||||
|
|
||||||
|
fprintf(
|
||||||
|
stderr,
|
||||||
|
"Unable to spawn `%s': %s\n",
|
||||||
|
ostr.str().c_str(),
|
||||||
|
strerror(errno));
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TestServer::TestServer(TestServerMode mode)
|
||||||
|
:
|
||||||
|
fMode(mode)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Start a child testserver.py process with the random TCP port chosen by
|
||||||
|
// fPort.
|
||||||
|
status_t TestServer::Start()
|
||||||
|
{
|
||||||
|
if (fPort.InitCheck() != B_OK) {
|
||||||
|
return fPort.InitCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
auto testFilePath = TestFilePath("testserver.py");
|
||||||
|
if (::access(testFilePath.data(), R_OK) != 0) {
|
||||||
|
fprintf(
|
||||||
|
stderr,
|
||||||
|
"ERROR: No access to the test server script at: %s\n",
|
||||||
|
testFilePath.data());
|
||||||
|
return B_IO_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is the child process. We can exec the server process.
|
||||||
|
std::vector<std::string> child_process_args;
|
||||||
|
child_process_args.push_back("/bin/python3");
|
||||||
|
child_process_args.push_back(testFilePath);
|
||||||
|
child_process_args.push_back("--port");
|
||||||
|
child_process_args.push_back(to_string(fPort.Port()));
|
||||||
|
child_process_args.push_back("--fd");
|
||||||
|
child_process_args.push_back(to_string(fPort.FileDescriptor()));
|
||||||
|
|
||||||
|
if (fMode == TestServerMode::Https) {
|
||||||
|
child_process_args.push_back("--use-tls");
|
||||||
|
}
|
||||||
|
|
||||||
|
// After this the child process has started. It may take a short amount of
|
||||||
|
// time before the child process is ready to call accept(), but that's OK.
|
||||||
|
//
|
||||||
|
// Since the socket has already been created above, the tests will not
|
||||||
|
// get ECONNREFUSED and will block until the child process calls
|
||||||
|
// accept(). So we don't have to busy loop here waiting for a
|
||||||
|
// connection to the child.
|
||||||
|
return fChildProcess.Start(child_process_args);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
BUrl TestServer::BaseUrl() const
|
||||||
|
{
|
||||||
|
std::string scheme;
|
||||||
|
switch(fMode) {
|
||||||
|
case TestServerMode::Http:
|
||||||
|
scheme = "http://";
|
||||||
|
break;
|
||||||
|
|
||||||
|
case TestServerMode::Https:
|
||||||
|
scheme = "https://";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string port_string = to_string(fPort.Port());
|
||||||
|
|
||||||
|
std::string baseUrl = scheme + "127.0.0.1:" + port_string + "/";
|
||||||
|
return BUrl(baseUrl.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Start a child proxy.py process using the random TCP port chosen by fPort.
|
||||||
|
status_t TestProxyServer::Start()
|
||||||
|
{
|
||||||
|
if (fPort.InitCheck() != B_OK) {
|
||||||
|
return fPort.InitCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
auto testFilePath = TestFilePath("proxy.py");
|
||||||
|
if (::access(testFilePath.data(), R_OK) != 0) {
|
||||||
|
fprintf(
|
||||||
|
stderr,
|
||||||
|
"ERROR: No access to the test server script at: %s\n",
|
||||||
|
testFilePath.data());
|
||||||
|
return B_IO_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> child_process_args;
|
||||||
|
child_process_args.push_back("/bin/python3");
|
||||||
|
child_process_args.push_back(testFilePath);
|
||||||
|
child_process_args.push_back("--port");
|
||||||
|
child_process_args.push_back(to_string(fPort.Port()));
|
||||||
|
child_process_args.push_back("--fd");
|
||||||
|
child_process_args.push_back(to_string(fPort.FileDescriptor()));
|
||||||
|
|
||||||
|
// After this the child process has started. It may take a short amount of
|
||||||
|
// time before the child process is ready to call accept(), but that's OK.
|
||||||
|
//
|
||||||
|
// Since the socket has already been created above, the tests will not
|
||||||
|
// get ECONNREFUSED and will block until the child process calls
|
||||||
|
// accept(). So we don't have to busy loop here waiting for a
|
||||||
|
// connection to the child.
|
||||||
|
return fChildProcess.Start(child_process_args);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
uint16_t TestProxyServer::Port() const
|
||||||
|
{
|
||||||
|
return fPort.Port();
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2020 Haiku, Inc. All rights reserved.
|
||||||
|
* Distributed under the terms of the MIT License.
|
||||||
|
*
|
||||||
|
* Authors:
|
||||||
|
* Kyle Ambroff-Kao, [email protected]
|
||||||
|
*/
|
||||||
|
#ifndef TEST_SERVER_H
|
||||||
|
#define TEST_SERVER_H
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <os/support/SupportDefs.h>
|
||||||
|
#include <os/support/Url.h>
|
||||||
|
|
||||||
|
|
||||||
|
// Binds to a random unused TCP port.
|
||||||
|
class RandomTCPServerPort {
|
||||||
|
public:
|
||||||
|
RandomTCPServerPort();
|
||||||
|
~RandomTCPServerPort();
|
||||||
|
|
||||||
|
status_t InitCheck() const;
|
||||||
|
int FileDescriptor() const;
|
||||||
|
uint16_t Port() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
status_t fInitStatus;
|
||||||
|
int fSocketFd;
|
||||||
|
uint16_t fServerPort;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
class ChildProcess {
|
||||||
|
public:
|
||||||
|
ChildProcess();
|
||||||
|
~ChildProcess();
|
||||||
|
|
||||||
|
status_t Start(const std::vector<std::string>& args);
|
||||||
|
private:
|
||||||
|
pid_t fChildPid;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
enum class TestServerMode {
|
||||||
|
Http,
|
||||||
|
Https,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
class TestServer {
|
||||||
|
public:
|
||||||
|
TestServer(TestServerMode mode);
|
||||||
|
|
||||||
|
status_t Start();
|
||||||
|
BUrl BaseUrl() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
TestServerMode fMode;
|
||||||
|
ChildProcess fChildProcess;
|
||||||
|
RandomTCPServerPort fPort;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
class TestProxyServer {
|
||||||
|
public:
|
||||||
|
status_t Start();
|
||||||
|
uint16_t Port() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
ChildProcess fChildProcess;
|
||||||
|
RandomTCPServerPort fPort;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
#endif // TEST_SERVER_H
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
#
|
||||||
|
# Copyright 2020 Haiku, Inc. All rights reserved.
|
||||||
|
# Distributed under the terms of the MIT License.
|
||||||
|
#
|
||||||
|
# Authors:
|
||||||
|
# Kyle Ambroff-Kao, [email protected]
|
||||||
|
#
|
||||||
|
|
||||||
|
"""
|
||||||
|
Transparent HTTP proxy.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import http.client
|
||||||
|
import http.server
|
||||||
|
import optparse
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
|
||||||
|
class RequestHandler(http.server.BaseHTTPRequestHandler):
|
||||||
|
"""
|
||||||
|
Implement the basic requirements for a transparent HTTP proxy as defined
|
||||||
|
by RFC 7230. Enough of the functionality is implemented to support the
|
||||||
|
integration tests in HttpTest that use the HTTP proxy feature.
|
||||||
|
|
||||||
|
There are many error conditions and failure modes which are not handled.
|
||||||
|
Those cases can be added as the test suite expands to handle more error
|
||||||
|
cases.
|
||||||
|
"""
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
# This is used to hold on to persistent connections to the downstream
|
||||||
|
# servers. This maps downstream_host:port => HTTPConnection
|
||||||
|
#
|
||||||
|
# This implementation is not thread safe, but that's OK we only have
|
||||||
|
# a single thread anyway.
|
||||||
|
self._connections = {}
|
||||||
|
|
||||||
|
super(RequestHandler, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def _proxy_request(self):
|
||||||
|
# Extract the downstream server from the request path.
|
||||||
|
#
|
||||||
|
# Note that no attempt is made to prevent message forwarding loops
|
||||||
|
# here. This doesn't need to be a complete proxy implementation, just
|
||||||
|
# enough of one for integration tests. RFC 7230 section 5.7 says if
|
||||||
|
# this were a complete implementation, it would have to make sure that
|
||||||
|
# the target system was not this process to avoid a loop.
|
||||||
|
target = urllib.parse.urlparse(self.path)
|
||||||
|
|
||||||
|
# If Connection: close wasn't used, then we may still have a connection
|
||||||
|
# to this downstream server handy.
|
||||||
|
conn = self._connections.get(target.netloc, None)
|
||||||
|
if conn is None:
|
||||||
|
conn = http.client.HTTPConnection(target.netloc)
|
||||||
|
|
||||||
|
# Collect headers from client which will be sent to the downstream
|
||||||
|
# server.
|
||||||
|
client_headers = {}
|
||||||
|
for header_name in self.headers:
|
||||||
|
if header_name in ('Host', 'Content-Length'):
|
||||||
|
continue
|
||||||
|
for header_value in self.headers.get_all(header_name):
|
||||||
|
client_headers[header_name] = header_value
|
||||||
|
|
||||||
|
# Compute X-Forwarded-For header
|
||||||
|
client_address = '{}:{}'.format(*self.client_address)
|
||||||
|
x_forwarded_for_header = self.headers.get('X-Forwarded-For', None)
|
||||||
|
if x_forwarded_for_header is None:
|
||||||
|
client_headers['X-Forwarded-For'] = client_address
|
||||||
|
else:
|
||||||
|
client_headers['X-Forwarded-For'] = \
|
||||||
|
x_forwarded_for_header + ', ' + client_address
|
||||||
|
|
||||||
|
# Read the request body from client.
|
||||||
|
request_body_length = int(self.headers.get('Content-Length', '0'))
|
||||||
|
request_body = self.rfile.read(request_body_length)
|
||||||
|
|
||||||
|
# Send the request to the downstream server
|
||||||
|
if target.query:
|
||||||
|
target_path = target.path + '?' + target.query
|
||||||
|
else:
|
||||||
|
target_path = target.path
|
||||||
|
conn.request(self.command, target_path, request_body, client_headers)
|
||||||
|
response = conn.getresponse()
|
||||||
|
|
||||||
|
# Echo the response to the client.
|
||||||
|
self.send_response_only(response.status, response.reason)
|
||||||
|
for header_name, header_value in response.headers.items():
|
||||||
|
self.send_header(header_name, header_value)
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
# Read the response body from upstream and write it to downstream, if
|
||||||
|
# there is a response body at all.
|
||||||
|
response_content_length = \
|
||||||
|
int(response.headers.get('Content-Length', '0'))
|
||||||
|
if response_content_length > 0:
|
||||||
|
self.wfile.write(response.read(response_content_length))
|
||||||
|
|
||||||
|
# Cleanup, possibly hang on to persistent connection to target
|
||||||
|
# server.
|
||||||
|
connection_header_value = self.headers.get('Connection', None)
|
||||||
|
if response.will_close or connection_header_value == 'close':
|
||||||
|
conn.close()
|
||||||
|
self.close_connection = True
|
||||||
|
else:
|
||||||
|
# Hang on to this connection for future requests. This isn't
|
||||||
|
# really bulletproof but it's good enough for integration tests.
|
||||||
|
self._connections[target.netloc] = conn
|
||||||
|
|
||||||
|
self.log_message(
|
||||||
|
'Proxied request from %s to %s',
|
||||||
|
client_address,
|
||||||
|
self.path)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
self._proxy_request()
|
||||||
|
|
||||||
|
def do_HEAD(self):
|
||||||
|
self._proxy_request()
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
self._proxy_request()
|
||||||
|
|
||||||
|
def do_PUT(self):
|
||||||
|
self._proxy_request()
|
||||||
|
|
||||||
|
def do_DELETE(self):
|
||||||
|
self._proxy_request()
|
||||||
|
|
||||||
|
def do_PATCH(self):
|
||||||
|
self._proxy_request()
|
||||||
|
|
||||||
|
def do_OPTIONS(self):
|
||||||
|
self._proxy_request()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
options = parse_args(sys.argv)
|
||||||
|
|
||||||
|
bind_addr = (
|
||||||
|
options.bind_addr,
|
||||||
|
0 if options.port is None else options.port)
|
||||||
|
|
||||||
|
server = http.server.HTTPServer(
|
||||||
|
bind_addr,
|
||||||
|
RequestHandler,
|
||||||
|
bind_and_activate=False)
|
||||||
|
if options.port is None:
|
||||||
|
server.server_port = server.socket.getsockname()[1]
|
||||||
|
else:
|
||||||
|
server.server_port = options.port
|
||||||
|
|
||||||
|
if options.server_socket_fd:
|
||||||
|
server.socket = socket.fromfd(
|
||||||
|
options.server_socket_fd,
|
||||||
|
socket.AF_INET,
|
||||||
|
socket.SOCK_STREAM)
|
||||||
|
else:
|
||||||
|
server.server_bind()
|
||||||
|
server.server_activate()
|
||||||
|
|
||||||
|
print(
|
||||||
|
'Transparent HTTP proxy listening on port',
|
||||||
|
server.server_port,
|
||||||
|
file=sys.stderr)
|
||||||
|
try:
|
||||||
|
server.serve_forever(0.01)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv):
|
||||||
|
parser = optparse.OptionParser(
|
||||||
|
usage='Usage: %prog [OPTIONS]',
|
||||||
|
description=__doc__)
|
||||||
|
parser.add_option(
|
||||||
|
'--bind-addr',
|
||||||
|
default='127.0.0.1',
|
||||||
|
dest='bind_addr',
|
||||||
|
help='By default only bind to loopback')
|
||||||
|
parser.add_option(
|
||||||
|
'--port',
|
||||||
|
dest='port',
|
||||||
|
default=None,
|
||||||
|
type='int',
|
||||||
|
help='If not specified a random port will be used.')
|
||||||
|
parser.add_option(
|
||||||
|
"--fd",
|
||||||
|
dest='server_socket_fd',
|
||||||
|
default=None,
|
||||||
|
type='int',
|
||||||
|
help='A socket FD to use for accept() instead of binding a new one.')
|
||||||
|
options, args = parser.parse_args(argv)
|
||||||
|
if len(args) > 1:
|
||||||
|
parser.error('Unexpected arguments: {}'.format(', '.join(args[1:])))
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,541 @@
|
|||||||
|
#
|
||||||
|
# Copyright 2020 Haiku, Inc. All rights reserved.
|
||||||
|
# Distributed under the terms of the MIT License.
|
||||||
|
#
|
||||||
|
# Authors:
|
||||||
|
# Kyle Ambroff-Kao, [email protected]
|
||||||
|
#
|
||||||
|
|
||||||
|
"""
|
||||||
|
HTTP(S) server used for integration testing of ServicesKit.
|
||||||
|
|
||||||
|
This service receives HTTP requests and just echos them back in the response.
|
||||||
|
|
||||||
|
This is intentionally not using any fancy frameworks or libraries so as to not
|
||||||
|
require any dependencies, and also to allow for adding endpoints to replicate
|
||||||
|
behavior of other servers in the future.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import abc
|
||||||
|
import base64
|
||||||
|
import gzip
|
||||||
|
import hashlib
|
||||||
|
import http.server
|
||||||
|
import io
|
||||||
|
import optparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import zlib
|
||||||
|
|
||||||
|
|
||||||
|
MULTIPART_FORM_BOUNDARY_RE = re.compile(
|
||||||
|
r'^multipart/form-data; boundary=(----------------------------\d+)$')
|
||||||
|
AUTH_PATH_RE = re.compile(
|
||||||
|
r'^/auth/(?P<strategy>(basic|digest))'
|
||||||
|
'/(?P<username>[a-z0-9]+)/(?P<password>[a-z0-9]+)',
|
||||||
|
re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
class RequestHandler(http.server.BaseHTTPRequestHandler):
|
||||||
|
"""
|
||||||
|
Any GET or POST request just gets echoed back to the sender. If the path
|
||||||
|
ends with a numeric component like "/404" or "/500", then that value will
|
||||||
|
be set as the status code in the response.
|
||||||
|
|
||||||
|
Note that this isn't meant to replicate expected functionality exactly.
|
||||||
|
Rather than implementing all of these status codes as expected per RFC,
|
||||||
|
such as having an empty response body for 201 response, only the
|
||||||
|
functionality that is required to handle requests from HttpTests is
|
||||||
|
implemented.
|
||||||
|
|
||||||
|
There can also be endpoints here that are intentionally non-compliant in
|
||||||
|
order to exercize the HTTP client's behavior when a server is badly
|
||||||
|
behaved.
|
||||||
|
"""
|
||||||
|
def do_GET(self, write_response=True):
|
||||||
|
authorized, extra_headers = self._authorize()
|
||||||
|
if not authorized:
|
||||||
|
return
|
||||||
|
|
||||||
|
encoding, response_body = self._build_response_body()
|
||||||
|
|
||||||
|
status_code = extract_desired_status_code_from_path(self.path, 200)
|
||||||
|
self.send_response(status_code)
|
||||||
|
if status_code >= 300 and status_code < 400:
|
||||||
|
self.send_header('Location', '/')
|
||||||
|
|
||||||
|
if status_code == 204:
|
||||||
|
write_response = False
|
||||||
|
else:
|
||||||
|
self.send_header('Content-Type', 'text/plain')
|
||||||
|
self.send_header('Content-Length', str(len(response_body)))
|
||||||
|
if encoding:
|
||||||
|
self.send_header('Content-Encoding', encoding)
|
||||||
|
|
||||||
|
for header_name, header_value in extra_headers:
|
||||||
|
self.send_header(header_name, header_value)
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
if write_response:
|
||||||
|
self.wfile.write(response_body)
|
||||||
|
|
||||||
|
def do_HEAD(self):
|
||||||
|
self.do_GET(False)
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
authorized, extra_headers = self._authorize()
|
||||||
|
if not authorized:
|
||||||
|
return
|
||||||
|
|
||||||
|
encoding, response_body = self._build_response_body()
|
||||||
|
self.send_response(
|
||||||
|
extract_desired_status_code_from_path(self.path, 200))
|
||||||
|
self.send_header('Content-Type', 'text/plain')
|
||||||
|
self.send_header('Content-Length', str(len(response_body)))
|
||||||
|
if encoding:
|
||||||
|
self.send_header('Content-Encoding', encoding)
|
||||||
|
for header_name, header_value in extra_headers:
|
||||||
|
self.send_header(header_name, header_value)
|
||||||
|
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(response_body)
|
||||||
|
|
||||||
|
def do_DELETE(self):
|
||||||
|
self._not_supported()
|
||||||
|
|
||||||
|
def do_PATCH(self):
|
||||||
|
self._not_supported()
|
||||||
|
|
||||||
|
def do_OPTIONS(self):
|
||||||
|
self._not_supported()
|
||||||
|
|
||||||
|
def send_response(self, code, message=None):
|
||||||
|
self.log_request(code)
|
||||||
|
self.send_response_only(code, message)
|
||||||
|
self.send_header('Server', 'Test HTTP Server for Haiku')
|
||||||
|
self.send_header('Date', 'Sun, 09 Feb 2020 19:32:42 GMT')
|
||||||
|
|
||||||
|
def _build_response_body(self):
|
||||||
|
# The post-body may be multi-part/form-data, in which case the client
|
||||||
|
# will have generated some random identifier to identify the boundary.
|
||||||
|
# If that's the case, we'll replace it here in order to allow the test
|
||||||
|
# client to validate the response data without needing to predict the
|
||||||
|
# boundary identifier. This makes the response body deterministic even
|
||||||
|
# though the boundary will change with every request, and lets the
|
||||||
|
# tests in HttpTests hard-code the entire expected response body for
|
||||||
|
# validation.
|
||||||
|
boundary_id_value = None
|
||||||
|
|
||||||
|
supported_encodings = [
|
||||||
|
e.strip()
|
||||||
|
for e in self.headers.get('Accept-Encoding', '').split(',')
|
||||||
|
if e.strip()]
|
||||||
|
if 'gzip' in supported_encodings:
|
||||||
|
encoding = 'gzip'
|
||||||
|
output_stream = GzipResponseBodyBuilder()
|
||||||
|
elif 'deflate' in supported_encodings:
|
||||||
|
encoding = 'deflate'
|
||||||
|
output_stream = DeflateResponseBodyBuilder()
|
||||||
|
else:
|
||||||
|
encoding = None
|
||||||
|
output_stream = RawResponseBodyBuilder()
|
||||||
|
|
||||||
|
output_stream.write(
|
||||||
|
'Path: {}\r\n\r\n'.format(self.path).encode('utf-8'))
|
||||||
|
output_stream.write(b'Headers:\r\n')
|
||||||
|
output_stream.write(b'--------\r\n')
|
||||||
|
for header in self.headers:
|
||||||
|
for header_value in self.headers.get_all(header):
|
||||||
|
if header in ('Host', 'Referer', 'X-Forwarded-For'):
|
||||||
|
# The server port can change between runs which will change
|
||||||
|
# the size and contents of the response body. To make tests
|
||||||
|
# that verify the contents of the response body easier the
|
||||||
|
# server port will be stripped from these headers when
|
||||||
|
# echoed to the response body.
|
||||||
|
header_value = re.sub(r':[0-9]+', ':PORT', header_value)
|
||||||
|
|
||||||
|
# The scheme will also be in this header value, and we want
|
||||||
|
# to return the same reguardless of whether http:// or
|
||||||
|
# https:// was used.
|
||||||
|
header_value = re.sub(
|
||||||
|
r'https?://',
|
||||||
|
'SCHEME://',
|
||||||
|
header_value)
|
||||||
|
if header == 'Content-Type':
|
||||||
|
match = MULTIPART_FORM_BOUNDARY_RE.match(
|
||||||
|
self.headers.get('Content-Type', 'text/plain'))
|
||||||
|
if match is not None:
|
||||||
|
boundary_id_value = match.group(1)
|
||||||
|
header_value = header_value.replace(
|
||||||
|
boundary_id_value,
|
||||||
|
'<<BOUNDARY-ID>>')
|
||||||
|
output_stream.write(
|
||||||
|
'{}: {}\r\n'.format(header, header_value).encode('utf-8'))
|
||||||
|
|
||||||
|
content_length = int(self.headers.get('Content-Length', 0))
|
||||||
|
if content_length > 0:
|
||||||
|
output_stream.write(b'\r\n')
|
||||||
|
output_stream.write(b'Request body:\r\n')
|
||||||
|
output_stream.write(b'-------------\r\n')
|
||||||
|
|
||||||
|
body_bytes = self.rfile.read(content_length).decode('utf-8')
|
||||||
|
if boundary_id_value:
|
||||||
|
body_bytes = body_bytes.replace(
|
||||||
|
boundary_id_value, '<<BOUNDARY-ID>>')
|
||||||
|
|
||||||
|
output_stream.write(body_bytes.encode('utf-8'))
|
||||||
|
output_stream.write(b'\r\n')
|
||||||
|
|
||||||
|
return encoding, output_stream.get_bytes()
|
||||||
|
|
||||||
|
def _not_supported(self):
|
||||||
|
self.send_response(405, '{} not supported'.format(self.command))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(
|
||||||
|
'{} not supported\r\n'.format(self.command).encode('utf-8'))
|
||||||
|
|
||||||
|
def _authorize(self):
|
||||||
|
"""
|
||||||
|
Authorizes the request. If True is returned that means that the
|
||||||
|
request was not authorized and the 4xx response has been send to the
|
||||||
|
client.
|
||||||
|
"""
|
||||||
|
# We only authorize paths like
|
||||||
|
# /auth/<strategy>/<expected-username>/<expected-password>
|
||||||
|
match = AUTH_PATH_RE.match(self.path)
|
||||||
|
if match is None:
|
||||||
|
return True, []
|
||||||
|
|
||||||
|
strategy = match.group('strategy')
|
||||||
|
expected_username = match.group('username')
|
||||||
|
expected_password = match.group('password')
|
||||||
|
|
||||||
|
if strategy == 'basic':
|
||||||
|
return self._handle_basic_auth(
|
||||||
|
expected_username,
|
||||||
|
expected_password)
|
||||||
|
elif strategy == 'digest':
|
||||||
|
return self._handle_digest_auth(
|
||||||
|
expected_username,
|
||||||
|
expected_password)
|
||||||
|
else:
|
||||||
|
raise NotImplementedError(
|
||||||
|
'Unimplemented authorization strategy ' + strategy)
|
||||||
|
|
||||||
|
def _handle_basic_auth(self, expected_username, expected_password):
|
||||||
|
authorization = self.headers.get('Authorization', None)
|
||||||
|
auth_type = None
|
||||||
|
encoded_credentials = None
|
||||||
|
username = None
|
||||||
|
password = None
|
||||||
|
|
||||||
|
if authorization:
|
||||||
|
auth_type, encoded_credentials = authorization.split()
|
||||||
|
|
||||||
|
if encoded_credentials is not None:
|
||||||
|
decoded = base64.decodebytes(encoded_credentials.encode('utf-8'))
|
||||||
|
username, password = decoded.decode('utf-8').split(':')
|
||||||
|
|
||||||
|
if authorization is None or auth_type != 'Basic' \
|
||||||
|
or encoded_credentials is None \
|
||||||
|
or username != expected_username \
|
||||||
|
or password != expected_password:
|
||||||
|
self.send_response(401, 'Not authorized')
|
||||||
|
self.send_header('Www-Authenticate', 'Basic realm="Fake Realm"')
|
||||||
|
self.end_headers()
|
||||||
|
return False, []
|
||||||
|
|
||||||
|
return True, [('Www-Authenticate', 'Basic realm="Fake Realm"')]
|
||||||
|
|
||||||
|
def _handle_digest_auth(self, expected_username, expected_password):
|
||||||
|
"""
|
||||||
|
Implement enough of the digest auth RFC to make tests pass.
|
||||||
|
"""
|
||||||
|
# Note: These values will always be the same because we want the
|
||||||
|
# response to be deterministic for testing purposes.
|
||||||
|
NONCE = 'f3a95f20879dd891a5544bf96a3e5518'
|
||||||
|
OPAQUE = 'f0bb55f1221a51b6d38117c331611799'
|
||||||
|
|
||||||
|
extra_headers = []
|
||||||
|
authorization = self.headers.get('Authorization', None)
|
||||||
|
credentials = None
|
||||||
|
auth_type = None
|
||||||
|
if authorization is not None:
|
||||||
|
auth_type, fields = authorization.split(maxsplit=1)
|
||||||
|
if auth_type == 'Digest':
|
||||||
|
credentials = parse_kv_pair_header(fields)
|
||||||
|
|
||||||
|
expected_response_hash = None
|
||||||
|
if credentials:
|
||||||
|
expected_response_hash = compute_digest_challenge_response_hash(
|
||||||
|
self.command,
|
||||||
|
self.path,
|
||||||
|
'',
|
||||||
|
credentials,
|
||||||
|
expected_password)
|
||||||
|
|
||||||
|
if authorization is None or credentials is None \
|
||||||
|
or auth_type != 'Digest' \
|
||||||
|
or expected_response_hash != credentials.get('response'):
|
||||||
|
self.send_response(401, 'Not authorized')
|
||||||
|
self.send_header(
|
||||||
|
'Www-Authenticate',
|
||||||
|
'Digest realm="user@shredder",'
|
||||||
|
' nonce="{}",'
|
||||||
|
' qop="auth",'
|
||||||
|
' opaque={},'
|
||||||
|
' algorithm=MD5,'
|
||||||
|
' stale=FALSE'.format(NONCE, OPAQUE))
|
||||||
|
self.send_header('Set-Cookie', 'stale_after=never; Path=/')
|
||||||
|
self.send_header('Set-Cookie', 'fake=fake_value; Path=/')
|
||||||
|
self.end_headers()
|
||||||
|
return False, extra_headers
|
||||||
|
|
||||||
|
return True, extra_headers
|
||||||
|
|
||||||
|
|
||||||
|
class ResponseBodyBuilder(object):
|
||||||
|
__meta__ = abc.ABCMeta
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def write(self, bytes):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def get_bytes(self):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
|
||||||
|
class RawResponseBodyBuilder(ResponseBodyBuilder):
|
||||||
|
def __init__(self):
|
||||||
|
self.buf = io.BytesIO()
|
||||||
|
|
||||||
|
def write(self, bytes):
|
||||||
|
self.buf.write(bytes)
|
||||||
|
|
||||||
|
def get_bytes(self):
|
||||||
|
return self.buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
class GzipResponseBodyBuilder(ResponseBodyBuilder):
|
||||||
|
def __init__(self):
|
||||||
|
self.buf = io.BytesIO()
|
||||||
|
self.compressor = gzip.GzipFile(
|
||||||
|
mode='wb',
|
||||||
|
compresslevel=4,
|
||||||
|
fileobj=self.buf)
|
||||||
|
|
||||||
|
def write(self, bytes):
|
||||||
|
self.compressor.write(bytes)
|
||||||
|
|
||||||
|
def get_bytes(self):
|
||||||
|
self.compressor.close()
|
||||||
|
return self.buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
class DeflateResponseBodyBuilder(ResponseBodyBuilder):
|
||||||
|
def __init__(self):
|
||||||
|
self.raw = RawResponseBodyBuilder()
|
||||||
|
|
||||||
|
def write(self, bytes):
|
||||||
|
self.raw.write(bytes)
|
||||||
|
|
||||||
|
def get_bytes(self):
|
||||||
|
return zlib.compress(self.raw.get_bytes())
|
||||||
|
|
||||||
|
|
||||||
|
def extract_desired_status_code_from_path(path, default=200):
|
||||||
|
status_code = default
|
||||||
|
path_parts = os.path.split(path)
|
||||||
|
try:
|
||||||
|
status_code = int(path_parts[-1])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return status_code
|
||||||
|
|
||||||
|
|
||||||
|
def generate_self_signed_tls_cert(common_name, cert_path, key_path):
|
||||||
|
subprocess.check_call([
|
||||||
|
'openssl',
|
||||||
|
'req',
|
||||||
|
'-x509',
|
||||||
|
'-nodes',
|
||||||
|
'-subj', '/CN={}'.format(common_name),
|
||||||
|
'-newkey', 'rsa:4096',
|
||||||
|
'-keyout', key_path,
|
||||||
|
'-out', cert_path,
|
||||||
|
'-days', '1'
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def compute_digest_challenge_response_hash(
|
||||||
|
request_method,
|
||||||
|
request_uri,
|
||||||
|
request_body,
|
||||||
|
credentials,
|
||||||
|
expected_password):
|
||||||
|
"""
|
||||||
|
Compute hash as defined by RFC2069, although this isn't an attempt to be
|
||||||
|
perfect, just enough for basic integration tests in HttpTests to work.
|
||||||
|
|
||||||
|
:param credentials: Map of values parsed from the Authorization header
|
||||||
|
from the client.
|
||||||
|
:param expected_password: The known correct password of the user
|
||||||
|
attempting to authenticate.
|
||||||
|
:return: None if a hash cannot be produced, otherwise the hash as defined
|
||||||
|
by RFC2069.
|
||||||
|
"""
|
||||||
|
algorithm = credentials.get('algorithm')
|
||||||
|
if algorithm == 'MD5':
|
||||||
|
hashfunc = hashlib.md5
|
||||||
|
elif algorithm == 'SHA-256':
|
||||||
|
hashfunc = hashlib.sha256
|
||||||
|
elif algorithm == 'SHA-512':
|
||||||
|
hashfunc = hashlib.sha512
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
realm = credentials.get('realm')
|
||||||
|
username = credentials.get('username')
|
||||||
|
|
||||||
|
ha1 = hashfunc(':'.join([
|
||||||
|
username,
|
||||||
|
realm,
|
||||||
|
expected_password]).encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
|
qop = credentials.get('qop')
|
||||||
|
if qop is None or qop == 'auth':
|
||||||
|
ha2 = hashfunc(':'.join([
|
||||||
|
request_method,
|
||||||
|
request_uri]).encode('utf-8')).hexdigest()
|
||||||
|
elif qop == 'auth-int':
|
||||||
|
ha2 = hashfunc(':'.join([
|
||||||
|
request_method,
|
||||||
|
request_uri,
|
||||||
|
request_body]).encode('utf-8')).hexdigest()
|
||||||
|
else:
|
||||||
|
ha2 = None
|
||||||
|
|
||||||
|
if ha1 is None or ha2 is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if qop is None:
|
||||||
|
return hashfunc(':'.join([
|
||||||
|
ha1,
|
||||||
|
credentials.get('nonce', ''),
|
||||||
|
ha2]).encode('utf-8')).hexdigest()
|
||||||
|
elif qop == 'auth' or qop == 'auth-int':
|
||||||
|
hash_components = [
|
||||||
|
ha1,
|
||||||
|
credentials.get('nonce', ''),
|
||||||
|
credentials.get('nc', ''),
|
||||||
|
credentials.get('cnonce', ''),
|
||||||
|
qop,
|
||||||
|
ha2]
|
||||||
|
return hashfunc(':'.join(hash_components).encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_kv_pair_header(header_value, sep=','):
|
||||||
|
d = {}
|
||||||
|
for kvpair in header_value.split(sep):
|
||||||
|
key, value = kvpair.strip().split('=')
|
||||||
|
d[key.strip()] = value.strip().strip('"')
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
options = parse_args(sys.argv)
|
||||||
|
|
||||||
|
bind_addr = (
|
||||||
|
options.bind_addr,
|
||||||
|
0 if options.port is None else options.port)
|
||||||
|
|
||||||
|
server = http.server.HTTPServer(
|
||||||
|
bind_addr,
|
||||||
|
RequestHandler,
|
||||||
|
bind_and_activate=False)
|
||||||
|
if options.port is None:
|
||||||
|
server.server_port = server.socket.getsockname()[1]
|
||||||
|
else:
|
||||||
|
server.server_port = options.port
|
||||||
|
|
||||||
|
if options.server_socket_fd:
|
||||||
|
server.socket = socket.fromfd(
|
||||||
|
options.server_socket_fd,
|
||||||
|
socket.AF_INET,
|
||||||
|
socket.SOCK_STREAM)
|
||||||
|
|
||||||
|
def run_server():
|
||||||
|
if not options.server_socket_fd:
|
||||||
|
server.server_bind()
|
||||||
|
server.server_activate()
|
||||||
|
print(
|
||||||
|
'Test server listening on port',
|
||||||
|
server.server_port,
|
||||||
|
file=sys.stderr)
|
||||||
|
server.serve_forever(0.01)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if options.use_tls:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_cert_dir:
|
||||||
|
common_name = options.bind_addr + ':' + str(options.port)
|
||||||
|
cert_file = os.path.join(temp_cert_dir, 'cert.pem')
|
||||||
|
key_file = os.path.join(temp_cert_dir, 'key.pem')
|
||||||
|
generate_self_signed_tls_cert(
|
||||||
|
common_name,
|
||||||
|
cert_file,
|
||||||
|
key_file)
|
||||||
|
server.socket = ssl.wrap_socket(
|
||||||
|
server.socket,
|
||||||
|
certfile=cert_file,
|
||||||
|
keyfile=key_file,
|
||||||
|
server_side=True,
|
||||||
|
do_handshake_on_connect=False)
|
||||||
|
run_server()
|
||||||
|
else:
|
||||||
|
run_server()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv):
|
||||||
|
parser = optparse.OptionParser(
|
||||||
|
usage='Usage: %prog [OPTIONS]',
|
||||||
|
description=__doc__)
|
||||||
|
parser.add_option(
|
||||||
|
'--bind-addr',
|
||||||
|
default='127.0.0.1',
|
||||||
|
dest='bind_addr',
|
||||||
|
help='By default only bind to loopback')
|
||||||
|
parser.add_option(
|
||||||
|
'--use-tls',
|
||||||
|
dest='use_tls',
|
||||||
|
default=False,
|
||||||
|
action='store_true',
|
||||||
|
help='If set, a self-signed TLS certificate, key and CA will be'
|
||||||
|
' generated for testing purposes.')
|
||||||
|
parser.add_option(
|
||||||
|
'--port',
|
||||||
|
dest='port',
|
||||||
|
default=None,
|
||||||
|
type='int',
|
||||||
|
help='If not specified a random port will be used.')
|
||||||
|
parser.add_option(
|
||||||
|
"--fd",
|
||||||
|
dest='server_socket_fd',
|
||||||
|
default=None,
|
||||||
|
type='int',
|
||||||
|
help='A socket FD to use for accept() instead of binding a new one.')
|
||||||
|
options, args = parser.parse_args(argv)
|
||||||
|
if len(args) > 1:
|
||||||
|
parser.error('Unexpected arguments: {}'.format(', '.join(args[1:])))
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user