diff --git a/.github/workflows/docker-build-and-push.yml b/.github/workflows/docker-build-and-push.yml index 321f0ec..18790c7 100644 --- a/.github/workflows/docker-build-and-push.yml +++ b/.github/workflows/docker-build-and-push.yml @@ -6,30 +6,52 @@ on: - '**' paths: - 'src/**' - - '.github/**' + - '.github/workflows/**' - 'requirements.txt' - 'entrypoint.sh' - 'Dockerfile' + - 'VERSION.txt' jobs: docker: runs-on: ubuntu-latest steps: - - - name: Set up QEMU + - name: Replace slashes in branch name + run: | + SAFE_BRANCH_NAME="${GITHUB_REF_NAME//\//-}" + echo "SAFE_BRANCH_NAME=$SAFE_BRANCH_NAME" >> $GITHUB_ENV + env: + GITHUB_REF_NAME: ${{ github.ref_name }} + + - name: Set up QEMU uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx + + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - - name: Login to Docker Hub + + - name: Set Docker tags and read version from VERSION.txt (only on main branch) + run: | + TAGS="erikmagkekse/ziti-edge-proxy:${{ env.SAFE_BRANCH_NAME }}" + + if [ "${GITHUB_REF_NAME}" == "main" ]; then + if [ -f VERSION.txt ]; then + VERSION=$(cat VERSION.txt) + TAGS="${TAGS},erikmagkekse/ziti-edge-proxy:${VERSION},erikmagkekse/ziti-edge-proxy:latest" + else + TAGS="${TAGS},erikmagkekse/ziti-edge-proxy:latest" + fi + fi + + echo "TAGS=$TAGS" >> $GITHUB_ENV + + - name: Login to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Build and push + + - name: Build and push uses: docker/build-push-action@v6 with: push: true - tags: erikmagkekse/ziti-edge-proxy:${{ github.ref_name }} \ No newline at end of file + tags: ${{ env.TAGS }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1940de3 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +identity.json \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index c26fa25..37d9cb9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,8 +8,12 @@ ENV GID=23456 ENV USER_HOME=/app ENV VIRTUAL_ENV=$USER_HOME/.venv +ENV SOCKS_ENABLED=TRUE +ENV HTTP_ENABLED=TRUE + ENV PROXY_HOST=127.0.0.1 -ENV PROXY_PORT=1080 +ENV SOCKS_PORT=1080 +ENV HTTP_PORT=1080 ENV PROXY_USERNAME=user ENV PROXY_PASSWORD=password @@ -52,5 +56,6 @@ RUN pip3 install --no-cache -r requirements.txt # Start Python script, entrypoint and configure port EXPOSE 1080 +EXPOSE 8080 ENTRYPOINT ["/app/entrypoint.sh"] CMD [ "python", "main.py" ] diff --git a/README.md b/README.md index 153908b..abf4be5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # What is ziti-edge-proxy? -This project uses OpenZiti to provide a SOCKS5 Proxy with simple authentication that tunnels intercepted traffic through OpenZiti. +This project uses OpenZiti to provides a SOCKS5 & HTTP Proxy with simple authentication that tunnels intercepted traffic through OpenZiti. The goal for this project was to make it fully functional in UserSpace, so that it can also be used in pipelines without privileges, for example in GitOps processes. ## Who is it for? @@ -24,7 +24,10 @@ docker pull docker.io/erikmagkekse/ziti-edge-proxy:main | Variable | Default Value | Usage | | ---------------- | ----------------- | ----------------------------------------------------------- | | PROXY_HOST | 127.0.0.1 | Where the SOCKS5 server should be attached | -| PROXY_PORT | 1080 | Default port of the SOCKS5 server | +| SOCKS_ENABLED | true | Enables SOCKS5 Server | +| HTTP_ENABLED | true | Enables HTTP Server | +| SOCKS_PORT | 1080 | Default port of the SOCKS5 server | +| HTTP_PORT | 8080 | Default port of the HTTP proxy server | | PROXY_USERNAME | user | Username for the SOCKS5 server | | PROXY_PASSWORD | password | Password for the SOCKS5 Server | | *ZITI_IDENTITIES | *empty* | List of used Ziti identities, separated by semicolon | @@ -34,10 +37,10 @@ docker pull docker.io/erikmagkekse/ziti-edge-proxy:main ## Future roadmap - Add Codesinging -- Improving logging +- Improving logging ✅ - Add ghcr.io repository for image - Switch from Python image to Alpine or RedHat UBI -- Add HTTP Proxy support +- Add HTTP Proxy support ✅ - Rewrite in Go - CI Tests diff --git a/VERSION.txt b/VERSION.txt new file mode 100644 index 0000000..39e0f45 --- /dev/null +++ b/VERSION.txt @@ -0,0 +1 @@ +v0.2-alpha \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..750dce3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + ziti-edge-proxy: + build: . + ports: + - "1080:1080" + - "8080:8080" + environment: + PROXY_HOST: 0.0.0.0 + SOCKS_PORT: 1080 + HTTP_PORT: 8080 + HTTP_ENABLED: true + SOCKS_ENABLED: true + PROXY_USERNAME: user + PROXY_PASSWORD: 1234 + ZITI_IDENTITIES: /app/identity.json + volumes: + - "../identity.json:/app/identity.json" \ No newline at end of file diff --git a/examples/docker-compose/README.md b/examples/docker-compose/README.md index db70f9b..c452839 100644 --- a/examples/docker-compose/README.md +++ b/examples/docker-compose/README.md @@ -4,6 +4,11 @@ Use Netcat with the SSH ProxyProtocol feature. ssh -o "ProxyCommand=ncat --proxy-auth user:1234 --proxy-type socks5 --proxy 127.0.0.1:1080 %h %p" root@your.intercept.hostname.com ``` +Simple curl to use the HTTP Proxy as example. +``` +curl -X http://127.0.0.1:8080 https://your.intercept.hostname.com +``` + # Docker Compose example ``` @@ -14,7 +19,8 @@ services: - "1080:1080" environment: PROXY_HOST: 0.0.0.0 - PROXY_PORT: 1080 + SOCKS_PORT: 1080 + HTTP_PORT: 8080 PROXY_USERNAME: user PROXY_PASSWORD: 1234 ZITI_IDENTITIES: /app/identity.json @@ -31,7 +37,8 @@ services: - "1080:1080" environment: PROXY_HOST: 0.0.0.0 - PROXY_PORT: 1080 + SOCKS_PORT: 1080 + HTTP_PORT: 8080 PROXY_USERNAME: user PROXY_PASSWORD: 1234 ZITI_IDENTITY: "eyXXXXX" # Your identity.json just Base64 encoded, no JWT Token! diff --git a/examples/gitlab/.gitlab-ci.yml b/examples/gitlab/.gitlab-ci.yml index 5c62481..7776b38 100644 --- a/examples/gitlab/.gitlab-ci.yml +++ b/examples/gitlab/.gitlab-ci.yml @@ -5,7 +5,8 @@ variables: CI_DEBUG_SERVICES: true PROXY_ADDRESS: ziti-edge-proxy PROXY_HOST: 127.0.0.1 - PROXY_PORT: 1080 + SOCKS_PORT: 1080 + HTTP_PORT: 8080 PROXY_USERNAME: user PROXY_PASSWORD: password ZITI_IDENTITY: $ZITI_IDENTITY_BASE64 # Variable from Gitlab CI/CD secrets @@ -17,7 +18,7 @@ default: deploy: stage: build - image: "YOUR-ANSIBLE-IMAGE" + image: YOUR-IMAGE variables: ANSIBLE_REMOTE_USER: deployer ANSIBLE_INVENTORY: hosts.ini @@ -28,4 +29,10 @@ deploy: script: - ansible-playbook main.yml -v +curl-example: + stage: build + image: YOUR-IMAGE + script: + - curl -x http://${PROXY_USERNAME}:${PROXY_PASSWORD}@${PROXY_ADDRESS}:${PROXY_PORT} https://your.intercept.hostname.com + \ No newline at end of file diff --git a/src/main.py b/src/main.py index 3cb83c1..ed6ab98 100644 --- a/src/main.py +++ b/src/main.py @@ -5,6 +5,16 @@ import openziti import socket import sys +import logging +import base64 + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler() + ] +) class Socks5Server: def __init__(self, PROXY_HOST='0.0.0.0', PROXY_PORT=1080, PROXY_USERNAME=None, PROXY_PASSWORD=None): @@ -17,81 +27,176 @@ def __init__(self, PROXY_HOST='0.0.0.0', PROXY_PORT=1080, PROXY_USERNAME=None, P def start(self): self.server_socket.bind((self.host, self.port)) self.server_socket.listen(5) - print(f"SOCKS5 server listening on {self.host}:{self.port}") + logging.info(f"SOCKS5 server listening on {self.host}:{self.port}") while True: client_socket, client_address = self.server_socket.accept() - print(f"Connection from {client_address}") - threading.Thread(target=self.handle_client, args=(client_socket,)).start() + logging.info(f"SOCKS5 Connection from {client_address}") + threading.Thread(target=self.handle_client, args=(client_socket, client_address)).start() - def handle_client(self, client_socket): + def handle_client(self, client_socket, client_address): try: if not self.socks5_handshake(client_socket): return self.socks5_connect(client_socket) except Exception as e: - print(f"Error: {e}") + logging.error(f"Error handling SOCKS5 client {client_address}: {e}") finally: client_socket.close() + logging.info(f"SOCKS5 Connection closed: {client_address}") def socks5_handshake(self, client_socket): - version, n_methods = struct.unpack("!BB", client_socket.recv(2)) - assert version == 5 - - methods = client_socket.recv(n_methods) - - if self.username and self.password: - if 2 not in methods: - client_socket.sendall(struct.pack("!BB", 5, 0xFF)) - return False - client_socket.sendall(struct.pack("!BB", 5, 2)) + try: + version, n_methods = struct.unpack("!BB", client_socket.recv(2)) + assert version == 5 - version = struct.unpack("!B", client_socket.recv(1))[0] - if version != 1: - return False + methods = client_socket.recv(n_methods) - ulen = struct.unpack("!B", client_socket.recv(1))[0] - username = client_socket.recv(ulen).decode() - plen = struct.unpack("!B", client_socket.recv(1))[0] - password = client_socket.recv(plen).decode() + if self.username and self.password: + if 2 not in methods: + client_socket.sendall(struct.pack("!BB", 5, 0xFF)) + logging.warning("No supported authentication methods.") + return False + client_socket.sendall(struct.pack("!BB", 5, 2)) + + version = struct.unpack("!B", client_socket.recv(1))[0] + if version != 1: + return False + + ulen = struct.unpack("!B", client_socket.recv(1))[0] + username = client_socket.recv(ulen).decode() + plen = struct.unpack("!B", client_socket.recv(1))[0] + password = client_socket.recv(plen).decode() - if username != self.username or password != self.password: - client_socket.sendall(struct.pack("!BB", 1, 1)) - return False - client_socket.sendall(struct.pack("!BB", 1, 0)) - else: - client_socket.sendall(struct.pack("!BB", 5, 0)) + if username != self.username or password != self.password: + client_socket.sendall(struct.pack("!BB", 1, 1)) + logging.warning("Authentication failed.") + return False + client_socket.sendall(struct.pack("!BB", 1, 0)) + else: + client_socket.sendall(struct.pack("!BB", 5, 0)) - return True + return True + except Exception as e: + logging.error(f"Error during SOCKS5 handshake: {e}") + return False def socks5_connect(self, client_socket): openziti.monkeypatch() - version, cmd, _, addr_type = struct.unpack("!BBBB", client_socket.recv(4)) - assert version == 5 + try: + version, cmd, _, addr_type = struct.unpack("!BBBB", client_socket.recv(4)) + assert version == 5 + + if cmd != 1: + client_socket.sendall(struct.pack("!BBBBIH", 5, 7, 0, 1, 0, 0)) + raise ValueError("Only CONNECT command is supported") + + if addr_type == 1: + address = socket.inet_ntoa(client_socket.recv(4)) + elif addr_type == 3: + domain_length = struct.unpack("!B", client_socket.recv(1))[0] + address = client_socket.recv(domain_length).decode("utf-8") + else: + raise ValueError("Unsupported address type") + + port = struct.unpack("!H", client_socket.recv(2))[0] + + logging.info(f"Connecting to {address}:{port}") + remote_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + remote_socket.connect((address, port)) + + client_socket.sendall(struct.pack("!BBBB", 5, 0, 0, 1) + socket.inet_aton("0.0.0.0") + struct.pack("!H", 0)) + + self.relay_traffic(client_socket, remote_socket) + except Exception as e: + logging.error(f"Error during SOCKS5 connect: {e}") + + def relay_traffic(self, client_socket, remote_socket): + try: + while True: + ready_sockets, _, _ = select.select([client_socket, remote_socket], [], []) + for sock in ready_sockets: + data = sock.recv(4096) + if not data: + return + if sock is client_socket: + remote_socket.sendall(data) + else: + client_socket.sendall(data) + except Exception as e: + logging.error(f"Relay error: {e}") + finally: + client_socket.close() + remote_socket.close() + +class HttpProxyServer: + def __init__(self, PROXY_HOST='0.0.0.0', PROXY_PORT=8080, PROXY_USERNAME=None, PROXY_PASSWORD=None): + self.host = PROXY_HOST + self.port = PROXY_PORT + self.username = PROXY_USERNAME + self.password = PROXY_PASSWORD + self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + + def start(self): + self.server_socket.bind((self.host, self.port)) + self.server_socket.listen(5) + logging.info(f"HTTP proxy server listening on {self.host}:{self.port}") - if cmd != 1: - client_socket.sendall(struct.pack("!BBBBIH", 5, 7, 0, 1, 0, 0)) - raise ValueError("Only CONNECT command is supported") + while True: + client_socket, client_address = self.server_socket.accept() + logging.info(f"HTTP Connection from {client_address}") + threading.Thread(target=self.handle_client, args=(client_socket, client_address)).start() - if addr_type == 1: - address = socket.inet_ntoa(client_socket.recv(4)) - elif addr_type == 3: - domain_length = struct.unpack("!B", client_socket.recv(1))[0] - address = client_socket.recv(domain_length).decode("utf-8") - else: - raise ValueError("Unsupported address type") + def handle_client(self, client_socket, client_address): + try: + request = client_socket.recv(4096).decode() + headers = request.split("\r\n") + if self.username and self.password: + auth_header = [h for h in headers if h.startswith("Proxy-Authorization:")] + if not auth_header or not self.authenticate(auth_header[0]): + client_socket.sendall(b"HTTP/1.1 407 Proxy Authentication Required\r\n\r\n") + logging.warning("HTTP authentication failed.") + return - port = struct.unpack("!H", client_socket.recv(2))[0] + first_line = headers[0].split() + method, url, _ = first_line + logging.info(f"Requested URL: {url}") + target_host, target_port = self.parse_url(url) - remote_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - remote_socket.connect((address, port)) + openziti.monkeypatch() + remote_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + remote_socket.connect((target_host, target_port)) - client_socket.sendall(struct.pack("!BBBB", 5, 0, 0, 1) + socket.inet_aton("0.0.0.0") + struct.pack("!H", 0)) + if method == "CONNECT": + client_socket.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n") + else: + remote_socket.sendall(request.encode()) - self.relay_traffic(client_socket, remote_socket) + self.relay_traffic(client_socket, remote_socket) + except Exception as e: + logging.error(f"HTTP Proxy error for {client_address}: {e}") + finally: + client_socket.close() + logging.info(f"HTTP Connection closed: {client_address}") + + def authenticate(self, auth_header): + try: + auth_value = auth_header.split()[2] + credentials = f"{self.username}:{self.password}".encode() + return auth_value == base64.b64encode(credentials).decode() + except Exception as e: + logging.error(f"Error authenticating: {e}") + return False + + def parse_url(self, url): + if "://" in url: + url = url.split("://")[1] + if ":" in url: + host, port = url.split(":") + return host, int(port.split("/")[0]) + return url.split("/")[0], 80 def relay_traffic(self, client_socket, remote_socket): - openziti.monkeypatch() try: while True: ready_sockets, _, _ = select.select([client_socket, remote_socket], [], []) @@ -104,33 +209,53 @@ def relay_traffic(self, client_socket, remote_socket): else: client_socket.sendall(data) except Exception as e: - print(f"Relay error: {e}") + logging.error(f"Relay error: {e}") finally: client_socket.close() remote_socket.close() def validate_env(): - host = os.getenv("PROXY_HOST") - port = os.getenv("PROXY_PORT") + socks_host = os.getenv("PROXY_HOST") + socks_port = os.getenv("SOCKS_PORT") + http_port = os.getenv("HTTP_PORT") username = os.getenv("PROXY_USERNAME") password = os.getenv("PROXY_PASSWORD") + socks_enabled = os.getenv("SOCKS_ENABLED", "false").lower() == "true" + http_enabled = os.getenv("HTTP_ENABLED", "false").lower() == "true" - if not host: - print("Error: PROXY_HOST environment variable is missing.") + if not socks_host: + logging.error("PROXY_HOST environment variable is missing.") sys.exit(1) - try: - port = int(port) - if not (0 < port < 65536): - raise ValueError - except (ValueError, TypeError): - print("Error: PROXY_PORT must be a valid port number (1-65535).") - sys.exit(1) - - return host, port, username, password + # Validating SOCKS port only if SOCKS_ENABLED is true + if socks_enabled: + try: + socks_port = int(socks_port) + if not (0 < socks_port < 65536): + raise ValueError + except (ValueError, TypeError): + logging.error("SOCKS_PORT must be a valid port number (1-65535).") + sys.exit(1) + + # Validating HTTP port only if HTTP_ENABLED is true + if http_enabled: + try: + http_port = int(http_port) + if not (0 < http_port < 65536): + raise ValueError + except (ValueError, TypeError): + logging.error("HTTP_PORT must be a valid port number (1-65535).") + sys.exit(1) + + return socks_host, socks_port, http_port, username, password, socks_enabled, http_enabled if __name__ == "__main__": - PROXY_HOST, PROXY_PORT, PROXY_USERNAME, PROXY_PASSWORD = validate_env() + PROXY_HOST, SOCKS_PORT, HTTP_PORT, PROXY_USERNAME, PROXY_PASSWORD, SOCKS_ENABLED, HTTP_ENABLED = validate_env() + + if SOCKS_ENABLED: + socks_server = Socks5Server(PROXY_HOST=PROXY_HOST, PROXY_PORT=SOCKS_PORT, PROXY_USERNAME=PROXY_USERNAME, PROXY_PASSWORD=PROXY_PASSWORD) + threading.Thread(target=socks_server.start).start() - server = Socks5Server(PROXY_HOST=PROXY_HOST, PROXY_PORT=PROXY_PORT, PROXY_USERNAME=PROXY_USERNAME, PROXY_PASSWORD=PROXY_PASSWORD) - server.start() \ No newline at end of file + if HTTP_ENABLED: + http_server = HttpProxyServer(PROXY_HOST=PROXY_HOST, PROXY_PORT=HTTP_PORT, PROXY_USERNAME=PROXY_USERNAME, PROXY_PASSWORD=PROXY_PASSWORD) + threading.Thread(target=http_server.start).start()