├── .devcontainer ├── vimrc ├── bashrc ├── devcontainer.json ├── docker-init.sh └── Dockerfile ├── captcha ├── src │ ├── vite-env.d.ts │ ├── index.css │ ├── utils.ts │ └── index.tsx ├── captcha.rs ├── tsconfig.json ├── Cargo.toml ├── .gitignore ├── index.html ├── package.json ├── tsconfig.node.json ├── Makefile ├── tsconfig.app.json └── vite.config.ts ├── pingoo ├── config │ ├── mod.rs │ └── config_file.rs ├── service_discovery │ ├── mod.rs │ ├── dns.rs │ ├── docker.rs │ └── service_registry.rs ├── tls │ ├── mod.rs │ ├── certificate.rs │ └── tls_manager.rs ├── crypto_utils.rs ├── tracing_utils.rs ├── error.rs ├── services │ ├── mod.rs │ ├── tcp_proxy_service.rs │ ├── http_utils.rs │ └── http_proxy_service.rs ├── rules.rs ├── serde_utils.rs ├── listeners │ ├── tcp_listener.rs │ ├── tcp_tls_listener.rs │ ├── https_listener.rs │ ├── mod.rs │ └── http_listener.rs ├── main.rs ├── lists.rs ├── geoip.rs └── server.rs ├── .dockerignore ├── docs ├── assets │ ├── icon-512.png │ └── pingoo_deployment_modes.png ├── support.md ├── contact.md ├── markdown_ninja.yml ├── listeners.md ├── getting_started.md ├── geoip.md ├── rules.md ├── configuration.md ├── tls.md ├── overview.md └── services.md ├── .rustfmt.toml ├── docker ├── src │ ├── docker.rs │ ├── error.rs │ ├── containers.rs │ ├── main.rs │ └── client.rs └── Cargo.toml ├── jwt ├── Cargo.toml ├── base64_utils.rs ├── jwk.rs └── key.rs ├── rules ├── Cargo.toml └── rules.rs ├── assets ├── pingoo.yml └── www │ └── index.html ├── pong ├── Cargo.toml ├── pong.rs └── pong.js ├── .cargo └── config.toml ├── .github └── workflows │ ├── publish_docs.yml │ ├── audit.yml │ └── release.yml ├── .gitignore ├── LICENSE.txt ├── CHANGELOG.md ├── Makefile ├── README.md ├── Dockerfile └── Cargo.toml /.devcontainer/vimrc: -------------------------------------------------------------------------------- 1 | set nu 2 | set mouse=a 3 | -------------------------------------------------------------------------------- /captcha/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /pingoo/config/mod.rs: -------------------------------------------------------------------------------- 1 | mod config; 2 | mod config_file; 3 | 4 | pub use config::*; 5 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | _dev 3 | dist/ 4 | target/ 5 | pingoo.yml 6 | Dockerfile 7 | -------------------------------------------------------------------------------- /pingoo/service_discovery/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod dns; 2 | pub mod docker; 3 | pub mod service_registry; 4 | -------------------------------------------------------------------------------- /docs/assets/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pingooio/pingoo/HEAD/docs/assets/icon-512.png -------------------------------------------------------------------------------- /captcha/captcha.rs: -------------------------------------------------------------------------------- 1 | use embed::Embed; 2 | 3 | #[derive(Embed)] 4 | #[folder = "./dist"] 5 | pub struct Assets; 6 | -------------------------------------------------------------------------------- /pingoo/tls/mod.rs: -------------------------------------------------------------------------------- 1 | mod tls_manager; 2 | pub use tls_manager::*; 3 | 4 | pub mod acme; 5 | pub mod certificate; 6 | -------------------------------------------------------------------------------- /docs/assets/pingoo_deployment_modes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pingooio/pingoo/HEAD/docs/assets/pingoo_deployment_modes.png -------------------------------------------------------------------------------- /captcha/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | # imports_granularity = "Crate" 2 | # group_imports = "StdExternalCrate" 3 | max_width = 120 4 | fn_call_width = 80 5 | # struct_lit_single_line = false 6 | -------------------------------------------------------------------------------- /pingoo/crypto_utils.rs: -------------------------------------------------------------------------------- 1 | use aws_lc_rs::constant_time::verify_slices_are_equal; 2 | 3 | pub fn constant_time_compare(a: &[u8], b: &[u8]) -> bool { 4 | return verify_slices_are_equal(a, b).is_ok(); 5 | } 6 | -------------------------------------------------------------------------------- /docker/src/docker.rs: -------------------------------------------------------------------------------- 1 | mod client; 2 | pub mod containers; 3 | mod error; 4 | pub mod model; 5 | 6 | pub use client::Client; 7 | pub use error::Error; 8 | 9 | pub const DEFAULT_DOCKER_SOCKET: &str = "/var/run/docker.sock"; 10 | -------------------------------------------------------------------------------- /captcha/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "captcha" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [lib] 7 | path = "./captcha.rs" 8 | 9 | 10 | [dependencies] 11 | embed = { workspace = true, features = ["debug-embed"] } 12 | -------------------------------------------------------------------------------- /docker/src/error.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, thiserror::Error)] 2 | pub enum Error { 3 | #[error("connecting to docker socket: {0}")] 4 | Connecting(Box), 5 | #[error("{0}")] 6 | Unspecified(String), 7 | } 8 | -------------------------------------------------------------------------------- /captcha/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /jwt/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "jwt" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [lib] 7 | path = "jwt.rs" 8 | 9 | [dependencies] 10 | aws-lc-rs = { workspace = true } 11 | base64 = { workspace = true } 12 | chrono = { workspace = true } 13 | serde = { workspace = true, features = ["derive"] } 14 | serde_json = { workspace = true } 15 | thiserror = { workspace = true } 16 | -------------------------------------------------------------------------------- /rules/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rules" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [lib] 7 | path = "./rules.rs" 8 | 9 | [dependencies] 10 | bel = { workspace = true } 11 | chrono = { workspace = true, features = ["clock", "oldtime", "serde", "std" ] } 12 | serde = { workspace = true, features = ["derive"] } 13 | thiserror = { workspace = true } 14 | uuid = { workspace = true } 15 | 16 | -------------------------------------------------------------------------------- /docker/src/containers.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | Client, Error, 3 | model::{ContainerSummary, ListContainersOptions}, 4 | }; 5 | 6 | impl Client { 7 | pub async fn list_containers( 8 | &self, 9 | options: Option, 10 | ) -> Result, Error> { 11 | return self.send_request("/containers/json", options, None).await; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /assets/pingoo.yml: -------------------------------------------------------------------------------- 1 | # Default pingoo.yml file. For demonstration purpose only. 2 | 3 | listeners: 4 | http: 5 | address: http://0.0.0.0 6 | 7 | services: 8 | static_site: 9 | static: 10 | root: /var/wwww 11 | 12 | 13 | rules: 14 | basic_waf: 15 | expression: http_request.path.starts_with("/.env") || http_request.path.starts_with("/.git") 16 | actions: 17 | - action: block 18 | -------------------------------------------------------------------------------- /pong/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "pong" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [[bin]] 7 | name = "pong" 8 | path = "pong.rs" 9 | 10 | [dependencies] 11 | bytes = { workspace = true } 12 | http-body-util = { workspace = true } 13 | hyper = { workspace = true, features = ["full"] } 14 | hyper-util = { workspace = true, features = ["full"] } 15 | tokio = { workspace = true, features = ["full"] } 16 | -------------------------------------------------------------------------------- /.devcontainer/bashrc: -------------------------------------------------------------------------------- 1 | alias gs="git status" 2 | alias ga="git add" 3 | alias gu="git add -u" 4 | alias gm="git commit -m" 5 | alias gp="git push" 6 | 7 | export TZ="UTC" 8 | export DO_NOT_TRACK=1 9 | 10 | export GOROOT="/usr/local/go" 11 | export GOPATH="$HOME/.local/gopath" 12 | export GOPROXY=direct 13 | export GOTOOLCHAIN="local" 14 | # go telemetry off 15 | 16 | export PATH="$PATH:$GOROOT/bin:$GOPATH/bin:$HOME/.cargo/bin" 17 | -------------------------------------------------------------------------------- /docs/support.md: -------------------------------------------------------------------------------- 1 | --- 2 | date: 2025-09-16T06:00:00Z 3 | title: "Pingoo support" 4 | type: "page" 5 | url: "/docs/support" 6 | --- 7 | 8 | # Support 9 | 10 | Do you have custom needs? Do you want your features to be prioritized? Are you under attack and need help? Do you need support for deploying and self-hosting Pingoo? 11 | 12 | Feel free to reach our team of experts to see how we can help: [hello@pingoo.io](mailto:hello@pingoo.io) 13 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "dockerFile": "Dockerfile", 3 | "extensions": [ 4 | "rust-lang.rust-analyzer" 5 | ], 6 | "forwardPorts": [8080, 8081, 8082, 8083, 8443], 7 | "runArgs": ["--init", "--name", "pingoo-dev"], 8 | "mounts": [ 9 | "source=/var/run/docker.sock,target=/var/run/docker-host.sock,type=bind" 10 | ], 11 | "overrideCommand": false, 12 | // "containerUser": "dev", 13 | "remoteUser": "dev" 14 | } 15 | -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | rustflags = ["-C", "target-cpu=native"] 3 | 4 | # Using lld as the linker greatly improves compilation time. 5 | # Since Rust 1.90 lld is the default linker on x86_64-unknown-linux-gnu 6 | [target.x86_64-unknown-linux-musl] 7 | rustflags = ["-C", "link-args=-fuse-ld=lld"] 8 | [target.aarch64-unknown-linux-gnu] 9 | rustflags = ["-C", "link-args=-fuse-ld=lld"] 10 | [target.aarch64-unknown-linux-musl] 11 | rustflags = ["-C", "link-args=-fuse-ld=lld"] 12 | -------------------------------------------------------------------------------- /captcha/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Bot detection 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /assets/www/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Pingoo 10 | 11 | 12 |

Welcome to Pingoo! Visit https://pingoo.io to get started.

13 | 14 | 15 | -------------------------------------------------------------------------------- /.github/workflows/publish_docs.yml: -------------------------------------------------------------------------------- 1 | name: publish_docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | publish_docs: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@ff7abcd0c3c05ccf6adc123a8cd1fd4fb30fb493 # v5.0.0 13 | 14 | - name: publish 15 | env: 16 | MARKDOWN_NINJA_API_KEY: ${{ secrets.MARKDOWN_NINJA_API_KEY }} 17 | working-directory: docs 18 | run: docker run -i --rm -e MARKDOWN_NINJA_API_KEY -v `pwd`:/mdninja ghcr.io/bloom42/markdown-ninja publish 19 | -------------------------------------------------------------------------------- /captcha/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pingoo-captcha", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite --strictPort --port 8082 --host", 8 | "build": "tsc -b && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@preact/signals": "^2.2.1", 13 | "@tailwindcss/vite": "^4.1.11", 14 | "preact": "^10.26.5" 15 | }, 16 | "devDependencies": { 17 | "@preact/preset-vite": "^2.10.1", 18 | "typescript": "^5.9.2", 19 | "vite": "^7.0.4" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /docs/contact.md: -------------------------------------------------------------------------------- 1 | --- 2 | date: 2025-09-16T06:00:00Z 3 | title: "Pingoo contact" 4 | type: "page" 5 | url: "/contact" 6 | --- 7 | 8 | # Contact 9 | 10 | While we prefer working in the open on [GitHub](https://github.com/pingooio/pingoo/issues), you can privately reach us at [hello@pingoo.io](mailto:hello@pingoo.io) if you have special requirements or a private request. 11 | 12 | ## Security 13 | 14 | If you've found a security issue in Pingoo, please privately reach us at [security@pingoo.io](mailto:security@pingoo.io) so we can ensure the safety of all Pingoo's users. 15 | -------------------------------------------------------------------------------- /docker/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use docker::{Client, model::ListContainersOptions}; 4 | 5 | #[tokio::main] 6 | async fn main() { 7 | let client = Client::new(None); 8 | 9 | let mut filters = HashMap::new(); 10 | filters.insert("label".to_string(), vec!["my.service=test".to_string()]); 11 | let containers = client 12 | .list_containers(Some(ListContainersOptions { 13 | filters: filters, 14 | ..Default::default() 15 | })) 16 | .await 17 | .unwrap(); 18 | println!("{containers:?}"); 19 | } 20 | -------------------------------------------------------------------------------- /pingoo/tracing_utils.rs: -------------------------------------------------------------------------------- 1 | // We need this macro because tracing expects the level to be const: 2 | // https://github.com/tokio-rs/tracing/issues/2730 3 | #[macro_export] 4 | macro_rules! dynamic_event { 5 | ($level:expr, $($args:tt)*) => {{ 6 | match $level { 7 | ::tracing::Level::TRACE => ::tracing::trace!($($args)*), 8 | ::tracing::Level::DEBUG => ::tracing::debug!($($args)*), 9 | ::tracing::Level::INFO => ::tracing::info!($($args)*), 10 | ::tracing::Level::WARN => ::tracing::warn!($($args)*), 11 | ::tracing::Level::ERROR => ::tracing::error!($($args)*), 12 | } 13 | }}; 14 | } 15 | -------------------------------------------------------------------------------- /pingoo/error.rs: -------------------------------------------------------------------------------- 1 | use std::net::SocketAddr; 2 | 3 | use crate::geoip; 4 | 5 | #[derive(Debug, thiserror::Error)] 6 | pub enum Error { 7 | #[error("{0}")] 8 | Config(String), 9 | #[error("error loading config: {0}")] 10 | Unspecified(String), 11 | #[error("error listening {listener} on {address}: {err}")] 12 | Listening { 13 | listener: String, 14 | address: SocketAddr, 15 | err: std::io::Error, 16 | }, 17 | #[error("{0}")] 18 | Tls(String), 19 | } 20 | 21 | impl From for Error { 22 | fn from(err: geoip::Error) -> Self { 23 | Error::Unspecified(err.to_string()) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /docker/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "docker" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [lib] 7 | name = "docker" 8 | path = "src/docker.rs" 9 | 10 | [[bin]] 11 | name = "docker" 12 | path = "src/main.rs" 13 | 14 | [dependencies] 15 | bytes = { workspace = true } 16 | http-body-util = { workspace = true } 17 | hyper = { workspace = true, features = ["full"] } 18 | hyper-util = { workspace = true } 19 | serde = { workspace = true, features = ["derive"] } 20 | serde_json = { workspace = true } 21 | serde_urlencoded = { workspace = true } 22 | thiserror = { workspace = true } 23 | tokio = { workspace = true, features = ["full"] } 24 | tracing = { workspace = true } 25 | -------------------------------------------------------------------------------- /captcha/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "verbatimModuleSyntax": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "erasableSyntaxOnly": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "noUncheckedSideEffectImports": true 23 | }, 24 | "include": ["vite.config.ts"] 25 | } 26 | -------------------------------------------------------------------------------- /captcha/src/index.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | #pingoo-captcha { 4 | width: 100%; 5 | } 6 | 7 | :root { 8 | font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; 9 | line-height: 1.5; 10 | font-weight: 400; 11 | 12 | color-scheme: light dark; 13 | color: rgba(255, 255, 255, 0.87); 14 | background-color: #242424; 15 | 16 | font-synthesis: none; 17 | text-rendering: optimizeLegibility; 18 | -webkit-font-smoothing: antialiased; 19 | -moz-osx-font-smoothing: grayscale; 20 | } 21 | 22 | body { 23 | margin: 0; 24 | display: flex; 25 | place-items: center; 26 | min-width: 320px; 27 | min-height: 100vh; 28 | } 29 | 30 | 31 | @media (prefers-color-scheme: light) { 32 | :root { 33 | color: #213547; 34 | background-color: #ffffff; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /captcha/Makefile: -------------------------------------------------------------------------------- 1 | NAME = $(shell cat package.json | grep name | cut -d '"' -f4) 2 | VERSION = $(shell cat package.json | grep version | cut -d '"' -f4) 3 | DATE = $(shell date +"%Y-%m-%d") 4 | DIST_DIR = dist 5 | 6 | .PHONY: check 7 | check: 8 | npm run type-check 9 | 10 | .PHONY: build 11 | build: 12 | npm run build 13 | 14 | .PHONY: exif 15 | exif: 16 | exiftool -overwrite_original -recurse -all= public/* || exit 0 17 | 18 | .PHONY: install 19 | install: 20 | npm install --no-scripts --ignore-scripts 21 | 22 | .PHONY: install_ci 23 | install_ci: 24 | npm config set ignore-scripts true 25 | npm ci --no-scripts --ignore-scripts 26 | 27 | .PHONY: dev 28 | dev: 29 | npm run dev 30 | 31 | .PHONY: clean 32 | clean: 33 | rm -rf $(DIST_DIR) node_modules 34 | 35 | 36 | .PHONY: lint 37 | lint: 38 | npm run type-check 39 | 40 | 41 | .PHONY: re 42 | re: clean build 43 | 44 | .PHONY: update_deps 45 | update_deps: 46 | npm update 47 | make build 48 | npm outdated 49 | -------------------------------------------------------------------------------- /.github/workflows/audit.yml: -------------------------------------------------------------------------------- 1 | name: audit dependencies 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | tags-ignore: 8 | - '**' 9 | 10 | jobs: 11 | audit-pingoo: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: install and update packages 15 | run: | 16 | sudo apt update && sudo apt upgrade -y 17 | rustup update stable 18 | cargo install --locked --git https://github.com/rustsec/rustsec cargo-audit 19 | 20 | - uses: actions/checkout@ff7abcd0c3c05ccf6adc123a8cd1fd4fb30fb493 # v5.0.0 21 | 22 | - name: cargo audit 23 | run: cargo audit 24 | 25 | audit-captcha: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: update packages 29 | run: sudo apt update && sudo apt upgrade -y 30 | 31 | - uses: actions/checkout@ff7abcd0c3c05ccf6adc123a8cd1fd4fb30fb493 # v5.0.0 32 | 33 | - name: npm audit 34 | working-directory: captcha 35 | run: npm audit 36 | -------------------------------------------------------------------------------- /captcha/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "module": "ESNext", 7 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 8 | "skipLibCheck": true, 9 | "paths": { 10 | "react": ["./node_modules/preact/compat/"], 11 | "react-dom": ["./node_modules/preact/compat/"] 12 | }, 13 | 14 | /* Bundler mode */ 15 | "moduleResolution": "bundler", 16 | "allowImportingTsExtensions": true, 17 | "verbatimModuleSyntax": true, 18 | "moduleDetection": "force", 19 | "noEmit": true, 20 | "jsx": "react-jsx", 21 | "jsxImportSource": "preact", 22 | 23 | /* Linting */ 24 | "strict": true, 25 | "noUnusedLocals": true, 26 | "noUnusedParameters": true, 27 | "erasableSyntaxOnly": true, 28 | "noFallthroughCasesInSwitch": true, 29 | "noUncheckedSideEffectImports": true 30 | }, 31 | "include": ["src"] 32 | } 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # editors / OS 2 | *~ 3 | *.sw[mnpcod] 4 | *.log 5 | *.tmp 6 | *.tmp.* 7 | *.suo 8 | *.ntvs* 9 | *.njsproj 10 | *.sln 11 | log.txt 12 | *.sublime-project 13 | *.sublime-workspace 14 | .vscode/ 15 | .idea/ 16 | .DS_Store 17 | Thumbs.db 18 | 19 | # js / ts 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | .sourcemaps/ 24 | .sass-cache/ 25 | node_modules/ 26 | 27 | # Miscellaneous 28 | *.class 29 | *.log 30 | *.pyc 31 | *.swp 32 | .DS_Store 33 | .atom/ 34 | .buildlog/ 35 | .history 36 | .svn/ 37 | .venv 38 | 39 | # IntelliJ related 40 | *.iml 41 | *.ipr 42 | *.iws 43 | .idea/ 44 | 45 | 46 | .env* 47 | *.local 48 | 49 | # Rust 50 | target/ 51 | 52 | 53 | #################################################################################################### 54 | ## pingoo 55 | #################################################################################################### 56 | 57 | # development folder for local data and more 58 | _dev 59 | dist/ 60 | 61 | pingoo.yml 62 | geoip.mmdb 63 | 64 | !assets/* 65 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright © 2025 Arcane Services 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /docs/markdown_ninja.yml: -------------------------------------------------------------------------------- 1 | site: "pingoo.io" 2 | name: "Pingoo" 3 | description: "Performance and Security for everyone" 4 | 5 | pages: ["./"] 6 | 7 | 8 | navigation: 9 | primary: 10 | - label: GitHub 11 | url: https://github.com/pingooio/pingoo 12 | - label: Bluesky 13 | url: https://bsky.app/profile/pingoo.io 14 | - label: Mastodon 15 | url: https://mastodon.social/@pingooio 16 | 17 | secondary: 18 | - label: Overview 19 | url: "/" 20 | 21 | - label: Getting Started 22 | url: "/docs/getting-started" 23 | 24 | - label: Configuration 25 | url: "/docs/configuration" 26 | 27 | - label: Listeners 28 | url: "/docs/listeners" 29 | 30 | - label: HTTPS / TLS 31 | url: "/docs/tls" 32 | 33 | - label: Services 34 | url: "/docs/services" 35 | 36 | - label: Rules, Lists & WAF 37 | url: "/docs/rules" 38 | 39 | - label: Geoip 40 | url: "/docs/geoip" 41 | 42 | - label: Support 43 | url: "/docs/support" 44 | 45 | - label: Contact 46 | url: "/contact" 47 | -------------------------------------------------------------------------------- /docs/listeners.md: -------------------------------------------------------------------------------- 1 | --- 2 | date: 2025-09-16T06:00:00Z 3 | title: "Pingoo listeners" 4 | type: "page" 5 | url: "/docs/listeners" 6 | --- 7 | 8 | # Listeners 9 | 10 | Listeners are the addresses, ports and protcols that Pingoo listen to. 11 | 12 | **pingoo.yml** 13 | ```yml 14 | listeners: 15 | http: 16 | address: http://0.0.0.0:8080 17 | https: 18 | address: http://0.0.0.0:8080 19 | services: ["api"] 20 | ``` 21 | 22 | Valid protocols: 23 | - `http` 24 | - `https` 25 | - `tcp` 26 | - `tcp+tls` 27 | 28 | ## Graceful shutdown 29 | 30 | When receiving a `Ctrl+C` / `terminate` signal, listeners initiate the graceful shutdown process. They first stop accepting new connections / requests and then wait up to 20 seconds (this may change in the future) for in-flight connections / request to finish. 31 | 32 | ## Zero-downtime upgrades 33 | 34 | Pingoo uses the `SO_REUSEPORT` option on sockets to enable zero-downtime upgrades. 35 | 36 | If you are using docker you will need to use the `--network host` CLI argument to use zero-downtime upgrades with `SO_REUSEPORT`. 37 | 38 | ```bash 39 | $ docker run -d --network host pingooio/pingoo 40 | ``` 41 | -------------------------------------------------------------------------------- /captcha/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import preact from '@preact/preset-vite' 3 | import tailwindcss from '@tailwindcss/vite' 4 | // @ts-ignore TODO 5 | import { fileURLToPath, URL } from 'node:url' 6 | 7 | 8 | // https://vite.dev/config/ 9 | export default defineConfig({ 10 | plugins: [ 11 | preact(), 12 | tailwindcss(), 13 | ], 14 | resolve: { 15 | alias: { 16 | // @ts-ignore TODO 17 | '@': fileURLToPath(new URL('./src', import.meta.url)) 18 | } 19 | }, 20 | build:{ 21 | assetsDir: '__pingoo/captcha/assets', 22 | rollupOptions: { 23 | output: { 24 | // we use the full hashes to reduces the risk of collision with assets cached for long time 25 | assetFileNames(_chunkInfo) { 26 | return `__pingoo/captcha/assets/[name]-[hash:21][extname]`; 27 | }, 28 | chunkFileNames(_chunkInfo) { 29 | return `__pingoo/captcha/assets/[name]-[hash:21].js`; 30 | }, 31 | entryFileNames(_chunkInfo) { 32 | return `__pingoo/captcha/assets/[name]-[hash:21].js`; 33 | }, 34 | } 35 | }, 36 | }, 37 | esbuild: { 38 | legalComments: 'none', 39 | }, 40 | }) 41 | -------------------------------------------------------------------------------- /pingoo/services/mod.rs: -------------------------------------------------------------------------------- 1 | use std::{net::SocketAddr, sync::Arc}; 2 | 3 | use bytes::Bytes; 4 | use http::{Request, Response}; 5 | use http_body_util::combinators::BoxBody; 6 | use tokio::{ 7 | io::{AsyncRead, AsyncWrite}, 8 | net::TcpStream, 9 | }; 10 | use tokio_rustls::server::TlsStream; 11 | 12 | pub mod http_proxy_service; 13 | pub mod http_static_site_service; 14 | pub mod http_utils; 15 | pub mod tcp_proxy_service; 16 | 17 | /// DynIo is a trait used to serve connections for both TCP and TCP+TLS streams 18 | pub trait DynIo: AsyncRead + AsyncWrite + Unpin + Send {} 19 | 20 | impl DynIo for TcpStream {} 21 | impl DynIo for TlsStream {} 22 | 23 | #[async_trait::async_trait] 24 | pub trait TcpService: Send + Sync { 25 | // TODO: can we do without the Box? 26 | async fn serve_connection( 27 | self: Arc, 28 | mut inbound_tcp_connection: Box, 29 | client_socket_address: SocketAddr, 30 | ); 31 | } 32 | 33 | #[async_trait::async_trait] 34 | pub trait HttpService: Send + Sync { 35 | fn match_request(&self, ctx: &rules::Context) -> bool; 36 | async fn handle_http_request(&self, req: Request) -> Response>; 37 | } 38 | -------------------------------------------------------------------------------- /pong/pong.rs: -------------------------------------------------------------------------------- 1 | use std::convert::Infallible; 2 | use std::env; 3 | use std::net::SocketAddr; 4 | 5 | use bytes::Bytes; 6 | use http_body_util::Full; 7 | use hyper::server::conn::http1; 8 | use hyper::service::service_fn; 9 | use hyper::{Request, Response}; 10 | use hyper_util::rt::TokioIo; 11 | use tokio::net::TcpListener; 12 | 13 | // a Simple HTTP server to test Pingoo's capabilities 14 | 15 | async fn hello(_: Request) -> Result>, Infallible> { 16 | Ok(Response::new(Full::new(Bytes::from("Hello World!")))) 17 | } 18 | 19 | #[tokio::main] 20 | pub async fn main() -> Result<(), Box> { 21 | let port = env::var("PORT").unwrap_or("8080".to_string()).parse::()?; 22 | let addr: SocketAddr = ([127, 0, 0, 1], port).into(); 23 | 24 | let listener = TcpListener::bind(addr).await?; 25 | println!("Listening on http://{}", addr); 26 | loop { 27 | let (tcp, _) = listener.accept().await?; 28 | let io = TokioIo::new(tcp); 29 | tokio::task::spawn(async move { 30 | if let Err(err) = http1::Builder::new().serve_connection(io, service_fn(hello)).await { 31 | eprintln!("Error serving connection: {:?}", err); 32 | } 33 | }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.devcontainer/docker-init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | USERNAME="dev" 4 | DOCKER_SOCKET_GID="$(stat -c '%g' /var/run/docker.sock)" 5 | 6 | # on macOS, the docker socket binded into the container belongs to root, which make problems when trying 7 | # to interact with it. Therefore, we "forward" the host docker socket that was binded to /var/run/docker-host.sock 8 | # to /var/run/docker.sock with socat. 9 | # You can check that the docker socket si working with: curl --unix-socket /var/run/docker.sock http://localhost/version 10 | if [ "${DOCKER_SOCKET_GID}" == '0' ]; then 11 | rm -rf /var/run/docker.sock 12 | ((socat UNIX-LISTEN:/var/run/docker.sock,fork,reuseaddr,mode=660,user=${USERNAME} UNIX-CONNECT:/var/run/docker-host.sock) 2>&1 >> /tmp/vscr-docker-from-docker.log) & > /dev/null 13 | else 14 | if [ "$(cat /etc/group | grep :${DOCKER_SOCKET_GID}:)" = '' ]; then groupadd --gid ${DOCKER_SOCKET_GID} docker-host; fi 15 | if [ "$(id ${USERNAME} | grep -E "groups=.*(=|,)${DOCKER_SOCKET_GID}(")" = '' ]; then usermod -aG ${DOCKER_SOCKET_GID} ${USERNAME}; fi 16 | fi 17 | 18 | # if [ \"\$(cat /etc/group | grep :\${DOCKER_SOCKET_GID}:)\" = '' ]; then groupadd --gid \${DOCKER_SOCKET_GID} docker-host; fi \n\ 19 | # if [ \"\$(id ${USERNAME} | grep -E \"groups=.*(=|,)\${DOCKER_SOCKET_GID}\(\")\" = '' ]; then usermod -aG \${DOCKER_SOCKET_GID} ${USERNAME}; fi\n\ 20 | 21 | exec "$@" 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Pingoo Changelog 2 | 3 | ## v0.14.0 - 2025-10-14 4 | 5 | * **minor breaking change**: logs are now formatted in JSON 6 | * Improved configuration: rules' actions no longer need the `parameters` field. 7 | * Docker hub image is now the recommended way to use Pingoo. 8 | * Pingoo no longer errors if no Geoip database is found and print a warning message instead 9 | 10 | ## v0.13.0 - 2025-10-04 11 | 12 | * **Breaking change**: the `/etc/pingoo/certificates` folder has moved to `/etc/pingoo/tls`. 13 | * Add support for automatic TLS (ACME protocol) 🎉 14 | * HTTPS listeners now also support HTTP/1.1 alongside HTTP/2 15 | 16 | 17 | ## v0.12.0 - 2025-09-26 18 | 19 | * **minor breaking change**: Pingoo now errors if GeoIP database is not found. 20 | 21 | 22 | ## v0.11.0 - 2025-09-22 23 | 24 | - **Breaking change**: the default geoip database is now located at `/usr/share/pingoo/geoip.mmdb(.zst)` in the Docker image instead of `/etc/pingoo_data/geoip.mmdb(.zst)` to follow the Filesystem Hierarchy Standard. 25 | - Add support for `HS512`, `ES256` and `ES512` JSON Web Tokens. 26 | 27 | 28 | ## v0.10.0 - 2025-09-20 29 | 30 | - **Breaking change**: Pingoo no longer try to read the configuration file from the current directory. Now Pingoo only loads its configuration file from `/etc/pingoo/pingoo.yml`. 31 | - Rules are now also loaded from the `/etc/pingoo/rules` folder. 32 | -------------------------------------------------------------------------------- /captcha/src/utils.ts: -------------------------------------------------------------------------------- 1 | export async function retry( 2 | fn: () => Promise, 3 | options?: { attempts?: number; delay?: number } 4 | ): Promise { 5 | const { attempts = 3, delay = 100 } = options || {}; 6 | 7 | for (let i = 0; i < attempts; i++) { 8 | try { 9 | return await fn(); 10 | } catch (error) { 11 | if (i < attempts - 1) { 12 | await new Promise(resolve => setTimeout(resolve, delay)); 13 | } else { 14 | // rethrow the last error if all attempts fail 15 | throw error; 16 | } 17 | } 18 | } 19 | 20 | // fallback error 21 | throw new Error('this should never be reached'); 22 | } 23 | 24 | export function uint8ArrayToHex(data: Uint8Array) { 25 | let hexString = ''; 26 | for (let i = 0; i < data.length; i++) { 27 | // convert each byte to hex and pad with zeros 28 | hexString += data[i].toString(16).padStart(2, '0'); 29 | } 30 | 31 | return hexString; 32 | } 33 | 34 | export function uint8ArrayTobase64(data: Uint8Array): string { 35 | return btoa(String.fromCharCode(...data)); 36 | } 37 | 38 | export function base64ToUint8Array(base64: string) { 39 | var binaryString = atob(base64); 40 | var bytes = new Uint8Array(binaryString.length); 41 | for (var i = 0; i < binaryString.length; i++) { 42 | bytes[i] = binaryString.charCodeAt(i); 43 | } 44 | return bytes; 45 | } 46 | -------------------------------------------------------------------------------- /docs/getting_started.md: -------------------------------------------------------------------------------- 1 | --- 2 | date: 2025-09-16T06:00:00Z 3 | title: "Getting started with Pingoo" 4 | type: "page" 5 | url: "/docs/getting-started" 6 | --- 7 | 8 | # Getting Started 9 | 10 | Here is an example of using Pingoo: 11 | - For TLS termination 12 | - To serve a static web app 13 | - As a load balancer for Docker containers serving an API 14 | - As a WAF to block requests from some countries 15 | 16 | ```bash 17 | $ ls 18 | pingoo/ 19 | pingoo.yml 20 | certificates/ 21 | example.com.key 22 | example.com.pem 23 | www/ 24 | index.html 25 | index.css 26 | assets/ 27 | ... 28 | ``` 29 | 30 | **pingoo.yml** 31 | ```yml 32 | listeners: 33 | https: 34 | address: https://localhost:443 35 | 36 | services: 37 | api: 38 | route: http_request.host.starts_with("api.") 39 | http_proxy: [] # leave upstreams empty when using Docker service discovery 40 | 41 | webapp: 42 | static: 43 | root: /var/www 44 | 45 | rules: 46 | block_some_countries: 47 | expression: | 48 | ["XX"].contains(client.country) 49 | actions: 50 | - action: block 51 | ``` 52 | 53 | 54 | We need to bind the docker socket so Pingoo can list the containers with the `pingoo.service` label. 55 | 56 | ```bash 57 | $ docker run -d --label pingoo.service=api your_api_image:latest 58 | $ docker run -d --network host -v `pwd`/www:/var/www:ro -v `pwd`/pingoo:/etc/pingoo -v /var/run/docker.sock:/var/run/docker.sock pingooio/pingoo:latest 59 | ``` 60 | -------------------------------------------------------------------------------- /pong/pong.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | 3 | const PORT = parseInt(process.env.PORT, 10) || 8080; 4 | 5 | function sendPlainText(res, status, text) { 6 | res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' }); 7 | res.end(text); 8 | } 9 | 10 | function handleSSE(req, res) { 11 | if (req.method !== 'GET') { 12 | res.writeHead(405, { Allow: 'GET' }); 13 | return res.end(); 14 | } 15 | 16 | res.writeHead(200, { 17 | 'Content-Type': 'text/event-stream; charset=utf-8', 18 | 'Cache-Control': 'no-cache, no-transform', 19 | Connection: 'keep-alive', 20 | }); 21 | 22 | res.write(': connected\n\n'); 23 | 24 | const sendTime = () => { 25 | const now = new Date().toISOString(); 26 | res.write(`data: ${now}\n\n`); 27 | }; 28 | 29 | sendTime(); 30 | const interval = setInterval(sendTime, 1000); 31 | 32 | req.on('close', () => { 33 | clearInterval(interval); 34 | }); 35 | } 36 | 37 | const server = http.createServer((req, res) => { 38 | if (req.url === '/sse') { 39 | return handleSSE(req, res); 40 | } 41 | 42 | if (req.url === '/' && req.method === 'GET') { 43 | return sendPlainText(res, 200, `Hello World!`); 44 | } 45 | 46 | return sendPlainText(res, 404, `Not Found`); 47 | }); 48 | 49 | server.listen(PORT, () => { 50 | console.log(`Server listening on port ${PORT}`); 51 | }); 52 | 53 | server.on('error', (err) => { 54 | console.error('Server error:', err); 55 | process.exit(1); 56 | }); 57 | -------------------------------------------------------------------------------- /docs/geoip.md: -------------------------------------------------------------------------------- 1 | --- 2 | date: 2025-09-16T06:00:00Z 3 | title: "Pingoo geoip" 4 | type: "page" 5 | url: "/docs/geoip" 6 | --- 7 | 8 | 9 | # GeoIP 10 | 11 | Pingoo natively supports looking up GeoIP information using `.mmdb` files. A default `geoip.mmdb` database is provided in Pingoo's Docker image. 12 | 13 | When geoip is enabled, Pingoo will add the `Pingoo-Client-Country` and `Pingoo-Client-Asn` HTTP headers to upstream requests. Visit the [Services](/docs/services) page to learn more about the HTTP headers added by Pingoo. 14 | 15 | 16 | ## GeoIP Databases 17 | 18 | Pingoo tries to load the GeoIP database from the following paths (in this order): 19 | - `/etc/pingoo/geoip.mmdb(.zst)` 20 | - `/usr/share/pingoo/geoip.mmdb(.zst)` 21 | 22 | GeoIP database records must have at least the two following fields: 23 | - `country` a 2-letters `String` 24 | - `asn` an `uint32` 25 | 26 | 27 | You can download the latest GeoIP database that we provide for free here: [https://downloads.pingoo.io/geoip.mmdb.zst](https://downloads.pingoo.io/geoip.mmdb.zst) 28 | 29 | > Free geoip databases are updated roughly once a month. Please feel free to [contact us](/contact) if you need a database updated daily. 30 | 31 | 32 | ## Database compression 33 | 34 | Pingoo supports geoip databases compressed with [zstd](https://github.com/facebook/zstd). 35 | 36 | Compressed geoip databases must have the `.zst` extension e.g. `geoip.mmdb.zst` 37 | 38 | 39 | 40 | ## Acknowledgment 41 | 42 | Some of our GeoIP data are kindly provided by ipinfo.io under the [Create Commons Attribution-ShareAlike 4.0 (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/) license. 43 | -------------------------------------------------------------------------------- /jwt/base64_utils.rs: -------------------------------------------------------------------------------- 1 | pub mod base64_url_no_padding { 2 | use base64::{Engine, engine::general_purpose}; 3 | use serde::{Deserialize, Deserializer, Serializer}; 4 | 5 | pub fn serialize(bytes: T, serializer: S) -> Result 6 | where 7 | S: Serializer, 8 | T: AsRef<[u8]>, 9 | { 10 | let encoded = general_purpose::URL_SAFE_NO_PAD.encode(bytes.as_ref()); 11 | serializer.serialize_str(&encoded) 12 | } 13 | 14 | pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> 15 | where 16 | D: Deserializer<'de>, 17 | { 18 | let s = String::deserialize(deserializer)?; 19 | general_purpose::URL_SAFE_NO_PAD 20 | .decode(s.as_bytes()) 21 | .map_err(serde::de::Error::custom) 22 | } 23 | 24 | pub mod option { 25 | use super::*; 26 | 27 | pub fn serialize(opt: &Option, serializer: S) -> Result 28 | where 29 | S: Serializer, 30 | T: AsRef<[u8]>, 31 | { 32 | match opt { 33 | Some(arr) => super::serialize(arr.as_ref(), serializer), 34 | None => serializer.serialize_none(), 35 | } 36 | } 37 | 38 | pub fn deserialize<'de, D>(deserializer: D) -> Result>, D::Error> 39 | where 40 | D: Deserializer<'de>, 41 | { 42 | let opt: Option = Option::deserialize(deserializer)?; 43 | match opt { 44 | None => Ok(None), 45 | Some(s) => super::deserialize(serde::de::value::StringDeserializer::new(s)).map(Some), 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /pingoo/rules.rs: -------------------------------------------------------------------------------- 1 | use std::net::IpAddr; 2 | 3 | use http::Uri; 4 | use serde::Serialize; 5 | use tracing::warn; 6 | 7 | use crate::{geoip::CountryCode, serde_utils}; 8 | 9 | #[derive(Debug, Clone)] 10 | pub struct Rule { 11 | pub name: String, 12 | pub expression: Option, 13 | pub actions: Vec, 14 | } 15 | 16 | #[derive(Debug, Serialize)] 17 | pub struct RequestData<'a> { 18 | pub host: &'a str, 19 | #[serde(serialize_with = "serde_utils::http_uri::serialize")] 20 | pub url: &'a Uri, 21 | pub path: &'a str, 22 | #[serde(serialize_with = "serde_utils::http_method::serialize")] 23 | pub method: &'a http::Method, 24 | pub user_agent: &'a str, 25 | } 26 | 27 | #[derive(Debug, Clone, Serialize)] 28 | pub struct ClientData { 29 | pub ip: IpAddr, 30 | // only signed integers are supported so we can't use an u16 31 | pub remote_port: i32, 32 | pub asn: i64, 33 | pub country: CountryCode, 34 | } 35 | 36 | impl Rule { 37 | pub fn match_request(&self, request_ctx: &rules::Context) -> bool { 38 | if let Some(expression) = &self.expression { 39 | let return_value = match expression.execute(request_ctx) { 40 | Ok(value) => value, 41 | Err(err) => { 42 | warn!("error executing rule {}: {err}", self.name); 43 | return false; 44 | } 45 | }; 46 | 47 | return return_value == true.into(); 48 | } else { 49 | return true; 50 | } 51 | } 52 | } 53 | 54 | // fn serialize_arc_string(value: &Arc, serializer: S) -> Result 55 | // where 56 | // S: serde::Serializer, 57 | // { 58 | // serializer.serialize_str(value) 59 | // } 60 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | DIST_DIR = dist 2 | COMMIT := $(shell git rev-parse HEAD) 3 | DOCKER_IMAGE = pingooio/pingoo 4 | VERSION := $(shell cat Cargo.toml | grep '^version =' | cut -d'"' -f2) 5 | 6 | #################################################################################################### 7 | # Dev 8 | #################################################################################################### 9 | 10 | .PHONY: dev 11 | dev: 12 | cargo run -p pingoo 13 | 14 | 15 | .PHONY: fmt 16 | fmt: 17 | cargo fmt 18 | 19 | .PHONY: check 20 | check: 21 | cargo check 22 | 23 | .PHONY: clean 24 | clean: 25 | rm -rf $(DIST_DIR) captcha/dist 26 | 27 | 28 | .PHONY: update_deps 29 | update_deps: 30 | cargo update 31 | 32 | 33 | .PHONY: release 34 | release: 35 | date 36 | git checkout main 37 | git push 38 | git tag v$(VERSION) 39 | git push --tags 40 | git checkout release 41 | git merge main 42 | git push 43 | git checkout main 44 | 45 | 46 | .PHONY: compress_geoip_db 47 | compress_geoip_db: 48 | mkdir -p $(DIST_DIR) 49 | zstd --ultra -19 --force --check geoip.mmdb 50 | mv geoip.mmdb.zst $(DIST_DIR) 51 | 52 | #################################################################################################### 53 | # Build 54 | #################################################################################################### 55 | .PHONY: build 56 | build: 57 | mkdir -p $(DIST_DIR) 58 | cargo build -p pingoo --release 59 | cp target/release/pingoo $(DIST_DIR)/ 60 | strip --strip-all -xX $(DIST_DIR)/pingoo 61 | 62 | 63 | .PHONY: docker_build 64 | docker_build: 65 | docker build -t $(DOCKER_IMAGE):latest -f Dockerfile . 66 | docker tag $(DOCKER_IMAGE):latest $(DOCKER_IMAGE):$(VERSION) 67 | 68 | 69 | .PHONY: docker_push 70 | docker_push: 71 | docker push $(DOCKER_IMAGE):latest $(DOCKER_IMAGE):$(VERSION) 72 | -------------------------------------------------------------------------------- /pingoo/serde_utils.rs: -------------------------------------------------------------------------------- 1 | pub mod asn { 2 | use serde::{Deserialize, Deserializer}; 3 | 4 | #[inline] 5 | pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { 6 | let asn_str = String::deserialize(deserializer)?; 7 | return Ok(asn_str.trim_start_matches("AS").parse::().unwrap_or(0)); 8 | } 9 | } 10 | 11 | pub mod http_uri { 12 | use http::Uri; 13 | use serde::Serializer; 14 | 15 | #[inline] 16 | pub fn serialize(uri: &Uri, ser: S) -> Result { 17 | ser.collect_str(&uri) 18 | } 19 | } 20 | 21 | pub mod http_method { 22 | use http::Method; 23 | use serde::Serializer; 24 | 25 | #[inline] 26 | pub fn serialize(method: &Method, ser: S) -> Result { 27 | ser.serialize_str(method.as_str()) 28 | } 29 | } 30 | 31 | pub mod rustls_private_pkcs_key_der { 32 | use rustls::pki_types::PrivatePkcs8KeyDer; 33 | use serde::{Deserializer, Serializer, de}; 34 | use std::fmt; 35 | 36 | pub fn serialize(pruvate_key: &PrivatePkcs8KeyDer<'_>, serializer: S) -> Result { 37 | serializer.serialize_str(&base64::encode(pruvate_key.secret_pkcs8_der())) 38 | } 39 | 40 | pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { 41 | struct Visitor; 42 | 43 | impl de::Visitor<'_> for Visitor { 44 | type Value = PrivatePkcs8KeyDer<'static>; 45 | 46 | fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 47 | formatter.write_str("a base64-encoded PKCS#8 private key") 48 | } 49 | 50 | fn visit_str(self, v: &str) -> Result { 51 | let bytes = base64::decode(v.as_bytes()).map_err(de::Error::custom)?; 52 | PrivatePkcs8KeyDer::try_from(bytes).map_err(de::Error::custom) 53 | } 54 | } 55 | 56 | deserializer.deserialize_str(Visitor) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /pingoo/listeners/tcp_listener.rs: -------------------------------------------------------------------------------- 1 | use std::{net::SocketAddr, sync::Arc}; 2 | 3 | use tokio::{sync::watch, task::JoinSet}; 4 | use tracing::debug; 5 | 6 | use crate::{ 7 | Error, 8 | config::ListenerConfig, 9 | listeners::{GRACEFUL_SHUTDOWN_TIMEOUT, Listener, accept_tcp_connection, bind_tcp_socket}, 10 | services::TcpService, 11 | }; 12 | 13 | pub struct TcpListener { 14 | name: String, 15 | address: SocketAddr, 16 | socket: Option, 17 | service: Arc, 18 | } 19 | 20 | impl TcpListener { 21 | pub fn new(config: ListenerConfig, service: Arc) -> Self { 22 | return TcpListener { 23 | name: config.name, 24 | address: config.address, 25 | socket: None, 26 | service, 27 | }; 28 | } 29 | } 30 | 31 | #[async_trait::async_trait] 32 | impl Listener for TcpListener { 33 | fn bind(&mut self) -> Result<(), Error> { 34 | let socket = bind_tcp_socket(self.address, &self.name)?; 35 | self.socket = Some(socket); 36 | return Ok(()); 37 | } 38 | 39 | async fn listen(self: Box, mut shutdown_signal: watch::Receiver<()>) { 40 | let tcp_socket = self 41 | .socket 42 | .expect("You need to bind the listener before calling listen()"); 43 | 44 | let mut connections: JoinSet<_> = JoinSet::new(); 45 | 46 | loop { 47 | tokio::select! { 48 | accept_tcp_res = accept_tcp_connection(&tcp_socket, &self.name) => { 49 | let (tcp_stream, client_socket_addr) = match accept_tcp_res { 50 | Ok(connection) => connection, 51 | Err(_) => continue, 52 | }; 53 | 54 | let service = self.service.clone(); 55 | connections.spawn(service.serve_connection(Box::new(tcp_stream), client_socket_addr)); 56 | }, 57 | _ = shutdown_signal.changed() => { 58 | break; 59 | } 60 | } 61 | } 62 | 63 | // TODO: should we use connections.shutdown()? 64 | tokio::select! { 65 | _ = connections.join_all() => { 66 | debug!("listener {} has gracefully shut down", self.name); 67 | }, 68 | _ = tokio::time::sleep(GRACEFUL_SHUTDOWN_TIMEOUT) => {} 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /rules/rules.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use serde::{Deserialize, Serialize}; 3 | use uuid::Uuid; 4 | 5 | #[derive(Debug, Clone, Serialize, Deserialize)] 6 | pub struct Rule { 7 | pub id: Uuid, 8 | pub created_at: DateTime, 9 | pub updated_at: DateTime, 10 | 11 | pub name: String, 12 | pub description: String, 13 | pub enabled: bool, 14 | pub position: i32, 15 | pub expression: String, 16 | pub actions: Vec, 17 | 18 | pub project_id: Option, 19 | pub ruleset_id: Option, 20 | } 21 | 22 | pub type CompiledExpression = bel::Program; 23 | pub type Context<'a> = bel::Context<'a>; 24 | 25 | // pub struct CompiledRule { 26 | // pub id: Uuid, 27 | // pub updated_at: DateTime, 28 | // } 29 | 30 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 31 | #[serde(tag = "action", rename_all = "snake_case")] 32 | pub enum Action { 33 | Block {}, 34 | Captcha {}, 35 | } 36 | 37 | #[derive(Debug, thiserror::Error)] 38 | pub enum Error { 39 | #[error("{0}")] 40 | Unspecified(String), 41 | #[error("Expression is not valid: {0}")] 42 | ExpressionIsNotValid(String), 43 | } 44 | 45 | pub fn compile_expression(expression: &str) -> Result { 46 | let program = match std::panic::catch_unwind(|| bel::Program::compile(expression)) { 47 | Ok(Ok(program)) => program, 48 | Ok(Err(err)) => return Err(Error::ExpressionIsNotValid(err.to_string())), 49 | Err(_) => return Err(Error::ExpressionIsNotValid("invalid input".to_string())), 50 | }; 51 | 52 | return Ok(program); 53 | } 54 | 55 | pub fn validate_expression(expression: &str) -> Result<(), Error> { 56 | if expression.is_empty() { 57 | return Err(Error::ExpressionIsNotValid("expression is empty".to_string())); 58 | } 59 | 60 | let program = match std::panic::catch_unwind(|| bel::Program::compile(expression)) { 61 | Ok(Ok(program)) => program, 62 | Ok(Err(err)) => return Err(Error::ExpressionIsNotValid(err.to_string())), 63 | Err(_) => return Err(Error::ExpressionIsNotValid("invalid input".to_string())), 64 | }; 65 | let references = program.references(); 66 | 67 | // validate functions 68 | let functions = references.functions(); 69 | if functions.contains(&"@in") { 70 | return Err(Error::ExpressionIsNotValid("unknown operator: in".to_string())); 71 | } 72 | 73 | // validate variables 74 | // TODO 75 | 76 | return Ok(()); 77 | } 78 | -------------------------------------------------------------------------------- /docs/rules.md: -------------------------------------------------------------------------------- 1 | --- 2 | date: 2025-09-16T06:00:00Z 3 | title: "Pingoo rules & lists" 4 | type: "page" 5 | url: "/docs/rules" 6 | --- 7 | 8 | # Rules 9 | 10 | Rules are the main way to modify requests / responses and to configure services. 11 | 12 | Rules are loaded from both the `/etc/pingoo/pingoo.yml` file and all the `.yml` files in the `/etc/pingoo/rules` folder. 13 | 14 | For example in **pingoo.yml**: 15 | ```yml 16 | # ... 17 | 18 | rules: 19 | block: # name of the rule 20 | expression: http_request.path == "/blocked" 21 | actions: 22 | - action: block 23 | ``` 24 | 25 | Or, in **/etc/pingoo/rules/blocked.yml**: 26 | ```yml 27 | block: # name of the rule 28 | expression: http_request.path == "/blocked" 29 | actions: 30 | - action: block 31 | ``` 32 | 33 | 34 | 35 | ## Expression Language 36 | 37 | Pingoo uses a subset of the [Common Expression Language (CEL)](https://cel.dev) with all the inconsistencies and "surprising" things trimmed off. 38 | 39 | ## Types 40 | 41 | - `Bool` 42 | - `String` 43 | - `Int` 44 | - `Float` 45 | - `Ip` 46 | - `Regex` 47 | - `Array` 48 | - `Map` 49 | 50 | 51 | ## Variables 52 | 53 | ```rust 54 | http_request { 55 | host: String 56 | url: String 57 | path: String 58 | method: String 59 | user_agent: String 60 | } 61 | 62 | client { 63 | ip: Ip 64 | remote_port: Int 65 | asn: Int 66 | country: String 67 | } 68 | ``` 69 | 70 | 71 | ## Functions 72 | 73 | - `contains` 74 | - `length` 75 | - `starts_with` 76 | - `ends_with` 77 | 78 | 79 | ## Actions 80 | 81 | Pingoo currently supports the following actions: 82 | 83 | - `captcha`: Serve a CAPTCHA to the client that must be solved to proceed. 84 | - `block`: Serve a 403 permission denied page. 85 | 86 | 87 | ## Lists 88 | 89 | You can provide lists to use in your rules and routes expressions. 90 | 91 | List must be formatted as CSV with at least 1 column for the values, and 1 optional column for the description. 92 | 93 | For example: 94 | 95 | **blocked_ips.csv** 96 | ```csv 97 | 127.0.0.1,"really bad person" 98 | 1.2.3.4,"bad bot" 99 | ``` 100 | 101 | **pingoo.yml** 102 | ```yml 103 | lists: 104 | blocked_ips: 105 | type: Ip 106 | file: blocked_ips.csv 107 | 108 | rules: 109 | block_blocked_ips: 110 | expression: lists["blocked_ips"].contains(client.ip) 111 | actions: 112 | - action: block 113 | ``` 114 | 115 | 116 | Valid lists types: 117 | - `Int` 118 | - `String` 119 | - `Ip` 120 | 121 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | --- 2 | date: 2025-09-16T06:00:00Z 3 | title: "Pingoo configuration" 4 | type: "page" 5 | url: "/docs/configuration" 6 | --- 7 | 8 | # Configuration 9 | 10 | 11 | ## Configuration directory 12 | 13 | Pingoo uses the `/etc/pingoo` directory to load and store its configuration files. 14 | 15 | Pingoo needs **read and write** permission to the configuration directory. 16 | 17 | Pingoo uses the `/etc/pingoo/tls` directory to load and store TLS certificates. Visit the [TLS page](/docs/tls) to learn more about TLS configuration. 18 | 19 | 20 | ## Configuration File 21 | 22 | Pingoo's configuration file is located at `/etc/pingoo/pingoo.yml` 23 | 24 | 25 | 26 | ## pingoo.yml reference 27 | 28 | > You may find non-documented configuration field by reading Pingoo's source code. Please refrain from using them as we provide no guarantees about their stability. 29 | 30 | ```yml 31 | # Listeners are the port that Pingoo exposes and listen to. 32 | listeners: 33 | http: # name of the listener 34 | # valid protocols are: http, https, tcp, tcp+tls 35 | address: http://0.0.0.0:8080 36 | # optional list of service to match for this listener. 37 | # By default (if the service field is not provided) Pingoo will use all the compatible services: 38 | # - http_proxy and static for http / https listeners 39 | # - tcp_proxy for tcp / tcp+tls listeners 40 | services: ["api"] 41 | 42 | # (optional) 43 | tls: 44 | # Automatic Certificate Management Environment (ACME) 45 | acme: 46 | domains: ["pingoo.io"] 47 | 48 | # services are the applications that listeners route traffic to 49 | services: 50 | api: # name of the service 51 | # (optional) expression to filter requests 52 | # match any request / connection if left empty 53 | route: http_request.starts_with("/api") 54 | http_proxy: [] # list of upstreams. Can be left empty if using Docker service discovery 55 | 56 | webapp: 57 | # static site 58 | static: 59 | # root folder to serve the static site / assets 60 | root: /var/www 61 | 62 | # (optional) 63 | rules: 64 | captcha_bots: # name of the rule 65 | # (optional) Expression to match requests to apply the rule. 66 | # If expression is empty, then the rule matches all the requests. 67 | expression: | 68 | !http_request.user_agent.starts_with("Mozilla/") && !http_request.user_agent.contains("curl/") 69 | actions: 70 | - action: captcha 71 | 72 | # (optional) Lists can be used in rule expressions to match against a large number of values 73 | lists: 74 | blocked_ips: # name of the list 75 | type: Ip # type of the individual items of the list. Valid values are: int, ip, string 76 | file: /etc/pingoo/lists/blocked_ip.csv # path to the list 77 | ``` 78 | -------------------------------------------------------------------------------- /docs/tls.md: -------------------------------------------------------------------------------- 1 | --- 2 | date: 2025-09-16T06:00:00Z 3 | title: "Pingoo TLS" 4 | type: "page" 5 | url: "/docs/tls" 6 | --- 7 | 8 | # HTTPS / TLS 9 | 10 | TLS certificates are stored and read from the `/etc/pingoo/tls` folder. 11 | 12 | Private keys must have the `.key` extension and public certificates / certificate chains must have the `.pem` extension. 13 | 14 | For example: 15 | ```bash 16 | $ ls /etc/pingoo/tls 17 | pingoo.io.key 18 | pingoo.io.pem 19 | ``` 20 | 21 | Pingoo automatically parses TLS certificates and match the hostname provided in the Server Name Indication (SNI) of the TLS protocol with the Subject Alternative Names (SANs) of the certificates to know which one to use when serving `https` / `tcp+tls` connections. 22 | 23 | If no certificate is found for the requested domain, a default self-signed certificate is used. 24 | 25 | 26 | ## Automatic HTTPS / TLS (ACME) 27 | 28 | Pingoo supports the Automatic Certificate Management Environment (ACME) protocol in order to provide fully-automated certificate management. 29 | 30 | By default, Pingoo uses [Let's Encrypt](https://letsencrypt.org) to order (free) certificates. 31 | 32 | Example: **pingoo.yml** 33 | ```yml 34 | listeners: 35 | https: 36 | address: https://0.0.0.0 37 | 38 | tls: 39 | acme: 40 | domains: ["pingoo.io"] 41 | ``` 42 | 43 | Pingoo currently doesn't support wildcard certificates when using ACME. 44 | 45 | Pingoo currently only supports the [tls-alpn-01](https://letsencrypt.org/docs/challenge-types/#tls-alpn-01) challenge. It means that one of your TLS listeners must be publicly accessible on the port `443`. 46 | 47 | 48 | Pingoo stores your ACME credentials in the `/etc/pingoo/tls/acme.json` file which is automatically managed. **Do not edit by hand.** 49 | 50 | 51 | ## TLS versions support 52 | 53 | By design, Pingoo only supports TLS version 1.3 (and up in the future). 54 | 55 | TLS 1.3 was introduced in 2018 and is a huge step in security compared to TLS 1.2. 56 | 57 | TLS 1.3 is supported by virtually all browsers and client libraries: https://caniuse.com/tls1-3. Only abandonned bots and vulnerable IoT devices part of a botnet don't support TLS 1.3, therefore it makes no sense to reduce the security of everyone to support these bots. 58 | 59 | 60 | ## Post-Quantum TLS 61 | 62 | Pingoo supports post-quantum cryptography (also known as quantum-resistant cryptography), specifically the `X25519MLKEM768` hybrid key agreement. See [IETF's draft-ietf-tls-ecdhe-mlkem](https://datatracker.ietf.org/doc/draft-ietf-tls-ecdhe-mlkem/) for more information. 63 | 64 | 65 | ## Security 66 | 67 | Pingoo uses AWS' [aws-lc-rs](https://github.com/aws/aws-lc-rs) cryptographic library under the hood, which is formally verified and provide an FIPS mode, to ensure the best security and performance. 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Pingoo logo 3 |

Pingoo

4 |

The fast and secure Load Balancer / API Gateway / Reverse Proxy with built-in service discovery, GeoIP, WAF, bot protection and much more

5 |

6 | Documentation | Read the launch post 7 |

8 |

9 | 10 | Open Source load balancers and reverse proxies are stuck in the past century with a very slow pace of development and most of the important features reserved for "Enterprise Editions" which lead developers to use third-party cloud services, exposing their users' traffic to legal, security and reliability risks. 11 | 12 | Pingoo is a modern Load Balancer / API Gateway / Reverse Proxy that run on your own servers and already have (or will have soon) all the features you expect from managed services and even more. All of that with a huge boost in performance and security thanks to reduced latency and, of course, Rust ;) 13 | 14 | * Automatic and Post-Quantum HTTPS / TLS 15 | * Service Discovery (Docker, DNS...) 16 | * Web Application Firewall (WAF) 17 | * Easy compliance because the data never leaves your servers 18 | * Bot protection and management 19 | * TCP proxying 20 | * GeoIP (country, ASN) 21 | * Static sites 22 | * And much more 23 | 24 | 25 | ## Quickstart 26 | 27 | ```bash 28 | # You have a static site in the www folder 29 | $ ls www 30 | index.html 31 | $ docker run --rm -ti -p 80:80 -v `pwd`/www:/var/wwww pingooio/pingoo 32 | # Pingoo is now listenning on http://0.0.0.0 33 | ``` 34 | 35 | ## Documentation 36 | 37 | See https://pingoo.io 38 | 39 | 40 | ## Social 41 | 42 | Follow us on [Bluesky @pingoo.io](https://bsky.app/profile/pingoo.io) and on Mastodon [@pingooio@mastodon.social](https://mastodon.social/@pingooio) to get the latest updates and technical deep dives 🦀⚡️ 43 | 44 | ## Contributing 45 | 46 | Please open an issue to discuss your idea before submitting a Pull Request. 47 | 48 | Contributions that use AI must be disclosed. 49 | 50 | 51 | ## Support 52 | 53 | Do you have custom needs? Do you want your features to be prioritized? Are you under attack and need help? Do you need support for deploying and self-hosting Pingoo? 54 | 55 | Feel free to reach our team of experts to see how we can help: https://pingoo.io/contact 56 | 57 | 58 | ## Security 59 | 60 | We are committed to make Pingoo the most secure Load Balancer / Reverse Proxy in the universe and beyond. If you've found a security issue in Pingoo, we appreciate your help in disclosing it to us in a responsible manner by contacting us: https://pingoo.io/contact 61 | 62 | 63 | ## License 64 | 65 | MIT. See `LICENSE.txt` 66 | 67 | Forever Open Source. No Open Core or "Enterprise Edition". 68 | -------------------------------------------------------------------------------- /pingoo/listeners/tcp_tls_listener.rs: -------------------------------------------------------------------------------- 1 | use std::{net::SocketAddr, sync::Arc}; 2 | 3 | use tokio::{sync::watch, task::JoinSet}; 4 | use tracing::debug; 5 | 6 | use crate::{ 7 | Error, 8 | config::ListenerConfig, 9 | listeners::{GRACEFUL_SHUTDOWN_TIMEOUT, Listener, accept_tcp_connection, accept_tls_connection, bind_tcp_socket}, 10 | services::TcpService, 11 | tls::TlsManager, 12 | }; 13 | 14 | pub struct TcpAndTlsListener { 15 | name: String, 16 | address: SocketAddr, 17 | socket: Option, 18 | tls_manager: Arc, 19 | service: Arc, 20 | } 21 | 22 | impl TcpAndTlsListener { 23 | pub fn new(config: ListenerConfig, tls_manager: Arc, service: Arc) -> Self { 24 | return TcpAndTlsListener { 25 | name: config.name, 26 | address: config.address, 27 | socket: None, 28 | tls_manager, 29 | service, 30 | }; 31 | } 32 | } 33 | 34 | #[async_trait::async_trait] 35 | impl Listener for TcpAndTlsListener { 36 | fn bind(&mut self) -> Result<(), Error> { 37 | let socket = bind_tcp_socket(self.address, &self.name)?; 38 | self.socket = Some(socket); 39 | return Ok(()); 40 | } 41 | 42 | async fn listen(self: Box, mut shutdown_signal: watch::Receiver<()>) { 43 | let tcp_socket = self 44 | .socket 45 | .expect("You need to bind the listener before calling listen()"); 46 | 47 | let mut connections: JoinSet<_> = JoinSet::new(); 48 | 49 | let tls_server_config = self.tls_manager.get_tls_server_config([]); 50 | 51 | loop { 52 | tokio::select! { 53 | accept_tcp_res = accept_tcp_connection(&tcp_socket, &self.name) => { 54 | let (tcp_stream, client_socket_addr) = match accept_tcp_res { 55 | Ok(connection) => connection, 56 | Err(_) => continue, 57 | }; 58 | 59 | if let Ok(Some(tls_stream)) = 60 | accept_tls_connection(tcp_stream, self.tls_manager.clone(), client_socket_addr, &self.name, tls_server_config.clone()).await 61 | { 62 | let service = self.service.clone(); 63 | connections.spawn(service.serve_connection(Box::new(tls_stream), client_socket_addr)); 64 | }; 65 | }, 66 | _ = shutdown_signal.changed() => { 67 | break; 68 | } 69 | } 70 | } 71 | 72 | // TODO: should we use connections.shutdown()? 73 | tokio::select! { 74 | _ = connections.join_all() => { 75 | debug!("listener {} has gracefully shut down", self.name); 76 | }, 77 | _ = tokio::time::sleep(GRACEFUL_SHUTDOWN_TIMEOUT) => {} 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:24-trixie-slim AS node 2 | 3 | FROM ubuntu:24.04 4 | 5 | ENV TZ="UTC" 6 | RUN echo "${TZ}" > /etc/timezone 7 | 8 | ENV DEBIAN_FRONTEND=noninteractive 9 | RUN apt-get update && apt upgrade -y 10 | RUN apt-get install -y --no-install-recommends \ 11 | # base system. psmisc for killall. openssh-client for git over SSH. socat for docker 12 | bash curl wget psmisc ca-certificates lsb-release openssh-client socat \ 13 | # dev tools. dnsutils for dig. linux-tools-generic for perf. bsdmainutils for hexdump. 14 | git vim make binutils coreutils build-essential pkg-config linux-tools-generic \ 15 | zip htop wrk zstd gdb dnsutils jq b3sum gnupg libimage-exiftool-perl bsdmainutils \ 16 | # Rust toolchain 17 | rustup libfindbin-libs-perl lld mold gcc g++ musl musl-dev musl-tools libc6-dev cmake clang libclang-dev openssl libssl-dev \ 18 | # useful data. mailcap for mimetypes. tzdata for timezones. 19 | mailcap tzdata libpcre3-dev \ 20 | # pingoo specific dependencies 21 | sqlite3 22 | 23 | 24 | # setup node 25 | COPY --from=node /usr/local/bin/node /usr/local/bin/ 26 | COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules 27 | RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \ 28 | && ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx 29 | 30 | RUN npm config set ignore-scripts true 31 | RUN /usr/local/bin/npm i -g npm@latest 32 | 33 | 34 | # Setup dev user 35 | ARG USERNAME=dev 36 | ARG USER_UID=10001 37 | ARG USER_GID=$USER_UID 38 | 39 | RUN adduser \ 40 | --disabled-password \ 41 | --gecos "" \ 42 | --home "/home/${USERNAME}" \ 43 | --shell "/bin/bash" \ 44 | --uid "${USER_UID}" \ 45 | "${USERNAME}" 46 | 47 | 48 | RUN mkdir -p /etc/pingoo 49 | RUN chown -R $USERNAME:$USERNAME /etc/pingoo/ 50 | 51 | USER $USERNAME 52 | 53 | COPY --chown=$USERNAME bashrc /home/$USERNAME/.bashrc 54 | COPY --chown=$USERNAME vimrc /home/$USERNAME/.vimrc 55 | 56 | 57 | # setup Rust 58 | WORKDIR /home/$USERNAME 59 | RUN rustup default stable 60 | 61 | # setup go 62 | # RUN mkdir -p /home/$USERNAME/.local/gopath 63 | # COPY --from=go /usr/local/go /usr/local/go 64 | # RUN /usr/local/go/bin/go telemetry off 65 | 66 | # setup node (again, as a normal user this time) 67 | RUN npm config set ignore-scripts true 68 | 69 | # setup git 70 | RUN git config --global push.autoSetupRemote true 71 | RUN git config --global init.defaultBranch main 72 | 73 | WORKDIR / 74 | 75 | # Setup docker's socket access from docker 76 | USER root 77 | 78 | RUN touch /var/run/docker-host.sock \ 79 | && ln -s /var/run/docker-host.sock /var/run/docker.sock 80 | COPY docker-init.sh /usr/local/share/docker-init.sh 81 | RUN chmod +x /usr/local/share/docker-init.sh 82 | 83 | # VS Code overrides ENTRYPOINT and CMD when executing `docker run` by default. 84 | # Setting the ENTRYPOINT to docker-init.sh will configure non-root access to 85 | # the Docker socket if "overrideCommand": false is set in devcontainer.json. 86 | # The script will also execute CMD if you need to alter startup behaviors. 87 | ENTRYPOINT [ "/usr/local/share/docker-init.sh" ] 88 | CMD [ "sleep", "infinity" ] 89 | 90 | 91 | EXPOSE 8080 8081 8082 8083 8443 92 | -------------------------------------------------------------------------------- /docs/overview.md: -------------------------------------------------------------------------------- 1 | --- 2 | date: 2025-09-16T06:00:00Z 3 | title: "Pingoo" 4 | description: "Performance and Security for everyone" 5 | type: "page" 6 | url: "/" 7 | --- 8 | 9 | # Pingoo Overview 10 | 11 | 99.9999 % of the web uses some kind of reverse proxy or gateway, trillions of requests per day, whether it is to balance load between different services / machines, terminate TLS, apply security rules or block unwarranted traffic. And yet, this fundamental piece of infrastructure has seen very little love and innovation over the years, especially since the beginning of the AI bubble boom. 12 | 13 | Existing load balancers and proxies are either stuck in the last century, or all the interesting features are reserved for "Enterprise Editions". 14 | 15 | Pingoo is our attempt at bringing technical excellence and innovation to this forgotten corner of infrastructure. 16 | 17 | We are not only committed to building the best Load Balancer / API Gateway / Reverse proxy, we are also committed to making it forever Open Source, no strings attached. 18 | 19 | Our mission? Security and Performance for everyone. 20 | 21 | 22 | ## Deployment Patterns 23 | 24 | There are three principal ways to deploy Pingoo: 25 | - As a traditionnal load balancer / reverse proxy 26 | - As a sidecar inside a Docker container, which is particularly handy if you are using a Platform as a Service (PaaS) such as Fly, Render or Heroku to deploy your projects. 27 | - In hybrid mode, where an instance of Pingoo is used for load balancing, and sidecar instances are used as firewalls. 28 | 29 | ![Pingoo deployment modes](/assets/pingoo_deployment_modes.png) 30 | 31 | 32 | ### Load Balancer / Reverse Proxy 33 | 34 | Visit the [services](/docs/services) page to learn how to configure Pingoo as a Load balancer / reverse proxy. 35 | 36 | 37 | ### Sidecar 38 | 39 | Pingoo can also be deployed inside your own docker images and spawn your server as a child process. 40 | 41 | You may like this approach if you are using a Platform as a Service (PaaS) such as Fly, Render or Heroku to deploy your projects and need a WAF or bot management solution. 42 | 43 | 44 | **server.js** 45 | ```javascript 46 | const http = require('http'); 47 | 48 | const ADDRESS = '127.0.0.1' 49 | const PORT = 3000; 50 | 51 | const server = http.createServer((req, res) => { 52 | res.writeHead(200, { 'Content-Type': 'text/plain' }); 53 | res.end('Hello, world!\n'); 54 | }); 55 | 56 | server.listen(PORT, ADDRESS, () => { 57 | console.log(`Server running at http://${ADDRESS}:${PORT}`); 58 | }); 59 | ``` 60 | 61 | **pingoo.yml** 62 | ```yml 63 | child_process: 64 | # the command is executed from the current working directory. NOT relatively from pingoo.yml 65 | command: ["node", "server.js"] 66 | 67 | listeners: 68 | http: 69 | address: http://0.0.0.0:8080 70 | 71 | services: 72 | api: 73 | http_proxy: ["http://127.0.0.1:3000"] 74 | ``` 75 | 76 | **Dockerfile** 77 | ```dockerfile 78 | FROM pingooio/pingoo:latest AS pingoo 79 | FROM node:latest 80 | 81 | # setup pingoo 82 | RUN mkdir -p /etc/pingoo 83 | COPY ./pingoo.yml /etc/pingoo/ 84 | COPY --from=pingoo /bin/pingoo /bin/pingoo 85 | 86 | # setup server 87 | WORKDIR /server 88 | COPY ./server.js ./ 89 | 90 | CMD ["/bin/pingoo"] 91 | 92 | EXPOSE 8080 93 | ``` 94 | 95 | ```bash 96 | $ docker build -t myimage:latest -f Dockerfile . 97 | $ docker run --rm -ti -p 8080:8080 myimage:latest 98 | ``` 99 | -------------------------------------------------------------------------------- /pingoo/main.rs: -------------------------------------------------------------------------------- 1 | use std::process::Stdio; 2 | 3 | use tokio::{process::Command, signal, sync::watch}; 4 | use tracing::{debug, info}; 5 | use tracing_subscriber::{EnvFilter, Layer, layer::SubscriberExt, util::SubscriberInitExt}; 6 | 7 | mod config; 8 | mod server; 9 | 10 | mod captcha; 11 | mod crypto_utils; 12 | mod error; 13 | mod geoip; 14 | mod listeners; 15 | mod lists; 16 | mod rules; 17 | mod serde_utils; 18 | mod service_discovery; 19 | mod services; 20 | mod tls; 21 | 22 | pub use error::Error; 23 | 24 | use crate::{listeners::GRACEFUL_SHUTDOWN_TIMEOUT, server::Server}; 25 | use config::DEFAULT_CONFIG_FILE; 26 | 27 | // We don't use mimalloc in debug builds to speedup compilation 28 | // #[cfg(not(debug_assertions))] 29 | #[global_allocator] 30 | static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; 31 | 32 | #[tokio::main] 33 | async fn main() -> Result<(), Box> { 34 | tracing_subscriber::registry() 35 | .with( 36 | tracing_subscriber::fmt::layer() 37 | .json() 38 | .flatten_event(true) 39 | .with_writer(std::io::stderr) 40 | .with_target(false) 41 | .with_filter(EnvFilter::try_from_env("PINGOO_LOG").unwrap_or_else(|_| EnvFilter::new("info"))), 42 | ) 43 | .try_init() 44 | .unwrap(); 45 | 46 | rustls::crypto::aws_lc_rs::default_provider() 47 | .install_default() 48 | .map_err(|err| Error::Config(format!("error setting up rustls crypto provider: {err:?}")))?; 49 | 50 | let config = config::load_and_validate().await?; 51 | info!( 52 | services = config.services.len(), 53 | listeners = config.listeners.len(), 54 | "configuration successfully loaded from {DEFAULT_CONFIG_FILE}" 55 | ); 56 | 57 | let (shutdown_tx, shutdown_rx) = watch::channel(()); 58 | tokio::spawn(shutdown_signal(shutdown_tx)); 59 | 60 | if let Some(child_process_config) = &config.child_process { 61 | debug!("starting child process: {:?}", &child_process_config.command); 62 | let mut command = Command::new(&child_process_config.command[0]); 63 | if child_process_config.command.len() > 1 { 64 | command.args(&child_process_config.command[1..]); 65 | } 66 | 67 | // TODO: handle situation where the child exit early due to an error. 68 | // Give the child a short window of time and check if it has exited. 69 | command 70 | .stdin(Stdio::null()) 71 | .stdout(Stdio::null()) 72 | .stderr(Stdio::null()) 73 | .spawn() 74 | .map_err(|err| { 75 | Error::Unspecified(format!( 76 | "error starting child process ({:?}): {err}", 77 | &child_process_config.command 78 | )) 79 | })?; 80 | } 81 | 82 | Server::new(config).run(shutdown_rx).await?; 83 | 84 | return Ok(()); 85 | } 86 | 87 | async fn shutdown_signal(shutdown_tx: watch::Sender<()>) { 88 | let ctrl_c = async { 89 | signal::ctrl_c().await.expect("failed to install Ctrl+C handler"); 90 | }; 91 | 92 | let terminate = async { 93 | signal::unix::signal(signal::unix::SignalKind::terminate()) 94 | .expect("failed to install signal handler") 95 | .recv() 96 | .await; 97 | }; 98 | 99 | tokio::select! { 100 | _ = ctrl_c => {}, 101 | _ = terminate => {}, 102 | }; 103 | 104 | info!(timeout = ?GRACEFUL_SHUTDOWN_TIMEOUT, "Shutdown signal received. Shutting down listeners."); 105 | 106 | let _ = shutdown_tx.send(()); 107 | } 108 | -------------------------------------------------------------------------------- /docs/services.md: -------------------------------------------------------------------------------- 1 | --- 2 | date: 2025-09-16T06:00:00Z 3 | title: "Pingoo services" 4 | type: "page" 5 | url: "/docs/services" 6 | --- 7 | 8 | 9 | # Services 10 | 11 | 12 | ## Routing 13 | 14 | 15 | ```yml 16 | services: 17 | api: 18 | route: host.starts_with("api") 19 | http_proxy: ["http://127.0.0.1"] 20 | ``` 21 | 22 | See the [rules page](/docs/rules) to learn Pingoo's expression language and what variables and function are available. 23 | 24 | 25 | ## HTTP Proxy 26 | 27 | ```yml 28 | listeners: 29 | http: 30 | address: http://0.0.0.0:8080 31 | 32 | services: 33 | api: 34 | route: host.starts_with("api") 35 | http_proxy: ["http://api1.myservice.internal", "http://api2.myservice.internal"] 36 | ``` 37 | 38 | 39 | ### HTTP headers 40 | 41 | Pingoo adds the following HTTP headers to requests to upstream servers when used in HTTP proxy mode: 42 | 43 | 44 | `x-forwarded-host`: The original `Host` header. e.g. `example.com`. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-Host 45 | 46 | `X-Forwarded-For`: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-For 47 | 48 | `X-Forwarded-Proto`: `http` or `https`. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-Proto 49 | 50 | `Pingoo-Client-Ip`: The IP address of the client. e.g. `1.2.3.4`. 51 | 52 | 53 | The following headers are available **only** if [geoip](/docs/geoip) is enabled: 54 | 55 | `Pingoo-Client-Country`: The 2-letters codes of the country inferred from the IP address. e.g. `FR` 56 | 57 | `Pingoo-Client-Asn`: The [Autonomous System Number (ASN)](https://en.wikipedia.org/wiki/Autonomous_system_(Internet)) inferred from the IP address. e.g. `123` 58 | 59 | 60 | ## Static 61 | 62 | Pingoo can directly serve static content such as static sites, single page applications and assets. 63 | 64 | ```yml 65 | listeners: 66 | http: 67 | address: http://0.0.0.0:8080 68 | 69 | services: 70 | webapp: 71 | static: 72 | root: /var/www 73 | ``` 74 | 75 | 76 | ## TCP proxy 77 | 78 | **pingoo.yml** 79 | ```yml 80 | listeners: 81 | smtp: 82 | address: tcp://0.0.0.0:25 83 | 84 | services: 85 | smtp_backends: 86 | tcp_proxy: ["tcp://1.2.3.4:25", "tcp://4.3.2.1:25"] 87 | ``` 88 | 89 | 90 | ## Service Discovery 91 | 92 | ### DNS 93 | 94 | Pingoo automatically resolves domains in upstreams. 95 | 96 | ### Docker 97 | 98 | Pingoo automagically discovers containers that are tagged with the `pingoo.service` label: 99 | 100 | ```yml 101 | listeners: 102 | http: 103 | address: http://0.0.0.0:8080 104 | 105 | services: 106 | api: 107 | http_proxy: [] # leave the upstream list empty whehn using Docker service discovery 108 | ``` 109 | 110 | ```bash 111 | docker run -d --label pingoo.service=api my_api_image:latest 112 | ``` 113 | Note that `--label pingoo.service=api` match the service name: `api`. 114 | 115 | 116 | Pingoo requires that your containers expose a single port (e.g. `EXPOSE 8080`). If you containers don't expose any port or expose multiple ports, you will need to tag the port to forward traffic to with the `pingoo.port` label. 117 | 118 | 119 | ```bash 120 | docker run -d --label pingoo.service=api --label pingoo.port=8080 my_api_image:latest 121 | ``` 122 | 123 | 124 | In order to enable docker service discovery Pingoo needs access to the docker socket. If you are running Pingoo inside a docker container you need to bind the docker socket: 125 | 126 | ```bash 127 | docker run -d -v /var/run/docker.sock:/var/run/docker.sock pingooio/pingoo 128 | ``` 129 | 130 | 131 | ## Load balancing 132 | 133 | Pingoo currently load balance requests and connections between upstreams using the state of the art `random` algorithm. 134 | -------------------------------------------------------------------------------- /pingoo/services/tcp_proxy_service.rs: -------------------------------------------------------------------------------- 1 | use std::{net::SocketAddr, sync::Arc, time::Duration}; 2 | 3 | use futures::FutureExt; 4 | use rand::{rng, seq::IndexedRandom}; 5 | use tokio::{ 6 | net::TcpStream, 7 | time::{self, timeout}, 8 | }; 9 | use tracing::{debug, error}; 10 | 11 | use crate::{ 12 | Error, 13 | service_discovery::service_registry::ServiceRegistry, 14 | services::{DynIo, TcpService}, 15 | }; 16 | 17 | pub struct TcpProxyService { 18 | name: String, 19 | service_registry: Arc, 20 | } 21 | 22 | impl TcpProxyService { 23 | pub fn new(name: String, service_registry: Arc) -> Self { 24 | return TcpProxyService { name, service_registry }; 25 | } 26 | } 27 | 28 | #[async_trait::async_trait] 29 | impl TcpService for TcpProxyService { 30 | async fn serve_connection( 31 | self: Arc, 32 | mut inbound_tcp_connection: Box, 33 | _client_socket_address: SocketAddr, 34 | ) { 35 | let mut outbound_stream = match retry( 36 | || { 37 | let self = self.clone(); 38 | async move { 39 | let upstreams = self.service_registry.get_upstreams(&self.name).await; 40 | if upstreams.is_empty() { 41 | return Err(Error::Unspecified("no upstream available".to_string())); 42 | } 43 | 44 | let upstream = upstreams.choose(&mut rng()).unwrap(); 45 | 46 | let tcp_stream = 47 | match timeout(Duration::from_secs(3), TcpStream::connect(upstream.socket_address)).await { 48 | Ok(Ok(stream)) => Ok(stream), 49 | Ok(Err(err)) => Err(Error::Unspecified(format!( 50 | "error connecting to upstream {}: {err}", 51 | upstream.socket_address 52 | ))), 53 | Err(_) => Err(Error::Unspecified(format!( 54 | "error connecting to upstream {}: timeout", 55 | upstream.socket_address 56 | ))), 57 | }?; 58 | 59 | Ok(tcp_stream) 60 | } 61 | }, 62 | 3, 63 | Duration::from_millis(5), 64 | ) 65 | .await 66 | { 67 | Ok(stream) => stream, 68 | Err(err) => { 69 | debug!("[{}]: {err}", self.name,); 70 | return; 71 | } 72 | }; 73 | 74 | tokio::spawn(async move { 75 | tokio::io::copy_bidirectional(&mut inbound_tcp_connection, &mut outbound_stream) 76 | .map(|r| { 77 | if let Err(e) = r { 78 | error!("Failed to transfer; error={e}"); 79 | } 80 | }) 81 | .await 82 | }); 83 | } 84 | } 85 | 86 | pub async fn retry(mut operation: F, retries: usize, delay: Duration) -> Result 87 | where 88 | // `F` is a mutable callable that produces a future each time it’s invoked. 89 | F: FnMut() -> Fut, 90 | // The future must be `Send` because we’ll await it inside an async context. 91 | Fut: Future> + Send, 92 | { 93 | let mut attempt = 0; 94 | 95 | loop { 96 | attempt += 1; 97 | match operation().await { 98 | Ok(val) => return Ok(val), 99 | Err(_) if attempt < retries => { 100 | // If we still have attempts left, wait then try again. 101 | if !delay.is_zero() { 102 | time::sleep(delay).await; 103 | } 104 | // Continue looping for the next attempt. 105 | } 106 | Err(err) => { 107 | // No more retries left – return the last error. 108 | return Err(err); 109 | } 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # By using the FROM --platform= pattern we can reuse some steps between platforms. 2 | # For now we can't use cross-compilation for the build step as not all our build tools support 3 | # cross-compilation. 4 | # https://docs.docker.com/build/building/multi-platform 5 | # https://www.docker.com/blog/faster-multi-platform-builds-dockerfile-cross-compilation-guide/ 6 | 7 | #################################################################################################### 8 | ## Build Javascript projects 9 | #################################################################################################### 10 | FROM --platform=$BUILDPLATFORM node:lts AS builder_js 11 | 12 | RUN apt update && apt upgrade -y && \ 13 | apt install -y libimage-exiftool-perl make 14 | 15 | WORKDIR /pingoo 16 | COPY . ./ 17 | 18 | # build captcha 19 | WORKDIR /pingoo/captcha 20 | RUN make exif 21 | RUN make clean 22 | RUN make install_ci 23 | RUN make build 24 | 25 | 26 | #################################################################################################### 27 | ## Build pingoo 28 | #################################################################################################### 29 | FROM rust:alpine AS pingoo_build 30 | 31 | RUN apk add --no-cache --no-progress \ 32 | git make bash curl wget zip gnupg coreutils gcc g++ zstd binutils ca-certificates upx \ 33 | lld mold musl musl-dev cmake clang clang-dev openssl openssl-dev zstd 34 | RUN update-ca-certificates 35 | 36 | WORKDIR /pingoo 37 | COPY . ./ 38 | RUN make clean 39 | 40 | COPY --from=builder_js /pingoo/captcha/dist/ /pingoo/captcha/dist/ 41 | RUN make build 42 | 43 | 44 | #################################################################################################### 45 | ## This stage is used to get the correct files to the final image 46 | ## We use Debian instead of the traditional Ubuntu because their root certificates (ca-certificates) 47 | ## are certainly more secure. 48 | #################################################################################################### 49 | FROM --platform=$BUILDPLATFORM debian:13-slim AS builder_files 50 | 51 | # appuser 52 | ENV USER=pingoo 53 | ENV UID=10001 54 | 55 | # mailcap is used for content type (MIME type) detection 56 | RUN apt update && apt upgrade -y && \ 57 | apt install -y mailcap ca-certificates adduser wget 58 | RUN update-ca-certificates 59 | 60 | ENV TZ="UTC" 61 | RUN echo "${TZ}" > /etc/timezone 62 | 63 | RUN adduser \ 64 | --disabled-password \ 65 | --gecos "" \ 66 | --home "/nonexistent" \ 67 | --shell "/sbin/nologin" \ 68 | --no-create-home \ 69 | --uid "${UID}" \ 70 | "${USER}" 71 | 72 | # Use the /etc/pingoo folder for configuration 73 | RUN mkdir -p /etc/pingoo 74 | RUN mkdir -p /etc/pingoo/rules 75 | COPY ./assets/pingoo.yml /etc/pingoo/pingoo.yml 76 | RUN chown -R $USER:$USER /etc/pingoo 77 | 78 | # Use the /usr/share/pingoo folder for static data. 79 | # reference: https://en.wikipedia.org/wiki/Filesystem_Hierarchy_Standard 80 | WORKDIR /usr/share/pingoo 81 | RUN wget https://downloads.pingoo.io/geoip.mmdb.zst 82 | RUN chown -R $USER:$USER /usr/share/pingoo 83 | 84 | 85 | #################################################################################################### 86 | ## Final image 87 | #################################################################################################### 88 | FROM scratch 89 | 90 | # /etc/nsswitch.conf and resolv.conf may be used by some DNS resolvers 91 | # /etc/mime.types may be used to detect the MIME type of files 92 | # /etc/passwd \ 93 | # /etc/group \ 94 | COPY --from=builder_files \ 95 | /etc/nsswitch.conf \ 96 | /etc/mime.types \ 97 | /etc/timezone \ 98 | /etc/resolv.conf \ 99 | /etc/ 100 | 101 | COPY --from=builder_files /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 102 | COPY --from=builder_files /usr/share/zoneinfo /usr/share/zoneinfo 103 | 104 | # --chown=pingoo:pingoo 105 | COPY --from=builder_files /etc/pingoo /etc/pingoo 106 | COPY --from=builder_files /usr/share/pingoo /usr/share/pingoo 107 | COPY ./assets/www /var/www 108 | 109 | # Copy our build 110 | COPY --from=pingoo_build /pingoo/dist/pingoo /bin/pingoo 111 | 112 | # Use an unprivileged user 113 | # USER pingoo:pingoo 114 | 115 | # The final working directory 116 | WORKDIR /home/pingoo 117 | 118 | ENTRYPOINT ["/bin/pingoo"] 119 | 120 | EXPOSE 80 121 | -------------------------------------------------------------------------------- /pingoo/listeners/https_listener.rs: -------------------------------------------------------------------------------- 1 | use std::{net::SocketAddr, sync::Arc}; 2 | 3 | use hyper_util::{rt::TokioIo, server::graceful}; 4 | use tokio::sync::watch; 5 | use tracing::debug; 6 | 7 | use crate::{ 8 | Error, 9 | captcha::CaptchaManager, 10 | config::ListenerConfig, 11 | geoip::GeoipDB, 12 | listeners::{ 13 | GRACEFUL_SHUTDOWN_TIMEOUT, Listener, accept_tcp_connection, accept_tls_connection, bind_tcp_socket, 14 | http_listener::serve_http_requests, 15 | }, 16 | rules::Rule, 17 | services::HttpService, 18 | tls::{TLS_ALPN_HTTP2, TLS_ALPN_HTTP11, TlsManager}, 19 | }; 20 | 21 | pub struct HttpsListener { 22 | name: Arc, 23 | address: SocketAddr, 24 | socket: Option, 25 | services: Arc>>, 26 | tls_manager: Arc, 27 | rules: Arc>, 28 | lists: Arc, 29 | geoip: Option>, 30 | captcha_manager: Arc, 31 | } 32 | 33 | impl HttpsListener { 34 | pub fn new( 35 | config: ListenerConfig, 36 | tls_manager: Arc, 37 | services: Vec>, 38 | rules: Arc>, 39 | lists: Arc, 40 | geoip: Option>, 41 | captcha_manager: Arc, 42 | ) -> Self { 43 | return HttpsListener { 44 | name: Arc::new(config.name), 45 | address: config.address, 46 | socket: None, 47 | services: Arc::new(services), 48 | tls_manager, 49 | rules, 50 | lists, 51 | geoip, 52 | captcha_manager, 53 | }; 54 | } 55 | } 56 | 57 | #[async_trait::async_trait] 58 | impl Listener for HttpsListener { 59 | fn bind(&mut self) -> Result<(), Error> { 60 | let socket = bind_tcp_socket(self.address, &self.name)?; 61 | self.socket = Some(socket); 62 | return Ok(()); 63 | } 64 | 65 | async fn listen(self: Box, mut shutdown_signal: watch::Receiver<()>) { 66 | let tcp_socket = self 67 | .socket 68 | .expect("You need to bind the listener before calling listen()"); 69 | 70 | // see HTTP listener to learn how graceful shutdown works for HTTP requests 71 | let graceful_shutdown = graceful::GracefulShutdown::new(); 72 | 73 | let tls_server_config = self 74 | .tls_manager 75 | .get_tls_server_config([TLS_ALPN_HTTP2.to_vec(), TLS_ALPN_HTTP11.to_vec()]); 76 | 77 | loop { 78 | tokio::select! { 79 | accept_tcp_res = accept_tcp_connection(&tcp_socket, &self.name) => { 80 | let (tcp_stream, client_socket_addr) = match accept_tcp_res { 81 | Ok(connection) => connection, 82 | Err(_) => continue, 83 | }; 84 | 85 | let tls_stream = match accept_tls_connection( 86 | tcp_stream, 87 | self.tls_manager.clone(), 88 | client_socket_addr, 89 | &self.name, 90 | tls_server_config.clone(), 91 | ) 92 | .await 93 | { 94 | Ok(Some(tls_stream)) => tls_stream, 95 | _ => continue, 96 | }; 97 | 98 | tokio::spawn(serve_http_requests( 99 | TokioIo::new(tls_stream), 100 | self.services.clone(), 101 | client_socket_addr, 102 | self.address, 103 | self.name.clone(), 104 | self.rules.clone(), 105 | self.lists.clone(), 106 | self.geoip.clone(), 107 | self.captcha_manager.clone(), 108 | true, 109 | graceful_shutdown.watcher(), 110 | )); 111 | }, 112 | _ = shutdown_signal.changed() => { 113 | break; 114 | } 115 | } 116 | } 117 | 118 | tokio::select! { 119 | _ = graceful_shutdown.shutdown() => { 120 | debug!("listener {} has gracefully shut down", self.name); 121 | }, 122 | _ = tokio::time::sleep(GRACEFUL_SHUTDOWN_TIMEOUT) => {} 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /pingoo/lists.rs: -------------------------------------------------------------------------------- 1 | use core::fmt; 2 | use std::{collections::HashMap, str::FromStr, sync::Arc}; 3 | 4 | use ipnetwork::IpNetwork; 5 | use serde::{Deserialize, Serialize}; 6 | use tokio::fs; 7 | use tracing::debug; 8 | 9 | use crate::{Error, config::ListConfig}; 10 | 11 | enum List { 12 | String { items: Vec }, 13 | Int { items: Vec }, 14 | Ip { items: Vec }, 15 | } 16 | 17 | #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] 18 | pub enum ListType { 19 | String, 20 | Int, 21 | Ip, 22 | } 23 | 24 | impl fmt::Display for ListType { 25 | fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 26 | let enum_str = match self { 27 | Self::Int => "Int", 28 | Self::Ip => "Ip", 29 | Self::String => "String", 30 | }; 31 | return write!(formatter, "{}", enum_str); 32 | } 33 | } 34 | 35 | impl FromStr for ListType { 36 | type Err = Error; 37 | 38 | fn from_str(value: &str) -> Result { 39 | match value { 40 | "Int" => Ok(Self::Int), 41 | "Ip" => Ok(Self::Ip), 42 | "String" => Ok(Self::String), 43 | _ => Err(Error::Unspecified(format!("{value} is not a valid ListType"))), 44 | } 45 | } 46 | } 47 | 48 | pub async fn load_lists(lists_config: &HashMap) -> Result, Error> { 49 | // TODO: do we really need to Arc the lists, as under the hood heap-allocated values are already 50 | // Arc 51 | let mut lists = HashMap::with_capacity(lists_config.len()); 52 | 53 | for (list_name, list_config) in lists_config { 54 | let list = load_list(&list_config.file, list_config.r#type).await?; 55 | debug!("list successfully loaded: {list_name} from {}", list_config.file); 56 | lists.insert(list_name.clone(), list); 57 | } 58 | 59 | return Ok(Arc::new(lists_to_bel_value(lists))); 60 | } 61 | 62 | async fn load_list(path: &str, type_: ListType) -> Result { 63 | let file_content = fs::read(path) 64 | .await 65 | .map_err(|err| Error::Config(format!("error reading list {path}: {err}")))?; 66 | 67 | let mut csv_reader = csv::ReaderBuilder::new() 68 | .has_headers(false) 69 | .flexible(true) 70 | .from_reader(file_content.as_slice()); 71 | 72 | let mut list = match type_ { 73 | ListType::String => List::String { items: Vec::new() }, 74 | ListType::Int => List::Int { items: Vec::new() }, 75 | ListType::Ip => List::Ip { items: Vec::new() }, 76 | }; 77 | 78 | let mut line_number = 0; 79 | for record in csv_reader.records() { 80 | line_number += 1; 81 | 82 | let record = record 83 | .map_err(|err| Error::Unspecified(format!("error parsing list {path} at line {line_number}: {err}")))?; 84 | if record.len() > 2 || record.len() < 1 { 85 | return Err(Error::Unspecified(format!( 86 | "error parsing list {path} at line {line_number}: invalid number of columns. Min: 1, Max: 2" 87 | ))); 88 | } 89 | 90 | let item_value = record[0].trim().to_string(); 91 | match &mut list { 92 | List::String { items } => items.push(item_value), 93 | List::Int { items } => { 94 | let item_int: i64 = item_value.parse().map_err(|err| { 95 | Error::Unspecified(format!( 96 | "error parsing list {path} at line {line_number}: error parsing int: {err}" 97 | )) 98 | })?; 99 | items.push(item_int); 100 | } 101 | List::Ip { items } => { 102 | let item: IpNetwork = item_value.parse().map_err(|err| { 103 | Error::Unspecified(format!( 104 | "error parsing list {path} at line {line_number}: error parsing IP network: {err}" 105 | )) 106 | })?; 107 | items.push(item); 108 | } 109 | } 110 | } 111 | 112 | return Ok(list); 113 | } 114 | 115 | fn lists_to_bel_value(lists: HashMap) -> bel::Value { 116 | lists 117 | .into_iter() 118 | .map(|(key, value)| match value { 119 | List::String { items } => (key.clone(), items.into()), 120 | List::Int { items } => (key.clone(), items.into()), 121 | List::Ip { items } => (key.clone(), items.into()), 122 | }) 123 | .collect::>() 124 | .into() 125 | } 126 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | # Release the Docker images. 4 | 5 | # This workflow is composed of 2 stages. First, we build platform-specific Docker images 6 | # in parallel, then, in the final job, we create and push a Docker manifest for the :latest tag 7 | 8 | on: 9 | push: 10 | branches: 11 | - release 12 | 13 | permissions: 14 | packages: write 15 | contents: read 16 | 17 | env: 18 | DOCKER_IMAGE_GITHUB: ghcr.io/pingooio/pingoo 19 | DOCKER_IMAGE: pingooio/pingoo 20 | 21 | jobs: 22 | docker_build: 23 | # Builds are ran on the default runners for x64 and on 'sepcialized' runners for arm64. 24 | # https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/expressions 25 | runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} 26 | strategy: 27 | fail-fast: false 28 | matrix: 29 | arch: 30 | - amd64 31 | - arm64 32 | 33 | steps: 34 | - uses: actions/checkout@ff7abcd0c3c05ccf6adc123a8cd1fd4fb30fb493 # v5.0.0 35 | 36 | - name: Read version from Cargo.toml and export to env 37 | run: echo "PINGOO_VERSION=$(cat Cargo.toml | grep '^version =' | cut -d'"' -f2)" >> "$GITHUB_ENV" 38 | 39 | # - name: Update packages 40 | # run: | 41 | # sudo apt update && sudo apt upgrade -y 42 | 43 | - name: Docker build images 44 | run: | 45 | make docker_build 46 | docker image tag ${{ env.DOCKER_IMAGE }}:latest ${{ env.DOCKER_IMAGE }}:latest-${{ matrix.arch }} 47 | docker image tag ${{ env.DOCKER_IMAGE }}:latest ${{ env.DOCKER_IMAGE_GITHUB }}:latest-${{ matrix.arch }} 48 | docker image tag ${{ env.DOCKER_IMAGE }}:${PINGOO_VERSION} ${{ env.DOCKER_IMAGE }}:${PINGOO_VERSION}-${{ matrix.arch }} 49 | docker image tag ${{ env.DOCKER_IMAGE }}:${PINGOO_VERSION} ${{ env.DOCKER_IMAGE_GITHUB }}:${PINGOO_VERSION}-${{ matrix.arch }} 50 | 51 | - name: Docker images info 52 | run: | 53 | docker images 54 | 55 | - name: Login to container registries 56 | run: | 57 | echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u $ --password-stdin 58 | echo "${{ secrets.DOCKER_HUB_TOKEN }}" | docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} --password-stdin 59 | 60 | - name: Docker push images 61 | run: | 62 | docker push ${{ env.DOCKER_IMAGE }}:latest-${{ matrix.arch }} 63 | docker push ${{ env.DOCKER_IMAGE_GITHUB }}:latest-${{ matrix.arch }} 64 | docker push ${{ env.DOCKER_IMAGE }}:${PINGOO_VERSION}-${{ matrix.arch }} 65 | docker push ${{ env.DOCKER_IMAGE_GITHUB }}:${PINGOO_VERSION}-${{ matrix.arch }} 66 | 67 | 68 | docker_push_manifest: 69 | runs-on: ubuntu-24.04 70 | needs: 71 | - docker_build 72 | steps: 73 | - uses: actions/checkout@ff7abcd0c3c05ccf6adc123a8cd1fd4fb30fb493 # v5.0.0 74 | 75 | - name: Read version from Cargo.toml and export to env 76 | run: echo "PINGOO_VERSION=$(cat Cargo.toml | grep '^version =' | cut -d'"' -f2)" >> "$GITHUB_ENV" 77 | 78 | - name: Login to container registries 79 | run: | 80 | echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u $ --password-stdin 81 | echo "${{ secrets.DOCKER_HUB_TOKEN }}" | docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} --password-stdin 82 | 83 | - name: Push Docker manifest 84 | run: | 85 | docker manifest create ${{ env.DOCKER_IMAGE }}:${PINGOO_VERSION} \ 86 | --amend ${{ env.DOCKER_IMAGE }}:${PINGOO_VERSION}-amd64 \ 87 | --amend ${{ env.DOCKER_IMAGE }}:${PINGOO_VERSION}-arm64 88 | docker manifest push ${{ env.DOCKER_IMAGE }}:${PINGOO_VERSION} 89 | 90 | docker manifest create ${{ env.DOCKER_IMAGE }}:latest \ 91 | --amend ${{ env.DOCKER_IMAGE }}:latest-amd64 \ 92 | --amend ${{ env.DOCKER_IMAGE }}:latest-arm64 93 | docker manifest push ${{ env.DOCKER_IMAGE }}:latest 94 | 95 | 96 | docker manifest create ${{ env.DOCKER_IMAGE_GITHUB }}:${PINGOO_VERSION} \ 97 | --amend ${{ env.DOCKER_IMAGE_GITHUB }}:${PINGOO_VERSION}-amd64 \ 98 | --amend ${{ env.DOCKER_IMAGE_GITHUB }}:${PINGOO_VERSION}-arm64 99 | docker manifest push ${{ env.DOCKER_IMAGE_GITHUB }}:${PINGOO_VERSION} 100 | 101 | docker manifest create ${{ env.DOCKER_IMAGE_GITHUB }}:latest \ 102 | --amend ${{ env.DOCKER_IMAGE_GITHUB }}:latest-amd64 \ 103 | --amend ${{ env.DOCKER_IMAGE_GITHUB }}:latest-arm64 104 | docker manifest push ${{ env.DOCKER_IMAGE_GITHUB }}:latest 105 | -------------------------------------------------------------------------------- /pingoo/services/http_utils.rs: -------------------------------------------------------------------------------- 1 | use std::{net::SocketAddr, sync::Arc}; 2 | 3 | use bytes::Bytes; 4 | use http::{HeaderValue, Request, Response, StatusCode, header}; 5 | use http_body_util::{BodyExt, Full, combinators::BoxBody}; 6 | use hyper::body::Incoming; 7 | use serde::Serialize; 8 | 9 | use crate::{ 10 | config::ServiceConfig, 11 | geoip::CountryCode, 12 | service_discovery::service_registry::ServiceRegistry, 13 | services::{HttpService, http_proxy_service::HttpProxyService, http_static_site_service::StaticSiteService}, 14 | }; 15 | 16 | pub const CACHE_CONTROL_NO_CACHE: HeaderValue = 17 | HeaderValue::from_static("private, no-cache, no-store, must-revalidate"); 18 | pub const CACHE_CONTROL_DYNAMIC: HeaderValue = HeaderValue::from_static("public, no-cache, must-revalidate"); 19 | 20 | pub const USER_AGENT_MAX_LENGTH: usize = 256; 21 | pub const HOSTNAME_MAX_LENGTH: usize = 256; 22 | 23 | #[derive(Debug, Clone)] 24 | pub struct RequestContext { 25 | pub client_address: SocketAddr, 26 | pub server_address: SocketAddr, 27 | pub asn: u32, 28 | pub country: CountryCode, 29 | pub geoip_enabled: bool, 30 | pub tls: bool, 31 | pub host: heapless::String, 32 | } 33 | 34 | #[derive(Clone)] 35 | pub struct RequestExtensionContext(pub Arc); 36 | 37 | // #[derive(Clone)] 38 | // pub struct ReqExtensionCookies(pub Arc>>); 39 | 40 | #[derive(Debug, Serialize)] 41 | pub struct EmptyJsonBody {} 42 | 43 | pub fn new_http_service(config: ServiceConfig, service_registry: Arc) -> Arc { 44 | if config.r#static.is_some() { 45 | return Arc::new(StaticSiteService::new(config)); 46 | } else if config.http_proxy.is_some() { 47 | return Arc::new(HttpProxyService::new(config, service_registry)); 48 | } 49 | 50 | unreachable!("HTTP service type not handled"); 51 | } 52 | 53 | pub fn new_internal_error_response_500() -> Response> { 54 | const INTERNAL_ERROR_MESSAGE: &[u8] = b"500 Internal Server Error"; 55 | let res_body = Full::new(Bytes::from_static(INTERNAL_ERROR_MESSAGE)) 56 | .map_err(|never| match never {}) 57 | .boxed(); 58 | return Response::builder() 59 | .status(StatusCode::INTERNAL_SERVER_ERROR) 60 | .header(header::CACHE_CONTROL, &CACHE_CONTROL_NO_CACHE) 61 | .body(res_body) 62 | .expect("error building new_internal_error_response_500"); 63 | } 64 | 65 | // TODO 66 | pub fn new_bad_gateway_error() -> Response> { 67 | const ERROR_MESSAGE: &[u8] = b"502 Bad Gateway"; 68 | let res_body = Full::new(Bytes::from_static(ERROR_MESSAGE)) 69 | .map_err(|never| match never {}) 70 | .boxed(); 71 | return Response::builder() 72 | .status(StatusCode::BAD_GATEWAY) 73 | .header(header::CACHE_CONTROL, &CACHE_CONTROL_NO_CACHE) 74 | .body(res_body) 75 | .expect("error building new_bad_gateway_error"); 76 | } 77 | 78 | pub fn new_not_found_error() -> Response> { 79 | const NOT_FOUND_ERROR_MESSAGE: &[u8] = b"404 Not Found."; 80 | let res_body = Full::new(Bytes::from_static(NOT_FOUND_ERROR_MESSAGE)) 81 | .map_err(|never| match never {}) 82 | .boxed(); 83 | return Response::builder() 84 | .status(StatusCode::NOT_FOUND) 85 | .header(header::CACHE_CONTROL, &CACHE_CONTROL_NO_CACHE) 86 | .body(res_body) 87 | .expect("error building new_not_found_error_404"); 88 | } 89 | 90 | pub fn new_blocked_response() -> Response> { 91 | const ERROR_MESSAGE: &[u8] = b"Permission Denied"; 92 | let res_body = Full::new(Bytes::from_static(ERROR_MESSAGE)) 93 | .map_err(|never| match never {}) 94 | .boxed(); 95 | return Response::builder() 96 | .status(StatusCode::FORBIDDEN) 97 | .header(header::CACHE_CONTROL, &CACHE_CONTROL_NO_CACHE) 98 | .body(res_body) 99 | .expect("error building blocked_response"); 100 | } 101 | 102 | pub fn new_method_not_allowed_error() -> Response> { 103 | const ERROR_MESSAGE: &[u8] = b"405 Method Not Allowed"; 104 | let res_body = Full::new(Bytes::from_static(ERROR_MESSAGE)) 105 | .map_err(|never| match never {}) 106 | .boxed(); 107 | return Response::builder() 108 | .status(StatusCode::METHOD_NOT_ALLOWED) 109 | .header(header::CACHE_CONTROL, &CACHE_CONTROL_NO_CACHE) 110 | .body(res_body) 111 | .expect("error building new_method_not_allowed_error"); 112 | } 113 | 114 | pub fn get_path(req: &Request) -> &str { 115 | req.uri().path().trim_end_matches('/') 116 | } 117 | -------------------------------------------------------------------------------- /jwt/jwk.rs: -------------------------------------------------------------------------------- 1 | use aws_lc_rs::{encoding::AsBigEndian, signature::KeyPair}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use crate::{Algorithm, Key, KeyCrypto, base64_utils::base64_url_no_padding}; 5 | 6 | #[derive(Debug, Clone, Serialize, Deserialize)] 7 | pub struct Jwks { 8 | pub keys: Vec, 9 | } 10 | 11 | /// a JSON Web Key 12 | /// https://www.rfc-editor.org/rfc/rfc7517 13 | /// https://www.rfc-editor.org/rfc/rfc8037 14 | /// Note: Jwk are not validated during deserialization 15 | #[derive(Debug, Clone, Serialize, Deserialize)] 16 | pub struct Jwk { 17 | pub kid: String, 18 | pub r#use: KeyUse, 19 | #[serde(rename = "alg")] 20 | pub algorithm: Algorithm, 21 | 22 | #[serde(flatten)] 23 | pub crypto: JwkCrypto, 24 | } 25 | 26 | #[derive(Debug, Clone, Serialize, Deserialize)] 27 | #[serde(rename_all = "UPPERCASE", tag = "kty")] 28 | pub enum JwkCrypto { 29 | Okp { 30 | #[serde(rename = "crv")] 31 | curve: OkpCurve, 32 | #[serde(with = "base64_url_no_padding")] 33 | x: Vec, 34 | #[serde(with = "base64_url_no_padding::option")] 35 | d: Option>, 36 | }, 37 | Ec { 38 | #[serde(rename = "crv")] 39 | curve: EcCurve, 40 | #[serde(with = "base64_url_no_padding")] 41 | x: Vec, 42 | #[serde(with = "base64_url_no_padding")] 43 | y: Vec, 44 | #[serde(with = "base64_url_no_padding::option")] 45 | d: Option>, 46 | }, 47 | #[serde(rename = "oct")] 48 | Oct { 49 | #[serde(with = "base64_url_no_padding")] 50 | key: Vec, 51 | }, 52 | } 53 | 54 | #[derive(Copy, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 55 | pub enum KeyUse { 56 | #[serde(rename = "sig")] 57 | Sign, 58 | #[serde(rename = "enc")] 59 | Encrypt, 60 | } 61 | 62 | // https://csrc.nist.gov/pubs/fips/186-5/final 63 | // https://csrc.nist.gov/pubs/sp/800/186/final 64 | // https://www.rfc-editor.org/rfc/rfc8032 65 | #[derive(Copy, Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] 66 | pub enum OkpCurve { 67 | Ed25519, 68 | } 69 | 70 | #[derive(Copy, Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] 71 | pub enum EcCurve { 72 | /// P-256 and SHA-256 73 | #[serde(rename = "P-256")] 74 | P256, 75 | 76 | /// P-521 and SHA-512 77 | #[serde(rename = "P-521")] 78 | P521, 79 | } 80 | 81 | impl From<&Key> for Jwk { 82 | fn from(key: &Key) -> Self { 83 | match &key.crypto { 84 | KeyCrypto::Eddsa { curve, keypair } => { 85 | let public_key = keypair.public_key().as_ref().to_vec(); 86 | let private_key = keypair 87 | .seed() 88 | .expect("error getting seed") 89 | .as_be_bytes() 90 | .expect("error converting Ed25519 seed to bytes") 91 | .as_ref() 92 | .to_vec(); 93 | Jwk { 94 | kid: key.id.clone(), 95 | r#use: KeyUse::Sign, 96 | algorithm: key.algorithm, 97 | crypto: JwkCrypto::Okp { 98 | curve: *curve, 99 | x: public_key, 100 | d: Some(private_key), 101 | }, 102 | } 103 | } 104 | KeyCrypto::Ecdsa { curve, keypair } => { 105 | let mut public_key = keypair.public_key().as_ref(); 106 | // skip the 0x04 byte if present 107 | if (*curve == EcCurve::P256 && public_key.len() == 65) 108 | || (*curve == EcCurve::P521 && public_key.len() == 133) 109 | { 110 | public_key = &public_key[1..]; 111 | } 112 | 113 | let private_key = keypair 114 | .private_key() 115 | .as_be_bytes() 116 | .expect("error converting ECDSA private key to bytes") 117 | .as_ref() 118 | .to_vec(); 119 | 120 | let (x, y) = match *curve { 121 | EcCurve::P256 => (public_key[..32].to_vec(), public_key[32..].to_vec()), 122 | EcCurve::P521 => (public_key[..66].to_vec(), public_key[66..].to_vec()), 123 | }; 124 | Jwk { 125 | kid: key.id.clone(), 126 | r#use: KeyUse::Sign, 127 | algorithm: key.algorithm, 128 | crypto: JwkCrypto::Ec { 129 | curve: *curve, 130 | x: x, 131 | y: y, 132 | d: Some(private_key), 133 | }, 134 | } 135 | } 136 | KeyCrypto::Hmac { 137 | algorithm: _, 138 | key: hmac_key, 139 | } => Jwk { 140 | kid: key.id.clone(), 141 | r#use: KeyUse::Sign, 142 | algorithm: key.algorithm, 143 | crypto: JwkCrypto::Oct { key: hmac_key.clone() }, 144 | }, 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /captcha/src/index.tsx: -------------------------------------------------------------------------------- 1 | 2 | import './index.css'; 3 | 4 | import { render } from 'preact' 5 | import { useComputed, useSignal } from '@preact/signals'; 6 | import { Show } from '@preact/signals/utils'; 7 | import { retry, uint8ArrayToHex } from './utils'; 8 | 9 | render(, document.getElementById('pingoo-captcha')!) 10 | 11 | type ChallengeInitApiResponse = { 12 | challenge: string, 13 | difficulty: number, 14 | } 15 | 16 | type ChallengeVerifyInput = { 17 | nonce: string, 18 | hash: string, 19 | } 20 | 21 | export function Challenge () { 22 | const domain = window.location.hostname; 23 | // let checkbox = signal(false); 24 | let checkboxLoading = useSignal(false); 25 | let verified = useSignal(false); 26 | let error = useSignal(false); 27 | 28 | let message = useComputed(() => { 29 | if (verified.value) { 30 | return 'Success!'; 31 | } else if (checkboxLoading.value) { 32 | return 'Verifying...'; 33 | } 34 | return 'Click on the checkbox'; 35 | }) 36 | 37 | async function onCheckboxClicked(event?: MouseEvent) { 38 | // prevent the checkbox from becoming checked 39 | event?.preventDefault(); 40 | 41 | if (checkboxLoading.value || verified.value) { 42 | return; 43 | } 44 | 45 | error.value = false; 46 | checkboxLoading.value = true; 47 | 48 | try { 49 | const proofOfWorkSettings: ChallengeInitApiResponse = await retry(async () => { 50 | const initRes = await fetch('/__pingoo/captcha/api/init'); 51 | if (initRes.status !== 200) { 52 | throw new Error(await initRes.text()) 53 | } 54 | return await initRes.json(); 55 | }, { delay: 200 }); 56 | 57 | const proofOfWorkResult: ChallengeVerifyInput = await proofOfWork(proofOfWorkSettings.challenge, proofOfWorkSettings.difficulty); 58 | // const proofOfWorkResult: ChallengeVerifyInput = await proofOfWork('/__pingoo/captcha/api/verify', 4); 59 | // console.log(proofOfWorkResult); 60 | 61 | const verifyRes = await fetch('/__pingoo/captcha/api/verify', { 62 | method: 'POST', 63 | headers: { 'Content-Type': 'application/json' }, 64 | body: JSON.stringify(proofOfWorkResult), 65 | }); 66 | 67 | checkboxLoading.value = false; 68 | 69 | // if the challenge has been successfully verified by the server, show it 70 | if (verifyRes.status === 200) { 71 | verified.value = true; 72 | } 73 | 74 | // reload the page to allow access (or redo the challenge if verification has failed) 75 | setTimeout(() => location.reload(), 500); 76 | } catch (err: any) { 77 | console.error(err); 78 | error.value = true; 79 | checkboxLoading.value = false; 80 | } 81 | 82 | return; 83 | } 84 | 85 | return ( 86 |
87 |
88 | 89 |

{ domain }

90 | 91 |

92 | Verify you are human by completing the action below. 93 |

94 | 95 |
96 |
97 | {!checkboxLoading.value && 98 | 101 | } 102 | 103 | 104 | 105 |

{message}

106 |
107 |
108 | 109 | {error.value &&

110 | Oops! Something went wrong. Please reload the page and ensure that your cookies are enabled. 111 |

} 112 | 113 |
114 |
115 | ) 116 | } 117 | 118 | function Loader({ className }: { className: string}) { 119 | return ( 120 |
121 | 122 | 123 | 124 | 125 |
126 | ) 127 | } 128 | 129 | 130 | export type ProofOfWorkResult = { 131 | nonce: string, 132 | hash: string, 133 | } 134 | 135 | export async function proofOfWork(challenge: string, difficulty: number): Promise { 136 | let nonce = 0; 137 | let hash = ''; 138 | const target = '0'.repeat(difficulty); // Create a target string with 'difficulty' number of zeros 139 | 140 | do { 141 | nonce++; 142 | hash = uint8ArrayToHex(new Uint8Array(await window.crypto.subtle.digest("SHA-256", new TextEncoder().encode(challenge+nonce)))); 143 | } while (hash.substring(0, difficulty) !== target); 144 | 145 | return { nonce: nonce.toString(10), hash }; 146 | } 147 | 148 | -------------------------------------------------------------------------------- /docker/src/client.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use bytes::Bytes; 4 | use http_body_util::{BodyExt, Full}; 5 | use hyper::{ 6 | Method, StatusCode, Uri, 7 | client::conn::http1::SendRequest, 8 | header::{CONTENT_TYPE, HOST}, 9 | }; 10 | use hyper_util::rt::TokioIo; 11 | use serde::{Serialize, de::DeserializeOwned}; 12 | use tokio::{net::UnixStream, sync::Mutex}; 13 | use tracing::{debug, error}; 14 | 15 | use crate::{DEFAULT_DOCKER_SOCKET, error::Error}; 16 | 17 | pub struct Client { 18 | socket_path: PathBuf, 19 | // we use the interior mutability pattern to avoid users needing to make the client mut 20 | // each time they want to send a request. 21 | // See here to learn more about the Interior Mutability Pattern 22 | // https://doc.rust-lang.org/book/ch15-05-interior-mutability.html 23 | // socket: Arc>>>>>, 24 | socket: Mutex>>>, 25 | } 26 | 27 | impl Client { 28 | pub fn new(socket_path: Option<&str>) -> Client { 29 | let socket_path = socket_path.unwrap_or(DEFAULT_DOCKER_SOCKET); 30 | let socket_path = PathBuf::from(socket_path); 31 | 32 | return Client { 33 | socket_path: socket_path, 34 | socket: Mutex::new(None), 35 | }; 36 | } 37 | 38 | /// connect to the docker host. 39 | /// Note that you don't necessarily need to call `connect`. The client automatically connects 40 | /// to the Docker host on the first request if `connect` is not called before. 41 | pub async fn connect(&self) -> Result<(), Error> { 42 | if self.socket.lock().await.is_some() { 43 | return Ok(()); 44 | } 45 | 46 | let unix_stream = UnixStream::connect(&self.socket_path) 47 | .await 48 | .map_err(|err| Error::Connecting(err.into()))?; 49 | let stream = TokioIo::new(unix_stream); 50 | 51 | let (sender, conn) = hyper::client::conn::http1::handshake(stream) 52 | .await 53 | .map_err(|err| Error::Connecting(err.into()))?; 54 | debug!("connection established"); 55 | 56 | // spawn a task to poll the connection and drive the HTTP state 57 | tokio::task::spawn(async move { 58 | if let Err(err) = conn.await { 59 | error!("connection error: {:?}", err); 60 | } 61 | }); 62 | 63 | self.socket.lock().await.replace(sender); 64 | 65 | return Ok(()); 66 | } 67 | 68 | pub(crate) async fn send_request( 69 | &self, 70 | path: &str, 71 | query: Option, 72 | body: Option, 73 | ) -> Result { 74 | if self.socket.lock().await.is_none() { 75 | self.connect().await?; 76 | } 77 | 78 | // first we need to prepare the request for hyper 79 | let path_and_query = match query { 80 | Some(query_params) => { 81 | let query_string = serde_urlencoded::to_string(query_params) 82 | .map_err(|err| Error::Unspecified(format!("encoding request's query parameters: {err}")))?; 83 | format!("{path}?{query_string}") 84 | } 85 | None => path.to_string(), 86 | }; 87 | 88 | let hyper_uri = Uri::builder() 89 | .scheme("unix") 90 | .authority("docker") 91 | .path_and_query(path_and_query) 92 | .build() 93 | .map_err(|err| Error::Unspecified(format!("building request's URL: {err}")))?; 94 | 95 | let body = body 96 | .map(|body_data| serde_json::to_vec(&body_data)) 97 | .unwrap_or(Ok(Vec::new())) 98 | .map_err(|err| Error::Unspecified(format!("encoding body to JSON: {err}")))?; 99 | let body_bytes = Bytes::from(body); 100 | 101 | let hyper_request = hyper::Request::builder() 102 | .method(Method::GET) 103 | .uri(hyper_uri) 104 | .header(HOST, "docker") 105 | .header(CONTENT_TYPE, "application/json") 106 | .body(Full::new(body_bytes)) 107 | .map_err(|err| Error::Unspecified(format!("building request: {err}")))?; 108 | 109 | let response = { 110 | let mut socket = self.socket.lock().await; 111 | // we can safely unwrap here as `connect` would have returned an error earlier if the connection 112 | // failed 113 | socket 114 | .as_mut() 115 | .unwrap() 116 | .send_request(hyper_request) 117 | .await 118 | .map_err(|err| Error::Unspecified(format!("sending request: {err}")))? 119 | }; 120 | 121 | if response.status() != StatusCode::OK { 122 | return Err(Error::Unspecified(format!( 123 | "received not OK status code: {}", 124 | response.status() 125 | ))); 126 | } 127 | 128 | // let mut response_body = BytesMut::with_capacity(response.size_hint().upper().unwrap_or(500) as usize); 129 | // while let Some(next) = response.frame().await { 130 | // let frame = next.map_err(|err| Error::Unspecified(format!("reading response: {err}")))?; 131 | // if let Some(chunk) = frame.data_ref() { 132 | // response_body.put(chunk.as_ref()); 133 | // } 134 | // } 135 | 136 | let response_body = response 137 | .collect() 138 | .await 139 | .map_err(|err| Error::Unspecified(format!("reading response: {err}")))? 140 | .to_bytes(); 141 | let res = serde_json::from_slice(&response_body) 142 | .map_err(|err| Error::Unspecified(format!("parsing response: {err}")))?; 143 | 144 | return Ok(res); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /pingoo/geoip.rs: -------------------------------------------------------------------------------- 1 | use core::fmt; 2 | use std::{net::IpAddr, str::FromStr, time::Duration}; 3 | 4 | use maxminddb::MaxMindDBError; 5 | use moka::future::Cache; 6 | use serde::{Deserialize, Deserializer, Serialize, Serializer}; 7 | use tokio::fs; 8 | use tracing::debug; 9 | 10 | use crate::{config, serde_utils}; 11 | 12 | pub struct GeoipDB { 13 | mmdb: maxminddb::Reader>, 14 | cache: Cache, 15 | } 16 | 17 | #[derive(Clone, Debug, Deserialize, Serialize, Copy)] 18 | pub struct GeoipRecord { 19 | #[serde(deserialize_with = "serde_utils::asn::deserialize")] 20 | pub asn: u32, 21 | /// 2-letters country code 22 | pub country: CountryCode, 23 | } 24 | 25 | #[derive(Debug, thiserror::Error)] 26 | pub enum Error { 27 | #[error("address not found: {0}")] 28 | AddressNotFound(IpAddr), 29 | #[error("{0}")] 30 | Unspecified(String), 31 | #[error("mmdb file is not valid: {0}")] 32 | InvalidMmdbFile(#[from] MaxMindDBError), 33 | #[error("{0} is not a valid country code")] 34 | InvalidCountryCode(String), 35 | } 36 | 37 | /// 2-letters country code. 38 | /// We use a custome type to reduce the memory footprint and avoid allocations. 39 | #[derive(Clone, Copy, PartialEq, Eq, Hash)] 40 | pub struct CountryCode([u8; 2]); 41 | 42 | impl GeoipDB { 43 | /// try to load the geoip database from the default paths. 44 | pub async fn load() -> Result, Error> { 45 | let (geoip_db_path, mut mmdb_content) = match read_geoip_db().await? { 46 | Some(path_and_content) => path_and_content, 47 | None => return Ok(None), 48 | }; 49 | 50 | // if the geoip database has the .zst extension, then we consider it to be ZSTD-compressed 51 | if geoip_db_path.ends_with(".zst") { 52 | mmdb_content = zstd::decode_all(mmdb_content.as_slice()).map_err(|err| { 53 | Error::Unspecified(format!("error decompressing geoip database ({geoip_db_path}): {err}")) 54 | })?; 55 | } 56 | 57 | let mmdb_reader = maxminddb::Reader::from_source(mmdb_content)?; 58 | 59 | let cache = Cache::builder() 60 | .max_capacity(50_000) 61 | // Time to live (TTL): 1 hour 62 | .time_to_live(Duration::from_secs(3600)) 63 | .build(); 64 | 65 | debug!("geoip database successfully loaded from {geoip_db_path}"); 66 | 67 | return Ok(Some(GeoipDB { 68 | mmdb: mmdb_reader, 69 | cache, 70 | })); 71 | } 72 | 73 | pub async fn lookup(&self, ip: IpAddr) -> Result { 74 | if ip.is_loopback() || ip.is_multicast() { 75 | return Err(Error::AddressNotFound(ip)); 76 | } 77 | 78 | if let Some(record) = self.cache.get(&ip).await { 79 | return Ok(record); 80 | } 81 | 82 | return match self.mmdb.lookup::(ip) { 83 | Ok(record) => { 84 | // if geoip data is found, cache it for this IP 85 | self.cache.insert(ip, record).await; 86 | Ok(record) 87 | } 88 | Err(MaxMindDBError::AddressNotFoundError(_)) => Err(Error::AddressNotFound(ip)), 89 | Err(err) => Err(Error::Unspecified(format!("geoip: error looking up GeoIP for {ip}: {err}"))), 90 | }; 91 | } 92 | } 93 | 94 | async fn read_geoip_db() -> Result)>, Error> { 95 | for geoip_db_path in config::GEOIP_DATABASE_PATHS { 96 | if fs::try_exists(geoip_db_path) 97 | .await 98 | .map_err(|err| Error::Unspecified(format!("error reading geoip database ({geoip_db_path}): {err}")))? 99 | { 100 | return Ok(Some(( 101 | geoip_db_path.to_string(), 102 | fs::read(geoip_db_path).await.map_err(|err| { 103 | Error::Unspecified(format!("error reading geoip database ({geoip_db_path}): {err}")) 104 | })?, 105 | ))); 106 | } 107 | } 108 | return Ok(None); 109 | } 110 | 111 | impl Default for GeoipRecord { 112 | fn default() -> Self { 113 | GeoipRecord { 114 | asn: 0, 115 | country: CountryCode::from_str("XX").unwrap(), 116 | } 117 | } 118 | } 119 | 120 | impl CountryCode { 121 | pub fn as_str(&self) -> &str { 122 | // safe because we only allow ASCII uppercase letters 123 | // which are valid UTF-8 single-byte characters 124 | unsafe { str::from_utf8_unchecked(&self.0) } 125 | } 126 | } 127 | 128 | impl FromStr for CountryCode { 129 | type Err = Error; 130 | 131 | fn from_str(s: &str) -> Result { 132 | let bytes = s.as_bytes(); 133 | 134 | if bytes.len() != 2 || !bytes.iter().all(|byte| (b'A'..=b'Z').contains(byte)) { 135 | return Err(Error::InvalidCountryCode(s.to_string())); 136 | } 137 | 138 | let mut arr = [0u8; 2]; 139 | arr.copy_from_slice(bytes); 140 | Ok(CountryCode(arr)) 141 | } 142 | } 143 | 144 | impl fmt::Debug for CountryCode { 145 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 146 | f.debug_tuple("CountryCode").field(&self.as_str()).finish() 147 | } 148 | } 149 | 150 | impl fmt::Display for CountryCode { 151 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 152 | f.write_str(self.as_str()) 153 | } 154 | } 155 | 156 | impl Serialize for CountryCode { 157 | fn serialize(&self, serializer: S) -> Result 158 | where 159 | S: Serializer, 160 | { 161 | serializer.serialize_str(self.as_str()) 162 | } 163 | } 164 | 165 | impl<'de> Deserialize<'de> for CountryCode { 166 | fn deserialize(deserializer: D) -> Result 167 | where 168 | D: Deserializer<'de>, 169 | { 170 | use serde::de::Error as _; 171 | let s = String::deserialize(deserializer)?; 172 | CountryCode::from_str(&s).map_err(|e| D::Error::custom(e.to_string())) 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /pingoo/listeners/mod.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | net::{IpAddr, SocketAddr}, 3 | sync::Arc, 4 | time::Duration, 5 | }; 6 | 7 | use rustls::{ServerConfig, server::Acceptor}; 8 | use socket2::{Domain, Socket, Type}; 9 | use tokio::{io::AsyncWriteExt, net::TcpStream, sync::watch}; 10 | use tokio_rustls::{LazyConfigAcceptor, server::TlsStream}; 11 | use tracing::debug; 12 | 13 | use crate::{ 14 | Error, 15 | tls::{TlsManager, acme::is_tls_alpn_challenge}, 16 | }; 17 | 18 | mod http_listener; 19 | mod https_listener; 20 | mod tcp_listener; 21 | mod tcp_tls_listener; 22 | 23 | pub use http_listener::HttpListener; 24 | pub use https_listener::HttpsListener; 25 | pub use tcp_listener::TcpListener; 26 | pub use tcp_tls_listener::TcpAndTlsListener; 27 | 28 | pub const GRACEFUL_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(20); 29 | 30 | /// Listeners handle connections and dispatch HTTP requests to services 31 | #[async_trait::async_trait] 32 | pub trait Listener: Send + Sync { 33 | fn bind(&mut self) -> Result<(), Error>; 34 | async fn listen(self: Box, shutdown_signal: watch::Receiver<()>); 35 | } 36 | 37 | //////////////////////////////////////////////////////////////////////////////////////////////////// 38 | // utils 39 | //////////////////////////////////////////////////////////////////////////////////////////////////// 40 | 41 | pub fn bind_tcp_socket(address: SocketAddr, listener_name: &str) -> Result { 42 | // configure the socket to use the SO_REUSEADDR and SO_REUSEPORT options 43 | let socket_domain = match &address.ip() { 44 | IpAddr::V4(_) => Domain::IPV4, 45 | IpAddr::V6(_) => Domain::IPV6, 46 | }; 47 | let socket2_socket = Socket::new(socket_domain, Type::STREAM, None).map_err(|err| Error::Listening { 48 | listener: listener_name.to_string(), 49 | address: address, 50 | err: err, 51 | })?; 52 | socket2_socket.set_nonblocking(true).map_err(|err| Error::Listening { 53 | listener: listener_name.to_string(), 54 | address: address, 55 | err: err, 56 | })?; 57 | socket2_socket.set_reuse_port(true).map_err(|err| Error::Listening { 58 | listener: listener_name.to_string(), 59 | address: address, 60 | err: err, 61 | })?; 62 | socket2_socket.set_reuse_address(true).map_err(|err| Error::Listening { 63 | listener: listener_name.to_string(), 64 | address: address, 65 | err: err, 66 | })?; 67 | 68 | socket2_socket.bind(&address.into()).map_err(|err| Error::Listening { 69 | listener: listener_name.to_string(), 70 | address: address, 71 | err: err, 72 | })?; 73 | // tokio's TcpListener::bind use a value of 1024 74 | socket2_socket.listen(2048).map_err(|err| Error::Listening { 75 | listener: listener_name.to_string(), 76 | address: address, 77 | err: err, 78 | })?; 79 | 80 | // convert the socket2 Socket into tokio TcpListener 81 | let std_listener: std::net::TcpListener = socket2_socket.into(); 82 | std_listener.set_nonblocking(true).map_err(|err| Error::Listening { 83 | listener: listener_name.to_string(), 84 | address: address, 85 | err: err, 86 | })?; 87 | let listener = tokio::net::TcpListener::from_std(std_listener).map_err(|err| Error::Listening { 88 | listener: listener_name.to_string(), 89 | address: address, 90 | err: err, 91 | })?; 92 | return Ok(listener); 93 | } 94 | 95 | async fn accept_tcp_connection( 96 | listener: &tokio::net::TcpListener, 97 | listener_name: &str, 98 | ) -> Result<(TcpStream, SocketAddr), ()> { 99 | let connection = match listener.accept().await { 100 | Ok(connection) => connection, 101 | Err(err) => { 102 | debug!(listener = listener_name, "error accepting TCP connection: {err}",); 103 | return Err(()); 104 | } 105 | }; 106 | 107 | debug!(listener = listener_name, client = ?connection.1,"TCP connection accepted"); 108 | return Ok(connection); 109 | } 110 | 111 | // returns Ok(None) if it's an ACME tls-alpn-01 connection 112 | async fn accept_tls_connection( 113 | tcp_stream: IO, 114 | tls_manager: Arc, 115 | client_socket_addr: SocketAddr, 116 | listener_name: &str, 117 | tls_server_config: Arc, 118 | ) -> Result>, ()> { 119 | let tls_start_handshake = match LazyConfigAcceptor::new(Acceptor::default(), tcp_stream).await { 120 | Ok(tls_start_handshake) => tls_start_handshake, 121 | Err(err) => { 122 | debug!(listener = listener_name, client = ?client_socket_addr, "error accepting TLS connection: {err:?}"); 123 | return Err(()); 124 | } 125 | }; 126 | 127 | let client_hello = tls_start_handshake.client_hello(); 128 | 129 | // handle ACME tls-alpn-01 challenges 130 | if is_tls_alpn_challenge(&client_hello) { 131 | let tls_config_acme = tls_manager.get_tls_alpn_01_server_config(&client_hello).await; 132 | let mut stream = match tls_start_handshake.into_stream(tls_config_acme).await { 133 | Ok(stream) => stream, 134 | Err(err) => { 135 | debug!(listener = listener_name, client = ?client_socket_addr, "error converting TLS stream to TCP stream for ACME: {err:?}"); 136 | return Err(()); 137 | } 138 | }; 139 | let _ = stream.shutdown().await; 140 | return Ok(None); 141 | } 142 | 143 | let tcp_stream = match tls_start_handshake.into_stream(tls_server_config).await { 144 | Ok(tcp_stream) => tcp_stream, 145 | Err(err) => { 146 | debug!(listener = listener_name, client = ?client_socket_addr, "error converting TLS stream to TCP stream: {err:?}"); 147 | return Err(()); 148 | } 149 | }; 150 | 151 | debug!(listener = listener_name, client = ?client_socket_addr, "TLS connection accepted"); 152 | 153 | return Ok(Some(tcp_stream)); 154 | } 155 | -------------------------------------------------------------------------------- /pingoo/service_discovery/dns.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{HashMap, HashSet}, 3 | net::{IpAddr, Ipv4Addr, SocketAddr}, 4 | time::Duration, 5 | }; 6 | 7 | use hickory_resolver::{Resolver, config::ResolverOpts, name_server::TokioConnectionProvider}; 8 | 9 | use crate::{ 10 | Error, 11 | config::{ServiceConfig, UpstreamConfig}, 12 | service_discovery::service_registry::{ServiceDiscoverer, Upstream}, 13 | }; 14 | 15 | type DnsResolver = Resolver; 16 | 17 | pub struct DnsServiceDiscoverer { 18 | dns_resolver: DnsResolver, 19 | hosts: HashMap>, 20 | } 21 | 22 | struct UpstreamHost { 23 | hostname: String, 24 | tls: bool, 25 | port: u16, 26 | } 27 | 28 | impl DnsServiceDiscoverer { 29 | pub fn new(services_config: &[ServiceConfig]) -> Self { 30 | let hosts = get_upstream_hosts(services_config); 31 | 32 | let dns_resolver = Resolver::builder_tokio() 33 | .expect("error building DNS resolver") 34 | .with_options(default_resolver_opts()) 35 | .build(); 36 | 37 | // AsyncResolver::tokio( 38 | // // ResolverConfig::from_parts(None, vec![], nameserver_config_group), 39 | // ResolverConfig::cloudflare_https(), 40 | // default_resolver_opts(), 41 | // ); 42 | 43 | return DnsServiceDiscoverer { dns_resolver, hosts }; 44 | } 45 | } 46 | 47 | #[async_trait::async_trait] 48 | impl ServiceDiscoverer for DnsServiceDiscoverer { 49 | async fn discover(&self) -> Result>, Error> { 50 | let mut ret = HashMap::with_capacity(self.hosts.len()); 51 | for (service, upstream_hosts) in &self.hosts { 52 | let mut upstreams = Vec::with_capacity(upstream_hosts.len()); 53 | for upstream_host in upstream_hosts { 54 | let ips: Vec = match self.dns_resolver.lookup_ip(&upstream_host.hostname).await { 55 | Ok(records) => records.iter().collect(), 56 | Err(_) => Vec::new(), // TODO: log error? 57 | }; 58 | let host_upstreams: Vec = ips 59 | .iter() 60 | .map(|ip| { 61 | // if self.ipv4_only { 62 | // if socket_address.is_ipv4() { 63 | // return Some(socket_address); 64 | // } else { 65 | // return None; 66 | // } 67 | // } 68 | 69 | // the loopback IPv6 address can cause problems: let's say that upstream is 70 | // "localhost" which resolves to 127.0.01 and [::1] 71 | // if the server listens on 0.0.0.0, any request to [::1] won't be able to 72 | // connect to the upstream 73 | if ip.is_ipv6() && ip.is_loopback() { 74 | return SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), upstream_host.port); 75 | } 76 | return SocketAddr::new(*ip, upstream_host.port); 77 | }) 78 | // dedup ips 79 | .collect::>() 80 | .into_iter() 81 | .map(|socket_address| Upstream { 82 | socket_address, 83 | tls: upstream_host.tls, 84 | hostname: upstream_host.hostname.clone(), 85 | }) 86 | .collect(); 87 | 88 | upstreams.extend(host_upstreams); 89 | } 90 | ret.insert(service.clone(), upstreams); 91 | } 92 | 93 | return Ok(ret); 94 | } 95 | } 96 | 97 | pub fn default_resolver_opts() -> ResolverOpts { 98 | let mut opts = ResolverOpts::default(); 99 | opts.timeout = Duration::from_secs(8); 100 | // opts.shuffle_dns_servers = true; 101 | opts.positive_min_ttl = Some(Duration::from_secs(60)); 102 | opts.positive_max_ttl = Some(Duration::from_secs(7200)); 103 | opts.negative_max_ttl = Some(Duration::from_secs(1800)); 104 | return opts; 105 | } 106 | 107 | fn get_upstream_hosts(services_config: &[ServiceConfig]) -> HashMap> { 108 | services_config 109 | .iter() 110 | .map(|service_config| { 111 | let name = service_config.name.clone(); 112 | 113 | if let Some(upstreams) = &service_config.http_proxy { 114 | return ( 115 | name, 116 | upstreams 117 | .iter() 118 | .filter_map(|upstream| match upstream { 119 | UpstreamConfig::IPAddress(_) => None, 120 | UpstreamConfig::Domain { hostname, tls, port } => Some(UpstreamHost { 121 | hostname: hostname.clone(), 122 | tls: *tls, 123 | port: *port, 124 | }), 125 | }) 126 | .collect(), 127 | ); 128 | } 129 | if let Some(upstreams) = &service_config.tcp_proxy { 130 | return ( 131 | name, 132 | upstreams 133 | .iter() 134 | .filter_map(|upstream| match upstream { 135 | UpstreamConfig::IPAddress(_) => None, 136 | UpstreamConfig::Domain { hostname, tls, port } => Some(UpstreamHost { 137 | hostname: hostname.clone(), 138 | tls: *tls, 139 | port: *port, 140 | }), 141 | }) 142 | .collect(), 143 | ); 144 | } else { 145 | return (name, Vec::new()); 146 | } 147 | }) 148 | .collect() 149 | } 150 | -------------------------------------------------------------------------------- /pingoo/server.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, sync::Arc}; 2 | 3 | use tokio::{sync::watch, task::JoinSet}; 4 | 5 | use crate::{ 6 | captcha::CaptchaManager, 7 | geoip::GeoipDB, 8 | listeners::Listener, 9 | lists::load_lists, 10 | service_discovery::service_registry::ServiceRegistry, 11 | services::{HttpService, TcpService, http_utils::new_http_service, tcp_proxy_service::TcpProxyService}, 12 | }; 13 | use tracing::{info, warn}; 14 | 15 | use crate::{ 16 | config::{Config, ListenerProtocol}, 17 | error::Error, 18 | listeners::{self}, 19 | tls::TlsManager, 20 | }; 21 | 22 | /// The Server binds the listeners. 23 | #[derive(Debug)] 24 | pub struct Server { 25 | config: Config, 26 | } 27 | 28 | impl Server { 29 | pub fn new(config: Config) -> Server { 30 | return Server { config: config }; 31 | } 32 | 33 | pub async fn run(self, shutdown_signal: watch::Receiver<()>) -> Result<(), Error> { 34 | let mut listeners_handles = JoinSet::new(); 35 | 36 | let service_registry = 37 | Arc::new(ServiceRegistry::new(&self.config.service_discovery, &self.config.services).await?); 38 | service_registry.clone().start_in_background(); 39 | 40 | let geoip_db = GeoipDB::load().await?.map(|geoip_db| Arc::new(geoip_db)); 41 | if geoip_db.is_none() { 42 | warn!("geoip database not found. GeoIP lookups are disabled."); 43 | } 44 | 45 | let captcha_manager = Arc::new(CaptchaManager::new().await?); 46 | 47 | let lists = load_lists(&self.config.lists).await?; 48 | 49 | let tcp_services: HashMap> = self 50 | .config 51 | .services 52 | .iter() 53 | .filter(|service_config| service_config.tcp_proxy.is_some()) 54 | .map(|service_config| { 55 | ( 56 | service_config.name.clone(), 57 | Arc::new(TcpProxyService::new(service_config.name.clone(), service_registry.clone())) 58 | as Arc, 59 | ) 60 | }) 61 | .collect(); 62 | 63 | let http_services: HashMap> = self 64 | .config 65 | .services 66 | .into_iter() 67 | .filter(|service_config| service_config.http_proxy.is_some() || service_config.r#static.is_some()) 68 | .map(|service_config| { 69 | ( 70 | service_config.name.clone(), 71 | new_http_service(service_config, service_registry.clone()), 72 | ) 73 | }) 74 | .collect(); 75 | 76 | let rules = Arc::new(self.config.rules); 77 | 78 | let tls_manager = Arc::new(TlsManager::new(&self.config.tls).await?); 79 | tls_manager.start_acme_in_background(); 80 | 81 | for listener_config in self.config.listeners { 82 | let listener_address = listener_config.address; 83 | let listener_name = listener_config.name.clone(); 84 | let listener_protocol = listener_config.protocol; 85 | 86 | let mut listener: Box = match listener_protocol { 87 | ListenerProtocol::Tcp => { 88 | let tcp_service_for_listener = tcp_services 89 | .get(&listener_config.services[0]) 90 | .expect("TCP service not found for tcp listener") 91 | .clone(); 92 | Box::new(listeners::TcpListener::new(listener_config, tcp_service_for_listener)) 93 | } 94 | ListenerProtocol::TcpAndTls => { 95 | let tcp_service_for_listener = tcp_services 96 | .get(&listener_config.services[0]) 97 | .expect("TCP service not found for tcp+tls listener") 98 | .clone(); 99 | Box::new(listeners::TcpAndTlsListener::new( 100 | listener_config, 101 | tls_manager.clone(), 102 | tcp_service_for_listener, 103 | )) 104 | } 105 | ListenerProtocol::Http => { 106 | let http_services_for_listener = listener_config 107 | .services 108 | .iter() 109 | .map(|service| http_services.get(service).unwrap().clone()) 110 | .collect(); 111 | Box::new(listeners::HttpListener::new( 112 | listener_config, 113 | http_services_for_listener, 114 | rules.clone(), 115 | lists.clone(), 116 | geoip_db.clone(), 117 | captcha_manager.clone(), 118 | )) 119 | } 120 | ListenerProtocol::Https => { 121 | let http_services_for_listener = listener_config 122 | .services 123 | .iter() 124 | .map(|service| http_services.get(service).unwrap().clone()) 125 | .collect(); 126 | Box::new(listeners::HttpsListener::new( 127 | listener_config, 128 | tls_manager.clone(), 129 | http_services_for_listener, 130 | rules.clone(), 131 | lists.clone(), 132 | geoip_db.clone(), 133 | captcha_manager.clone(), 134 | )) 135 | } 136 | }; 137 | 138 | info!( 139 | listener = listener_name, 140 | "Starting listener {listener_name} on {listener_protocol}://{listener_address}" 141 | ); 142 | 143 | listener.bind()?; 144 | listeners_handles.spawn(listener.listen(shutdown_signal.clone())); 145 | } 146 | 147 | listeners_handles.join_all().await; 148 | 149 | return Ok(()); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /pingoo/service_discovery/docker.rs: -------------------------------------------------------------------------------- 1 | use docker::model::ListContainersOptions; 2 | use moka::future::Cache; 3 | use std::{ 4 | collections::HashMap, 5 | net::{IpAddr, SocketAddr}, 6 | sync::Arc, 7 | time::Duration, 8 | }; 9 | use tokio::{fs, sync::Mutex}; 10 | use tracing::{info, warn}; 11 | 12 | use crate::{ 13 | Error, 14 | config::ServiceDiscoveryConfig, 15 | service_discovery::service_registry::{ServiceDiscoverer, Upstream}, 16 | }; 17 | 18 | pub struct DockerServiceDiscoverer { 19 | docker_client: Option>>, 20 | /// containers that have a problem and we have already issued a warning, to avoid flooding the logs 21 | /// we use a cache to avoid memory leaks where containers would add up and never be freed. 22 | warned_containers: Cache, 23 | } 24 | 25 | impl DockerServiceDiscoverer { 26 | pub async fn new(config: &ServiceDiscoveryConfig) -> Result { 27 | let docker_client = match fs::metadata(&config.docker.socket).await { 28 | Ok(_) => Ok(Some(Arc::new(Mutex::new(::docker::Client::new(Some(&config.docker.socket)))))), 29 | Err(err) if err.kind() == std::io::ErrorKind::NotFound => { 30 | info!( 31 | "docker socket ({}) not found. Docker service discovery disabled.", 32 | config.docker.socket 33 | ); 34 | Ok(None) 35 | } 36 | Err(err) => Err(Error::Config(format!("error reading docker socket: {err}"))), 37 | }?; 38 | 39 | let warned_containers = Cache::builder().time_to_idle(Duration::from_secs(600)).build(); 40 | 41 | return Ok(DockerServiceDiscoverer { 42 | docker_client, 43 | warned_containers, 44 | }); 45 | } 46 | } 47 | 48 | #[async_trait::async_trait] 49 | impl ServiceDiscoverer for DockerServiceDiscoverer { 50 | async fn discover(&self) -> Result>, Error> { 51 | let mut new_upstreams = HashMap::new(); 52 | if self.docker_client.is_none() { 53 | return Ok(new_upstreams); 54 | } 55 | 56 | let mut docker_filters = HashMap::new(); 57 | docker_filters.insert("label".to_string(), vec!["pingoo.service".to_string()]); 58 | let containers = self 59 | .docker_client 60 | .as_ref() 61 | .unwrap() 62 | .lock() 63 | .await 64 | .list_containers(Some(ListContainersOptions { 65 | filters: docker_filters, 66 | ..Default::default() 67 | })) 68 | .await 69 | .map_err(|err| Error::Unspecified(format!("discovering Docker services: {err}")))?; 70 | 71 | for container in containers { 72 | // container. 73 | let container_id = container.id.unwrap_or_default(); 74 | let labels = container.labels.unwrap_or_default(); 75 | if labels.get("pingoo.service").is_none() { 76 | continue; 77 | } 78 | let pingoo_service_name = labels.get("pingoo.service").unwrap(); 79 | 80 | // if the label pingoo.port is present, use it 81 | let port = match labels.get("pingoo.port") { 82 | Some(port_str) => match port_str.parse::() { 83 | Ok(port) => port, 84 | Err(err) => { 85 | if !self.warned_containers.contains_key(&container_id) { 86 | warn!( 87 | "pingoo.port={port_str} is not valid for service {pingoo_service_name} (container: {container_id}): {err}" 88 | ); 89 | self.warned_containers.insert(container_id, ()).await; 90 | } 91 | continue; 92 | } 93 | }, 94 | None => { 95 | // otherwise, use the exposed ports 96 | match container.ports { 97 | Some(ports) if ports.len() > 0 => { 98 | // we currently use the first exposed port 99 | ports[0].private_port 100 | } 101 | _ => { 102 | if !self.warned_containers.contains_key(&container_id) { 103 | warn!("no port found for service {pingoo_service_name} (container: {container_id})"); 104 | self.warned_containers.insert(container_id, ()).await; 105 | } 106 | continue; 107 | } 108 | } 109 | } 110 | }; 111 | 112 | let container_ip = container 113 | .network_settings 114 | .map(|network_settings| network_settings.networks.unwrap_or_default()) 115 | .unwrap_or_default() 116 | .get("bridge") 117 | .map(|endpoint_settings| endpoint_settings.ip_address.clone().unwrap_or_default()); 118 | if container_ip.is_none() { 119 | if !self.warned_containers.contains_key(&container_id) { 120 | warn!( 121 | "container {} (service: {pingoo_service_name}) has no ip address for the bridge network", 122 | &container_id 123 | ); 124 | self.warned_containers.insert(container_id, ()).await; 125 | } 126 | continue; 127 | } 128 | 129 | let container_ip_str = container_ip.unwrap(); 130 | let container_ip: IpAddr = match container_ip_str.parse() { 131 | Ok(ip) => ip, 132 | Err(err) => { 133 | if !self.warned_containers.contains_key(&container_id) { 134 | warn!( 135 | "container {container_id} (service: {pingoo_service_name}) has not a valid IP address ({container_ip_str}): {err}", 136 | ); 137 | self.warned_containers.insert(container_id, ()).await; 138 | } 139 | continue; 140 | } 141 | }; 142 | 143 | // this container is valid, we can remove it from the warned containers 144 | if self.warned_containers.contains_key(&container_id) { 145 | self.warned_containers.remove(&container_id).await; 146 | } 147 | 148 | new_upstreams 149 | .entry(pingoo_service_name.clone()) 150 | .or_insert(Vec::new()) 151 | .push(Upstream { 152 | socket_address: SocketAddr::new(container_ip, port), 153 | tls: false, 154 | hostname: container_id, 155 | }); 156 | } 157 | 158 | return Ok(new_upstreams); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "pingoo" 3 | version = "0.14.7" 4 | edition = "2024" 5 | 6 | [[bin]] 7 | name = "pingoo" 8 | path = "pingoo/main.rs" 9 | 10 | # See the link below for more information and the default profiles 11 | # https://doc.rust-lang.org/cargo/reference/profiles.html 12 | [profile.release] 13 | opt-level = 3 14 | strip = true 15 | lto = true 16 | codegen-units = 1 17 | 18 | 19 | # These settings should improve compile time (link time) during dev by avoiding to link and include debugger data. 20 | # It seems to be especially faster on machines with slow disks. 21 | [profile.dev] 22 | debug = 0 23 | strip = "debuginfo" 24 | 25 | [dependencies] 26 | docker = { path = "./docker" } 27 | jwt = { path = "./jwt" } 28 | captcha = { path = "./captcha" } 29 | rules = { path = "./rules" } 30 | 31 | async-trait = { workspace = true } 32 | aws-lc-rs = { workspace = true } 33 | base64 = { workspace = true } 34 | bel = { workspace = true } 35 | bytes = { workspace = true } 36 | chrono = { workspace = true, features = ["clock", "oldtime", "serde", "std" ] } 37 | cookie = { workspace = true, features = ["percent-encode"] } 38 | csv = { workspace = true } 39 | dashmap = { workspace = true } 40 | futures = { workspace = true } 41 | heapless = { workspace = true, features = ["serde"] } 42 | hex = { workspace = true } 43 | hickory-resolver = { workspace = true, features = ["https-aws-lc-rs", "webpki-roots", "tls-aws-lc-rs"] } 44 | http = { workspace = true } 45 | http-body-util = { workspace = true } 46 | hyper = { workspace = true, features = ["full"] } 47 | hyper-rustls = { workspace = true, features = ["http2", "http1"] } 48 | hyper-util = { workspace = true, features = ["full"] } 49 | indexmap = { workspace = true, features = ["serde"] } 50 | instant-acme = { workspace = true } 51 | ipnetwork = { workspace = true, features = ["serde"] } 52 | maxminddb = { workspace = true } 53 | mimalloc = { workspace = true } 54 | mime_guess = { workspace = true } 55 | moka = { workspace = true, features = ["future"] } 56 | rand = { workspace = true } 57 | rcgen = { workspace = true, default-features = false, features = ["crypto", "aws_lc_rs", "pem"]} 58 | # disable TLS 1.2 support 59 | rustls = { workspace = true, default-features = false, features = ["aws_lc_rs", "logging", "prefer-post-quantum", "std"] } 60 | rustls-pemfile = { workspace = true } 61 | serde = { workspace = true, features = ["derive"] } 62 | serde_json = { workspace = true } 63 | serde_yaml = { workspace = true } 64 | socket2 = { workspace = true } 65 | thiserror = { workspace = true } 66 | tokio = { workspace = true, features = ["full"] } 67 | # disable TLS 1.2 support 68 | tokio-rustls = { workspace = true, default-features = false, features = ["aws_lc_rs", "logging"] } 69 | tokio-util = { workspace = true, features = ["full"] } 70 | tracing = { workspace = true } 71 | tracing-subscriber = { workspace = true, default-features = false, features = ["smallvec", "fmt", "tracing-log", "std", "env-filter", "json"] } 72 | url = { workspace = true } 73 | uuid = { workspace = true } 74 | wildcard = { workspace = true } 75 | x509-parser = { workspace = true } 76 | zeroize = { workspace = true, features = ["simd", "derive"] } 77 | zstd = { workspace = true } 78 | 79 | 80 | 81 | [workspace] 82 | resolver = "2" 83 | 84 | members = [ 85 | ".", 86 | "captcha", 87 | "docker", 88 | "jwt", 89 | 90 | "pong", 91 | ] 92 | 93 | 94 | [workspace.dependencies] 95 | base64 = { git = "https://github.com/pingooio/stdx-rs", branch = "main" } 96 | bel = { git = "https://github.com/pingooio/stdx-rs", branch = "main" } 97 | embed = { git = "https://github.com/pingooio/stdx-rs", branch = "main" } 98 | hex = { git = "https://github.com/pingooio/stdx-rs", branch = "main", features = ["serde"] } 99 | ipnetwork = { git = "https://github.com/pingooio/stdx-rs", branch = "main" } 100 | maxminddb = { git = "https://github.com/pingooio/stdx-rs", branch = "main" } 101 | mime_guess = { git = "https://github.com/pingooio/stdx-rs", branch = "main" } 102 | serde_urlencoded = { git = "https://github.com/pingooio/stdx-rs", branch = "main" } 103 | serde_yaml = { git = "https://github.com/pingooio/stdx-rs", branch = "main" } 104 | uuid = { git = "https://github.com/pingooio/stdx-rs", branch = "main" } 105 | 106 | 107 | async-trait = "0.1" 108 | aws-lc-rs = "1" 109 | bytes = "1" 110 | chrono = "0.4" 111 | cookie = { version = "0.18" } 112 | csv = "1" 113 | dashmap = "6" 114 | futures = "0.3" 115 | heapless = "0.9" 116 | hickory-resolver = "0.25" 117 | http = "1" 118 | http-body-util = "0.1" 119 | hyper = "1" 120 | hyper-rustls = "0.27" 121 | hyper-util = "0.1" 122 | indexmap = "2" 123 | instant-acme = { git = "https://github.com/djc/instant-acme", branch = "main" } 124 | mimalloc = "0.1" 125 | moka = "0.12" 126 | rand = "0.9" 127 | rcgen = { version = "0.14", default-features = false} 128 | rustls = { version = "0.23", default-features = false } 129 | rustls-pemfile = "2" 130 | serde = "1" 131 | serde_json = "1" 132 | socket2 = "0.6" 133 | thiserror = "2" 134 | tokio = "1" 135 | tokio-rustls = { version = "0.26", default-features = false } 136 | tokio-util = "0.7" 137 | tracing = "0.1" 138 | tracing-subscriber = { version = "0.3", default-features = false } 139 | url = { version = "2" } 140 | wildcard = "0.3" 141 | x509-parser = "0.18" 142 | zeroize = "1" 143 | zstd = "0.13" 144 | 145 | 146 | # countries = { git = "https://github.com/pingooio/stdx-rs", branch = "main" } 147 | # 148 | # 149 | # tld = { git = "https://github.com/pingooio/stdx-rs", branch = "main" } 150 | # jsonwebtoken = "9" 151 | # mime = "0.3" 152 | # regex = "1" 153 | # tokio-stream = "0.1" 154 | # ed25519-dalek = { git = "https://github.com/dalek-cryptography/curve25519-dalek", rev = "246723eefec35fdc8eb51c8c54a50c1c682e257d", features = ["pkcs8", "pem", "rand_core"] } 155 | # p256 = "0.13" 156 | # zeroize = "1" 157 | 158 | 159 | [patch.crates-io] 160 | base64 = { git = "https://github.com/pingooio/stdx-rs", branch = "main" } 161 | cfg-if = { git = "https://github.com/pingooio/stdx-rs", branch = "main" } 162 | form_urlencoded = { git = "https://github.com/pingooio/stdx-rs", branch = "main" } 163 | hex = { git = "https://github.com/pingooio/stdx-rs", branch = "main" } 164 | httpdate = { git = "https://github.com/pingooio/stdx-rs", branch = "main" } 165 | ipnetwork = { git = "https://github.com/pingooio/stdx-rs", branch = "main" } 166 | itoa = { git = "https://github.com/pingooio/stdx-rs", branch = "main" } 167 | mime_guess = { git = "https://github.com/pingooio/stdx-rs", branch = "main" } 168 | percent-encoding = { git = "https://github.com/pingooio/stdx-rs", branch = "main" } 169 | pin-project-lite = { git = "https://github.com/pingooio/stdx-rs", branch = "main" } 170 | ryu = { git = "https://github.com/pingooio/stdx-rs", branch = "main" } 171 | serde_urlencoded = { git = "https://github.com/pingooio/stdx-rs", branch = "main" } 172 | uuid = { git = "https://github.com/pingooio/stdx-rs", branch = "main" } 173 | 174 | 175 | thiserror = { git = "https://github.com/dtolnay/thiserror", rev = "91b181f0899fd42f41c210e73822c29eef29dd6d" } 176 | thiserror-impl = { git = "https://github.com/dtolnay/thiserror", rev = "91b181f0899fd42f41c210e73822c29eef29dd6d" } 177 | async-trait = { git = "https://github.com/dtolnay/async-trait", rev = "46896cb21234573aaeaf378590dc107d9609abca" } 178 | serde = { git = "https://github.com/serde-rs/serde", rev = "5a44519a9ac88854883f877b8186a9f6dcbf913a" } 179 | serde_core = { git = "https://github.com/serde-rs/serde", rev = "5a44519a9ac88854883f877b8186a9f6dcbf913a" } 180 | serde_derive = { git = "https://github.com/serde-rs/serde", rev = "5a44519a9ac88854883f877b8186a9f6dcbf913a" } 181 | serde_json = { git = "https://github.com/serde-rs/json", rev = "ce410dd77926181445321ce178fbc492a44328aa" } 182 | -------------------------------------------------------------------------------- /pingoo/tls/certificate.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | io::{self, BufReader}, 3 | sync::Arc, 4 | }; 5 | 6 | use aws_lc_rs::digest::{SHA256, digest}; 7 | use chrono::{DateTime, Datelike, TimeZone, Utc}; 8 | use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair, SanType}; 9 | use rustls::{crypto::CryptoProvider, pki_types::CertificateDer, sign::CertifiedKey}; 10 | use wildcard::{Wildcard, WildcardBuilder}; 11 | use x509_parser::prelude::{FromDer, GeneralName, ParsedExtension, X509Certificate}; 12 | use zeroize::{Zeroize, ZeroizeOnDrop}; 13 | 14 | use crate::Error; 15 | 16 | #[derive(Clone, Debug)] 17 | pub struct Certificate { 18 | /// Both the private key and public certificate. 19 | /// In rustls terms, it's actually a cert resolver. 20 | pub key: Arc, 21 | pub metadata: CertificateMetadata, 22 | } 23 | 24 | #[derive(Clone, Debug)] 25 | pub struct CertificateMetadata { 26 | /// hash of the DER encoding of the certificate / key 27 | pub hash: [u8; 32], 28 | /// hash of the DER encoding of the private key 29 | pub private_key_hash: [u8; 32], 30 | pub hostnames: Vec, 31 | pub wildcard_matchers: Vec>, 32 | pub not_after: DateTime, 33 | pub not_before: DateTime, 34 | } 35 | 36 | #[derive(Zeroize, ZeroizeOnDrop)] 37 | pub struct PrivateKeyAndCertPem { 38 | pub key: String, 39 | pub cert: String, 40 | } 41 | 42 | pub fn parse_certificate_and_private_key( 43 | certs_pem: &[u8], 44 | private_key_pem: &[u8], 45 | crypto_provider: &CryptoProvider, 46 | ) -> Result { 47 | let private_key = rustls_pemfile::private_key(&mut BufReader::new(private_key_pem)) 48 | .map_err(|err| Error::Config(format!("error parsing TLS private key: {err}")))? 49 | .ok_or(Error::Tls("private key file is not valid".to_string()))?; 50 | 51 | let certificates: Vec> = rustls_pemfile::certs(&mut BufReader::new(certs_pem)) 52 | .collect::>() 53 | .map_err(|err| Error::Config(format!("error parsing TLS certificates: {err}")))?; 54 | if certificates.is_empty() { 55 | return Err(Error::Tls("TLS cert chain is empty".to_string())); 56 | } 57 | // TODO: is the server certificate always the first one in a chain? 58 | let cert_metdata = get_certificate_metdata(&certificates[0], private_key.secret_der())?; 59 | if cert_metdata.hostnames.is_empty() && cert_metdata.wildcard_matchers.is_empty() { 60 | return Err(Error::Tls( 61 | "TLS certificate is not valid: hostnames list (SAN, Subject Alternative Name) is empty".to_string(), 62 | )); 63 | } 64 | 65 | let certified_key = CertifiedKey::from_der(certificates, private_key, crypto_provider) 66 | .map_err(|err| Error::Tls(format!("error verifying TLS certificates: {err}")))?; 67 | 68 | return Ok(Certificate { 69 | key: Arc::new(certified_key), 70 | metadata: cert_metdata, 71 | }); 72 | } 73 | 74 | fn get_certificate_metdata(cert_der: &[u8], private_key_der: &[u8]) -> Result { 75 | let (_, cert) = X509Certificate::from_der(cert_der) 76 | .map_err(|err| Error::Tls(format!("error parsing TLS certificate: {err}")))?; 77 | 78 | // validity 79 | let not_before = Utc 80 | .timestamp_opt(cert.validity.not_before.timestamp(), 0) 81 | .single() 82 | .ok_or(Error::Tls(format!( 83 | "TLS certificate not_before timestamp ({}) is not valid", 84 | cert.validity.not_before.timestamp() 85 | )))?; 86 | let not_after = Utc 87 | .timestamp_opt(cert.validity.not_after.timestamp(), 0) 88 | .single() 89 | .ok_or(Error::Tls(format!( 90 | "TLS certificate not_after timestamp ({}) is not valid", 91 | cert.validity.not_after.timestamp() 92 | )))?; 93 | 94 | // hostnames 95 | let mut subject_alternative_names = Vec::new(); 96 | for ext in cert.extensions() { 97 | if let ParsedExtension::SubjectAlternativeName(san) = &ext.parsed_extension() { 98 | for name in san.general_names.iter() { 99 | match name { 100 | GeneralName::DNSName(d) => subject_alternative_names.push(d.to_string()), 101 | GeneralName::IPAddress(bytes) => { 102 | let ip = if bytes.len() == 4 { 103 | std::net::IpAddr::from(<[u8; 4]>::try_from(&bytes[..4]).unwrap()) 104 | } else if bytes.len() == 16 { 105 | std::net::IpAddr::from(<[u8; 16]>::try_from(&bytes[..16]).unwrap()) 106 | } else { 107 | continue; 108 | }; 109 | subject_alternative_names.push(ip.to_string()); 110 | } 111 | _ => {} 112 | } 113 | } 114 | } 115 | } 116 | 117 | let wildcard_matchers = subject_alternative_names 118 | .iter() 119 | .filter(|hostname| hostname.contains('*')) 120 | .map(|hostname| { 121 | let matcher = WildcardBuilder::from_owned(hostname.clone().into_bytes()) 122 | .case_insensitive(false) 123 | .without_one_metasymbol() 124 | .build() 125 | .map_err(|err| Error::Tls(format!("TLS hostname {hostname} is not valid: {err}")))?; 126 | 127 | Ok(matcher) 128 | }) 129 | .collect::>()?; 130 | 131 | let hostnames = subject_alternative_names 132 | .into_iter() 133 | .filter(|san| !san.contains('*')) 134 | .collect(); 135 | 136 | return Ok(CertificateMetadata { 137 | hash: digest(&SHA256, cert_der).as_ref().try_into().unwrap(), 138 | private_key_hash: digest(&SHA256, private_key_der).as_ref().try_into().unwrap(), 139 | hostnames, 140 | wildcard_matchers, 141 | not_after, 142 | not_before, 143 | }); 144 | } 145 | 146 | pub fn generate_self_signed_certificates(hostnames: &[&str]) -> Result<(Certificate, PrivateKeyAndCertPem), Error> { 147 | let now = Utc::now(); 148 | let mut cert_params: CertificateParams = Default::default(); 149 | // 1 year validaity by default 150 | cert_params.not_before = rcgen::date_time_ymd(now.year(), now.month() as u8, now.day() as u8); 151 | cert_params.not_after = rcgen::date_time_ymd(now.year() + 1, now.month() as u8, now.day() as u8); 152 | 153 | // TODO 154 | cert_params.distinguished_name = DistinguishedName::new(); 155 | cert_params 156 | .distinguished_name 157 | .push(DnType::CommonName, "Pingoo self-signed certificate"); 158 | 159 | cert_params.subject_alt_names = hostnames 160 | .iter() 161 | .map(|hostname| { 162 | Ok(SanType::DnsName(hostname.to_string().try_into().map_err(|err| { 163 | Error::Tls(format!("error converting hostname {hostname} to SAN name: {err}")) 164 | })?)) 165 | }) 166 | .collect::>()?; 167 | 168 | let key_pair = KeyPair::generate() 169 | .map_err(|err| Error::Tls(format!("error generating keypair for self-signed certificate: {err}")))?; 170 | 171 | // it seems to be okay to sign self-signed certificate with the server's private key 172 | let cert = cert_params 173 | .self_signed(&key_pair) 174 | .map_err(|err| Error::Tls(format!("error seigning self-signed certificate: {err}")))?; 175 | 176 | let cert_pem = cert.pem(); 177 | let private_key_pem = key_pair.serialize_pem(); 178 | 179 | let certificate = parse_certificate_and_private_key( 180 | cert_pem.as_bytes(), 181 | private_key_pem.as_bytes(), 182 | CryptoProvider::get_default().unwrap(), 183 | )?; 184 | 185 | return Ok(( 186 | certificate, 187 | PrivateKeyAndCertPem { 188 | key: private_key_pem, 189 | cert: cert_pem, 190 | }, 191 | )); 192 | } 193 | -------------------------------------------------------------------------------- /pingoo/services/http_proxy_service.rs: -------------------------------------------------------------------------------- 1 | use std::{sync::Arc, time::Duration}; 2 | 3 | use bytes::Bytes; 4 | use http::{HeaderName, HeaderValue, Request, Response, header}; 5 | use http_body_util::combinators::BoxBody; 6 | use hyper_rustls::ConfigBuilderExt; 7 | use hyper_util::{ 8 | client::legacy::{Client, connect::HttpConnector}, 9 | rt::TokioExecutor, 10 | }; 11 | use rand::{rng, seq::IndexedRandom}; 12 | use tracing::{debug, error, warn}; 13 | 14 | use crate::{ 15 | config::ServiceConfig, 16 | service_discovery::service_registry::ServiceRegistry, 17 | services::{ 18 | HttpService, 19 | http_utils::{RequestExtensionContext, new_bad_gateway_error}, 20 | }, 21 | }; 22 | 23 | // headers that need to be removed from the client request 24 | // https://github.com/golang/go/blob/c39abe065886f62791f41240eef6ca03d452a17b/src/net/http/httputil/reverseproxy.go#L302 25 | const HOP_HEADERS: &[&str] = &[ 26 | "Connection", 27 | "Proxy-Connection", // non-standard but still sent by libcurl and rejected by e.g. google 28 | "Keep-Alive", 29 | "Proxy-Authenticate", 30 | "Proxy-Authorization", 31 | "Te", 32 | "Trailer", 33 | "Transfer-Encoding", 34 | "Upgrade", 35 | ]; 36 | 37 | const REPONSE_HEADERS_TO_REEMOVE: &[&str] = &[ 38 | "X-Accel-Buffering", 39 | "X-Accel-Charset", 40 | "X-Accel-Limit-Rate", 41 | "X-Accel-Redirect", 42 | "Alt-Svc", 43 | ]; 44 | 45 | pub struct HttpProxyService { 46 | name: Arc, 47 | http_client: Client, hyper::body::Incoming>, 48 | service_registry: Arc, 49 | route: Option, 50 | } 51 | 52 | impl HttpProxyService { 53 | pub fn new(config: ServiceConfig, service_registry: Arc) -> Self { 54 | let tls_config = rustls::ClientConfig::builder() 55 | .with_native_roots() 56 | .expect("error building TLS config") 57 | .with_no_client_auth(); 58 | 59 | let mut http_connector = HttpConnector::new(); 60 | http_connector.set_connect_timeout(Some(Duration::from_secs(4))); 61 | http_connector.enforce_http(false); 62 | 63 | let https_connector = hyper_rustls::HttpsConnectorBuilder::new() 64 | .with_tls_config(tls_config) 65 | .https_or_http() 66 | .enable_http1() 67 | .enable_http2() 68 | .wrap_connector(http_connector); 69 | 70 | let http_client: Client<_, hyper::body::Incoming> = 71 | Client::builder(TokioExecutor::new()).build(https_connector); 72 | 73 | return HttpProxyService { 74 | name: Arc::new(config.name), 75 | http_client, 76 | service_registry, 77 | route: config.route, 78 | }; 79 | } 80 | } 81 | 82 | #[async_trait::async_trait] 83 | impl HttpService for HttpProxyService { 84 | fn match_request(&self, ctx: &rules::Context) -> bool { 85 | match &self.route { 86 | None => true, 87 | Some(route) => match route.execute(&ctx) { 88 | Ok(value) => value == true.into(), 89 | Err(err) => { 90 | warn!("error executing route for service {}: {err}", self.name); 91 | false 92 | } 93 | }, 94 | } 95 | } 96 | 97 | async fn handle_http_request( 98 | &self, 99 | mut req: Request, 100 | ) -> Response> { 101 | let upstreams = self.service_registry.get_upstreams(&self.name).await; 102 | if upstreams.is_empty() { 103 | debug!("[{}]: no upstream available", self.name); 104 | return new_bad_gateway_error(); 105 | } 106 | 107 | let request_context = req 108 | .extensions() 109 | .get::() 110 | .expect("error getting RequestContext extension") 111 | .0 112 | .clone(); 113 | 114 | for header in HOP_HEADERS { 115 | req.headers_mut().remove(*header); 116 | } 117 | 118 | let upstream = upstreams.choose(&mut rng()).unwrap(); 119 | let path_and_query = req.uri().path_and_query().map(|x| x.as_str()).unwrap_or("/"); 120 | let mut upstream_tls_version = http::Version::HTTP_11; 121 | 122 | let uri_str = if upstream.tls { 123 | // TODO: use upstream socketAddress and correct SNI 124 | upstream_tls_version = http::Version::HTTP_2; 125 | format!("https://{}{path_and_query}", &upstream.hostname) 126 | } else { 127 | format!("http://{}{path_and_query}", &upstream.socket_address) 128 | }; 129 | let uri = uri_str.parse().unwrap(); 130 | 131 | *req.uri_mut() = uri; 132 | *req.version_mut() = upstream_tls_version; 133 | 134 | // here we forward the host from the client's request. 135 | // TODO: allow to configure if we forward the Host header or not (and thus use the host from the upstream). 136 | if let Ok(host_header) = HeaderValue::from_str(&request_context.host) { 137 | req.headers_mut().insert(header::HOST, host_header.clone()); // TODO: try to avoid clone 138 | req.headers_mut() 139 | .insert(HeaderName::from_static("x-forwarded-host"), host_header); 140 | } 141 | 142 | let client_ip = request_context.client_address.ip(); 143 | let client_ip_str = Arc::new(client_ip.to_string()); 144 | 145 | // TODO: allow users to configure if they trust the x-forwarded-for header or no 146 | let forwarded_for_from_client = req 147 | .headers() 148 | .get_all("x-forwarded-for") 149 | .iter() 150 | .map(|header_value| header_value.to_str().unwrap_or_default()) 151 | .collect::>(); 152 | let forwarded_for_to_upstream = if forwarded_for_from_client.is_empty() { 153 | client_ip_str.clone() 154 | } else { 155 | Arc::new(forwarded_for_from_client.join(", ") + format!(", {client_ip}").as_str()) 156 | }; 157 | if let Ok(forwarded_for) = HeaderValue::from_str(&forwarded_for_to_upstream) { 158 | req.headers_mut().insert("x-forwarded-for", forwarded_for); 159 | } 160 | 161 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-Proto 162 | if request_context.tls { 163 | req.headers_mut() 164 | .insert("x-forwarded-proto", HeaderValue::from_static("https")); 165 | } else { 166 | req.headers_mut() 167 | .insert("x-forwarded-proto", HeaderValue::from_static("http")); 168 | } 169 | 170 | if let Ok(client_ip_header) = HeaderValue::from_str(&client_ip_str) { 171 | req.headers_mut().insert("pingoo-client-ip", client_ip_header); 172 | } 173 | 174 | if request_context.geoip_enabled { 175 | let request_headers = req.headers_mut(); 176 | let country_code = &request_context.country.as_str(); 177 | match HeaderValue::from_str(country_code) { 178 | Ok(country_header_value) => { 179 | request_headers.insert(HeaderName::from_static("pingoo-client-country"), country_header_value); 180 | } 181 | Err(err) => error!("error converting country code ({country_code}) to HTTP header: {err}"), 182 | }; 183 | 184 | match HeaderValue::from_str(request_context.asn.to_string().as_str()) { 185 | Ok(country_header_value) => { 186 | request_headers.insert(HeaderName::from_static("pingoo-client-asn"), country_header_value); 187 | } 188 | Err(err) => error!("error converting ASN ({}) to HTTP header: {err}", request_context.asn), 189 | }; 190 | } 191 | 192 | let mut res = match self.http_client.request(req).await { 193 | Ok(res) => res, 194 | Err(_) => return new_bad_gateway_error(), 195 | }; 196 | 197 | for header in REPONSE_HEADERS_TO_REEMOVE { 198 | res.headers_mut().remove(*header); 199 | } 200 | 201 | res.headers_mut().insert("server", HeaderValue::from_static("pingoo")); 202 | 203 | let (parts, body) = res.into_parts(); 204 | let boxed_body: BoxBody = BoxBody::new(body); 205 | // Ok(Response::from_parts(parts, boxed_body)) 206 | return Response::from_parts(parts, boxed_body); 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /jwt/key.rs: -------------------------------------------------------------------------------- 1 | use crate::{Algorithm, EcCurve, Error, Jwk, JwkCrypto, OkpCurve, SIGNATURE_MAX_SIZE}; 2 | use aws_lc_rs::{ 3 | hmac, 4 | rand::SystemRandom, 5 | signature::{ 6 | ECDSA_P256_SHA256_FIXED, ECDSA_P256_SHA256_FIXED_SIGNING, ECDSA_P521_SHA512_FIXED, 7 | ECDSA_P521_SHA512_FIXED_SIGNING, ED25519, EcdsaKeyPair, Ed25519KeyPair, KeyPair, 8 | }, 9 | }; 10 | 11 | /// a Key used for crypto (sign / verify) operations 12 | pub struct Key { 13 | pub id: String, 14 | pub(crate) algorithm: Algorithm, 15 | pub(crate) crypto: KeyCrypto, 16 | } 17 | 18 | pub enum KeyCrypto { 19 | Eddsa { curve: OkpCurve, keypair: Ed25519KeyPair }, 20 | Ecdsa { curve: EcCurve, keypair: EcdsaKeyPair }, 21 | Hmac { algorithm: HmacAlgorithm, key: Vec }, 22 | } 23 | 24 | #[derive(Debug, Clone, Copy)] 25 | pub enum HmacAlgorithm { 26 | Sha256, 27 | Sha512, 28 | } 29 | 30 | #[derive(Clone, Copy)] 31 | pub struct Signature { 32 | value: [u8; SIGNATURE_MAX_SIZE], 33 | length: usize, 34 | } 35 | 36 | impl AsRef<[u8]> for Signature { 37 | #[inline] 38 | fn as_ref(&self) -> &[u8] { 39 | &self.value[..self.length] 40 | } 41 | } 42 | 43 | impl From for Signature { 44 | fn from(signature: aws_lc_rs::signature::Signature) -> Self { 45 | let length = signature.as_ref().len(); 46 | let mut value = [0u8; SIGNATURE_MAX_SIZE]; 47 | value[..length].copy_from_slice(signature.as_ref()); 48 | 49 | return Signature { value, length }; 50 | } 51 | } 52 | 53 | impl From for Signature { 54 | fn from(signature: aws_lc_rs::hmac::Tag) -> Self { 55 | let length = signature.as_ref().len(); 56 | let mut value = [0u8; SIGNATURE_MAX_SIZE]; 57 | value[..length].copy_from_slice(signature.as_ref()); 58 | 59 | return Signature { value, length }; 60 | } 61 | } 62 | 63 | impl Key { 64 | pub fn generate_ed25519(id: String) -> Result { 65 | let keypair = Ed25519KeyPair::generate() 66 | .map_err(|err| Error::Unspecified(format!("error generating Ed25519 signing key: {err}")))?; 67 | return Ok(Key { 68 | id, 69 | algorithm: Algorithm::EdDSA, 70 | crypto: KeyCrypto::Eddsa { 71 | curve: OkpCurve::Ed25519, 72 | keypair, 73 | }, 74 | }); 75 | } 76 | 77 | pub fn algorithm(&self) -> Algorithm { 78 | self.algorithm 79 | } 80 | 81 | pub fn sign(&self, message: &[u8]) -> Result { 82 | match &self.crypto { 83 | KeyCrypto::Eddsa { curve: _, keypair } => Ok(keypair.sign(message).into()), 84 | KeyCrypto::Ecdsa { curve: _, keypair } => keypair 85 | .sign(&SystemRandom::new(), message) 86 | .map(Into::into) 87 | .map_err(|err| Error::Unspecified(format!("error signing token: {err}"))), 88 | KeyCrypto::Hmac { algorithm, key } => { 89 | let hmac_key = match algorithm { 90 | HmacAlgorithm::Sha256 => hmac::Key::new(hmac::HMAC_SHA256, key), 91 | HmacAlgorithm::Sha512 => hmac::Key::new(hmac::HMAC_SHA512, key), 92 | }; 93 | Ok(hmac::sign(&hmac_key, message).into()) 94 | } 95 | } 96 | } 97 | 98 | pub fn verify(&self, message: &[u8], signature: &[u8]) -> Result<(), Error> { 99 | match &self.crypto { 100 | KeyCrypto::Eddsa { curve, keypair } => match curve { 101 | OkpCurve::Ed25519 => { 102 | aws_lc_rs::signature::ParsedPublicKey::new(&ED25519, keypair.public_key().as_ref()) 103 | .expect("error getting public key") 104 | .verify_sig(message, signature) 105 | .map_err(|_| Error::InvalidSignature) 106 | } 107 | }, 108 | KeyCrypto::Ecdsa { curve, keypair } => match curve { 109 | EcCurve::P256 => { 110 | aws_lc_rs::signature::ParsedPublicKey::new(&ECDSA_P256_SHA256_FIXED, keypair.public_key().as_ref()) 111 | .expect("error getting public key") 112 | .verify_sig(message, signature) 113 | .map_err(|_| Error::InvalidSignature) 114 | } 115 | EcCurve::P521 => { 116 | aws_lc_rs::signature::ParsedPublicKey::new(&ECDSA_P521_SHA512_FIXED, keypair.public_key().as_ref()) 117 | .expect("error getting public key") 118 | .verify_sig(message, signature) 119 | .map_err(|_| Error::InvalidSignature) 120 | } 121 | }, 122 | KeyCrypto::Hmac { algorithm, key } => { 123 | let hmac_key = match algorithm { 124 | HmacAlgorithm::Sha256 => hmac::Key::new(hmac::HMAC_SHA256, key), 125 | HmacAlgorithm::Sha512 => hmac::Key::new(hmac::HMAC_SHA512, key), 126 | }; 127 | hmac::verify(&hmac_key, message, signature).map_err(|_| Error::InvalidSignature) 128 | } 129 | } 130 | } 131 | } 132 | 133 | /// Validate and convert a Jwk to a key 134 | impl TryFrom for Key { 135 | type Error = Error; 136 | 137 | fn try_from(jwk: Jwk) -> Result { 138 | match (jwk.algorithm, jwk.crypto) { 139 | (Algorithm::HS512, JwkCrypto::Oct { key }) => Ok(Key { 140 | id: jwk.kid, 141 | algorithm: jwk.algorithm, 142 | crypto: KeyCrypto::Hmac { 143 | algorithm: HmacAlgorithm::Sha512, 144 | key, 145 | }, 146 | }), 147 | (Algorithm::EdDSA, JwkCrypto::Okp { curve, x, d }) => { 148 | let raw_seed = &d.ok_or(Error::InvalidJwk { 149 | kid: jwk.kid.clone(), 150 | err: "private key is missing".to_string(), 151 | })?; 152 | 153 | let keypair = match curve { 154 | OkpCurve::Ed25519 => { 155 | Ed25519KeyPair::from_seed_and_public_key(raw_seed, &x).map_err(|err| Error::InvalidJwk { 156 | kid: jwk.kid.clone(), 157 | err: err.to_string(), 158 | })? 159 | } 160 | }; 161 | 162 | Ok(Key { 163 | id: jwk.kid, 164 | algorithm: Algorithm::EdDSA, 165 | crypto: KeyCrypto::Eddsa { 166 | curve: OkpCurve::Ed25519, 167 | keypair, 168 | }, 169 | }) 170 | } 171 | (Algorithm::ES256, crate::JwkCrypto::Ec { curve, x, y, d }) 172 | | (Algorithm::ES512, crate::JwkCrypto::Ec { curve, x, y, d }) => { 173 | let private_key = &d.ok_or(Error::InvalidJwk { 174 | kid: jwk.kid.clone(), 175 | err: "private key is missing".to_string(), 176 | })?; 177 | let mut public_key = Vec::with_capacity(1 + x.len() + y.len()); 178 | public_key.push(0x04); 179 | public_key.extend(y); 180 | public_key.extend(x); 181 | 182 | let keypair = match curve { 183 | EcCurve::P256 => EcdsaKeyPair::from_private_key_and_public_key( 184 | &ECDSA_P256_SHA256_FIXED_SIGNING, 185 | private_key, 186 | &public_key, 187 | ), 188 | EcCurve::P521 => EcdsaKeyPair::from_private_key_and_public_key( 189 | &ECDSA_P521_SHA512_FIXED_SIGNING, 190 | private_key, 191 | &public_key, 192 | ), 193 | } 194 | .map_err(|err| Error::InvalidJwk { 195 | kid: jwk.kid.clone(), 196 | err: err.to_string(), 197 | })?; 198 | 199 | Ok(Key { 200 | id: jwk.kid, 201 | algorithm: jwk.algorithm, 202 | crypto: KeyCrypto::Ecdsa { curve, keypair }, 203 | }) 204 | } 205 | _ => { 206 | return Err(Error::InvalidJwk { 207 | kid: jwk.kid, 208 | err: "JWK is not valid".to_string(), 209 | }); 210 | } 211 | } 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /pingoo/service_discovery/service_registry.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | cmp::max, 3 | collections::{HashMap, HashSet}, 4 | net::SocketAddr, 5 | sync::Arc, 6 | time::Duration, 7 | }; 8 | 9 | use dashmap::DashMap; 10 | use futures::future::join_all; 11 | use tokio::time; 12 | use tracing::{debug, error}; 13 | 14 | use crate::{ 15 | Error, 16 | config::{ServiceConfig, ServiceDiscoveryConfig, UpstreamConfig}, 17 | service_discovery::{dns::DnsServiceDiscoverer, docker::DockerServiceDiscoverer}, 18 | }; 19 | 20 | /// The ServiceRegistry, unique per pingoo instance, keeps track of service upstreams. 21 | /// It can be queried to get the list of healthy upstreams for a given service. 22 | pub struct ServiceRegistry { 23 | // upstreams: DashMap>>, 24 | upstreams: DashMap>>, 25 | 26 | // TODO: should we Arc the Vec? 27 | // problem: getting a mut Arc (e.g to extend it with new upstream) is hard as it requires only 1 copy. 28 | /// static_upstreams are the upstreams that were provided direactly as IP addresses in the config 29 | static_upstreams: HashMap>, 30 | 31 | dns: DnsServiceDiscoverer, 32 | docker: DockerServiceDiscoverer, 33 | } 34 | 35 | #[async_trait::async_trait] 36 | pub trait ServiceDiscoverer: Send + Sync { 37 | async fn discover(&self) -> Result>, Error>; 38 | } 39 | 40 | #[derive(Clone, Debug, PartialEq)] 41 | pub struct Upstream { 42 | pub socket_address: SocketAddr, 43 | pub hostname: String, 44 | pub tls: bool, 45 | } 46 | 47 | struct UpstreamDiff { 48 | /// services that have upstreams that have been added or updated 49 | updated: HashSet, 50 | /// services that no longer have upstreams 51 | deleted: HashSet, 52 | } 53 | 54 | impl ServiceRegistry { 55 | pub async fn new( 56 | service_discovery_config: &ServiceDiscoveryConfig, 57 | services_config: &[ServiceConfig], 58 | ) -> Result { 59 | let static_upstreams = get_static_upstreams(services_config); 60 | let dns = DnsServiceDiscoverer::new(services_config); 61 | let docker = DockerServiceDiscoverer::new(service_discovery_config).await?; 62 | 63 | let upstreams = static_upstreams 64 | .iter() 65 | .map(|(service_name, upstreams)| (service_name.clone(), Arc::new(upstreams.clone()))); 66 | 67 | return Ok(ServiceRegistry { 68 | upstreams: DashMap::from_iter(upstreams), 69 | static_upstreams, 70 | dns, 71 | docker, 72 | }); 73 | } 74 | 75 | pub async fn get_upstreams(&self, service: &str) -> Arc> { 76 | return self 77 | .upstreams 78 | .get(service) 79 | .map(|upstreams| upstreams.clone()) 80 | .unwrap_or(Arc::new(Vec::new())); 81 | } 82 | 83 | pub fn start_in_background(self: Arc) { 84 | tokio::spawn(async move { 85 | debug!("Starting ServiceRegistry background service"); 86 | let mut ticker = time::interval(Duration::from_secs(2)); // every 2 seconds 87 | ticker.set_missed_tick_behavior(time::MissedTickBehavior::Delay); 88 | 89 | loop { 90 | tokio::select! { 91 | _ = ticker.tick() => { 92 | if let Err(err) = self.discover().await { 93 | error!("{err}"); 94 | } 95 | }, 96 | // Ok(_) = shutdown.changed() => { 97 | // info!("Shutting down ServiceDiscovery background service"); 98 | // return; 99 | // } 100 | }; 101 | } 102 | }); 103 | } 104 | 105 | pub async fn discover(&self) -> Result<(), Error> { 106 | // TODO: what if a discoverer fail? 107 | let mut new_upstreams = self.static_upstreams.clone(); 108 | 109 | let service_discoverers = vec![self.dns.discover(), self.docker.discover()]; 110 | 111 | let res = join_all(service_discoverers).await; 112 | for upstreams_res in res { 113 | let upstreams = match upstreams_res { 114 | Ok(upstreams) => upstreams, 115 | Err(err) => { 116 | debug!("service_registry: {err}"); 117 | continue; 118 | } 119 | }; 120 | 121 | // merge discovered upstreams with static upstreams 122 | for (service_name, upstreams) in upstreams { 123 | new_upstreams 124 | .entry(service_name) 125 | .or_insert(Vec::new()) 126 | .extend(upstreams); 127 | } 128 | } 129 | 130 | let upstreams_diff = diff_upstreams(&self.upstreams, &new_upstreams); 131 | for service_name in upstreams_diff.updated.into_iter() { 132 | if let Some(new_upstreams_for_service) = new_upstreams.remove(&service_name) { 133 | debug!("upstreams updated for service {service_name}"); 134 | self.upstreams.insert(service_name, Arc::new(new_upstreams_for_service)); 135 | } 136 | } 137 | 138 | for service_name in upstreams_diff.deleted.into_iter() { 139 | debug!("upstreams deleted for service {service_name}"); 140 | self.upstreams.remove(&service_name); 141 | } 142 | 143 | return Ok(()); 144 | } 145 | } 146 | 147 | /// diff_upstreams returns the list of services that have upstreams that have been updated or removed 148 | fn diff_upstreams( 149 | old_upstreams: &DashMap>>, 150 | new_upstreams: &HashMap>, 151 | ) -> UpstreamDiff { 152 | // it's better to allocate a little bit too much than to allocate too many times 153 | let ret_capacity = max(old_upstreams.len(), new_upstreams.len()); 154 | let mut ret = UpstreamDiff { 155 | updated: HashSet::with_capacity(ret_capacity), 156 | deleted: HashSet::with_capacity(ret_capacity), 157 | }; 158 | 159 | for (service_name, new_upstreams_for_service) in new_upstreams.iter() { 160 | if let Some(old_upstreams_for_service) = old_upstreams.get(service_name) { 161 | // if the upstreams are not in new upstreams (upstreams deleted) 162 | for old_upstream in old_upstreams_for_service.iter() { 163 | if !new_upstreams_for_service.contains(old_upstream) { 164 | ret.deleted.insert(service_name.clone()); 165 | } 166 | } 167 | 168 | // if the upstreams are not in old upstreams (upstreams added) 169 | for new_upstream in new_upstreams_for_service.iter() { 170 | if !old_upstreams_for_service.contains(new_upstream) { 171 | ret.updated.insert(service_name.clone()); 172 | } 173 | } 174 | } else { 175 | // if the service is not in the old upstreams (service added) 176 | ret.updated.insert(service_name.clone()); 177 | } 178 | } 179 | 180 | // if the service is in old upstreams but not in new upstreams (service deleted) 181 | for entry in old_upstreams.iter() { 182 | let service_name = entry.key(); 183 | if !new_upstreams.contains_key(service_name) { 184 | ret.deleted.insert(service_name.clone()); 185 | } 186 | } 187 | 188 | return ret; 189 | } 190 | 191 | fn get_static_upstreams(services_config: &[ServiceConfig]) -> HashMap> { 192 | services_config 193 | .iter() 194 | .map(|service_config| { 195 | let name = service_config.name.clone(); 196 | 197 | if let Some(upstreams) = &service_config.http_proxy { 198 | return ( 199 | name, 200 | upstreams 201 | .iter() 202 | .filter_map(|upstream| match upstream { 203 | UpstreamConfig::IPAddress(upstream) => Some(upstream.clone()), 204 | UpstreamConfig::Domain { .. } => None, 205 | }) 206 | .collect(), 207 | ); 208 | } 209 | if let Some(upstreams) = &service_config.tcp_proxy { 210 | return ( 211 | name, 212 | upstreams 213 | .iter() 214 | .filter_map(|upstream| match upstream { 215 | UpstreamConfig::IPAddress(upstream) => Some(upstream.clone()), 216 | UpstreamConfig::Domain { .. } => None, 217 | }) 218 | .collect(), 219 | ); 220 | } else { 221 | return (name, Vec::new()); 222 | } 223 | }) 224 | .collect() 225 | } 226 | -------------------------------------------------------------------------------- /pingoo/tls/tls_manager.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt::Debug, path::PathBuf, sync::Arc}; 2 | 3 | use dashmap::DashMap; 4 | use rustls::{ 5 | ServerConfig, 6 | crypto::CryptoProvider, 7 | server::{ClientHello, ResolvesServerCert}, 8 | }; 9 | use tokio::fs; 10 | use tracing::warn; 11 | 12 | use crate::{ 13 | Error, 14 | config::{DEFAULT_TLS_FOLDER, TlsConfig}, 15 | tls::{ 16 | acme::{AcmeChallenge, load_or_create_acme_account}, 17 | certificate::{Certificate, generate_self_signed_certificates, parse_certificate_and_private_key}, 18 | }, 19 | }; 20 | 21 | /// The ALPN protocol identifier for the ACME tls-alpn-01 challenge 22 | pub const TLS_ALPN_ACME: &[u8] = b"acme-tls/1"; 23 | /// The ALPN protocol identifier for HTTP/2 24 | pub const TLS_ALPN_HTTP2: &[u8] = b"h2"; 25 | /// The ALPN protocol identifier for HTTP/1.1 26 | pub const TLS_ALPN_HTTP11: &[u8] = b"http/1.1"; 27 | 28 | #[derive(Debug)] 29 | pub struct TlsManager { 30 | pub(super) default_certificate: Certificate, 31 | /// certificates indexed by their Subject Alternative Names that don't contain a wildcard 32 | pub(super) certificates: DashMap>, 33 | /// certificates that have at least 1 Subject Alternative Name containing a wildcard 34 | pub(super) wildcard_certificates: Vec>, 35 | 36 | pub(super) acme: Option>, 37 | } 38 | 39 | pub(super) struct AcmeConfig { 40 | pub account: instant_acme::Account, 41 | pub domains: Vec, 42 | pub challenges: DashMap, 43 | } 44 | 45 | impl Debug for AcmeConfig { 46 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 47 | f.debug_struct("AcmeConfig") 48 | .field("account", &"[REDACTED]") 49 | .field("domains", &self.domains) 50 | .field("challenges", &"[REDACTED]") 51 | .finish() 52 | } 53 | } 54 | 55 | impl TlsManager { 56 | pub async fn new(tls_config: &TlsConfig) -> Result { 57 | fs::create_dir_all(DEFAULT_TLS_FOLDER) 58 | .await 59 | .map_err(|err| Error::Config(format!("error creating TLS folder ({DEFAULT_TLS_FOLDER}): {err}")))?; 60 | 61 | let (certificates, wildcard_certificates) = load_certificates(DEFAULT_TLS_FOLDER).await?; 62 | 63 | let default_certificate = load_or_create_default_certificate(DEFAULT_TLS_FOLDER.into()).await?; 64 | 65 | let acme = match &tls_config.acme { 66 | Some(acme_config) => { 67 | let acme_account = 68 | load_or_create_acme_account(DEFAULT_TLS_FOLDER, acme_config.directory_url.clone()).await?; 69 | Some(Arc::new(AcmeConfig { 70 | account: acme_account, 71 | domains: acme_config.domains.clone(), 72 | challenges: DashMap::new(), 73 | })) 74 | } 75 | None => None, 76 | }; 77 | 78 | Ok(TlsManager { 79 | default_certificate, 80 | certificates, 81 | wildcard_certificates, 82 | 83 | acme, 84 | }) 85 | } 86 | 87 | pub fn get_tls_server_config( 88 | self: &Arc, 89 | alpn_protocols: impl IntoIterator>, 90 | ) -> Arc { 91 | // we only support TLS 1.3 92 | // TLS 1.3 was introduced in 2018 and is supported by virtually all browsers 93 | // and client libraries: https://caniuse.com/tls1-3 94 | // Only unmaintained bots don't support TLS 1.3 95 | let mut tls_server_config = ServerConfig::builder_with_protocol_versions(&[&rustls::version::TLS13]) 96 | .with_no_client_auth() 97 | .with_cert_resolver(self.clone()); 98 | 99 | tls_server_config.alpn_protocols = alpn_protocols.into_iter().collect(); 100 | 101 | return Arc::new(tls_server_config); 102 | } 103 | } 104 | 105 | impl ResolvesServerCert for TlsManager { 106 | fn resolve(&self, client_hello: ClientHello<'_>) -> Option> { 107 | let sni = client_hello.server_name().unwrap_or_default(); 108 | // first, we try an exact match of the SNI against the certificates Subject Alternative Names 109 | let key = match self.certificates.get(sni) { 110 | Some(cert) => cert.key.clone(), 111 | None => { 112 | // if not found, we try with certificates that contain wildcard Subject Alternative Names 113 | self.wildcard_certificates 114 | .iter() 115 | .find(|cert| { 116 | cert.metadata 117 | .wildcard_matchers 118 | .iter() 119 | .any(|matcher| matcher.is_match(sni.as_bytes())) 120 | }) 121 | .map(|cert| cert.key.clone()) 122 | // Finally, if still not found, we serve the default certificate 123 | .unwrap_or(self.default_certificate.key.clone()) 124 | } 125 | }; 126 | Some(key) 127 | } 128 | } 129 | 130 | async fn load_certificates( 131 | directory_path: &str, 132 | ) -> Result<(DashMap>, Vec>), Error> { 133 | let mut directory = fs::read_dir(directory_path) 134 | .await 135 | .map_err(|err| Error::Config(format!("error reading certificates folder ({directory_path}): {err}")))?; 136 | 137 | // list certs 138 | let mut certificate_paths = Vec::new(); 139 | while let Ok(Some(entry)) = directory.next_entry().await { 140 | let path = entry.path(); 141 | let file_type = entry 142 | .file_type() 143 | .await 144 | .map_err(|err| Error::Config(format!("error getting file type for {path:?}: {err}")))?; 145 | if !file_type.is_file() { 146 | continue; 147 | } 148 | 149 | let file_extension = path.extension().unwrap_or_default().to_str().unwrap_or_default(); 150 | // we expect that all .pem files are certificates 151 | if file_extension == "pem" && path.file_name().unwrap_or_default().to_str().unwrap_or_default() != "default" { 152 | certificate_paths.push(path); 153 | } 154 | } 155 | 156 | let certificates = DashMap::with_capacity(certificate_paths.len()); 157 | let mut wildcard_certificates = Vec::new(); 158 | for cert_file_path in certificate_paths { 159 | let certificate = Arc::new(load_certificate(&cert_file_path).await?); 160 | 161 | for hostname in &certificate.metadata.hostnames { 162 | if certificates.contains_key(hostname) { 163 | warn!("duplicate TLS certificate found for {hostname}"); 164 | } 165 | certificates.insert(hostname.clone(), certificate.clone()); 166 | } 167 | if certificate.metadata.wildcard_matchers.len() != 0 { 168 | wildcard_certificates.push(certificate.clone()); 169 | } 170 | } 171 | 172 | return Ok((certificates, wildcard_certificates)); 173 | } 174 | 175 | async fn load_certificate(cert_file_path: &PathBuf) -> Result { 176 | let cert_file_content = fs::read(cert_file_path) 177 | .await 178 | .map_err(|err| Error::Config(format!("error reading certificate {cert_file_path:?}: {err}")))?; 179 | 180 | let mut private_key_path = cert_file_path.clone(); 181 | private_key_path.set_extension("key"); 182 | let private_key_file_content = fs::read(&private_key_path) 183 | .await 184 | .map_err(|err| Error::Config(format!("error reading private key {private_key_path:?}: {err}")))?; 185 | 186 | return parse_certificate_and_private_key( 187 | &cert_file_content, 188 | &private_key_file_content, 189 | CryptoProvider::get_default().unwrap(), 190 | ); 191 | } 192 | 193 | async fn load_or_create_default_certificate(mut certs_dir: PathBuf) -> Result { 194 | certs_dir.push("default.pem"); 195 | let mut default_cert_path = certs_dir; 196 | 197 | let default_cert = match fs::metadata(&default_cert_path).await { 198 | Ok(_) => load_certificate(&default_cert_path).await, 199 | Err(err) if err.kind() == std::io::ErrorKind::NotFound => { 200 | let (default_certificate, pem) = generate_self_signed_certificates(&["*"])?; 201 | 202 | // save certificate and private key 203 | fs::write(&default_cert_path, pem.cert.as_bytes()) 204 | .await 205 | .map_err(|err| { 206 | Error::Tls(format!("error writing default TLS certificate to {default_cert_path:?}: {err}")) 207 | })?; 208 | 209 | default_cert_path.set_extension("key"); 210 | fs::write(&default_cert_path, pem.key.as_bytes()).await.map_err(|err| { 211 | Error::Tls(format!( 212 | "error writing private key for default TLS certificate to {default_cert_path:?}: {err}" 213 | )) 214 | })?; 215 | 216 | Ok(default_certificate) 217 | } 218 | Err(err) => Err(Error::Tls(format!( 219 | "error loading default certificate {default_cert_path:?}: {err}" 220 | ))), 221 | }?; 222 | 223 | if default_cert.metadata.hostnames.len() != 0 224 | || default_cert.metadata.wildcard_matchers.len() != 1 225 | || default_cert.metadata.wildcard_matchers[0].pattern() != b"*" 226 | { 227 | return Err(Error::Tls("default TLS certificate is not valid".to_string())); 228 | } 229 | 230 | return Ok(default_cert); 231 | } 232 | 233 | // pub async fn write_sensitive_file(path: impl AsRef + Debug, contents: impl AsRef<[u8]>) -> Result<(), Error> { 234 | // let mut file = fs::OpenOptions::new() 235 | // .create(true) 236 | // .write(true) 237 | // .mode(0o600) 238 | // .truncate(true) 239 | // .open(&path) 240 | // .map_err(|err| Error::Unspecified(err.to_string())) 241 | // .await?; 242 | 243 | // file.write_all(contents.as_ref()) 244 | // .await 245 | // .map_err(|err| Error::Unspecified(format!("error when writing data to file: {err}")))?; 246 | 247 | // file.flush() 248 | // .await 249 | // .map_err(|err| Error::Unspecified(format!("error when flushing data to file: {err}")))?; 250 | 251 | // return Ok(()); 252 | // } 253 | -------------------------------------------------------------------------------- /pingoo/config/config_file.rs: -------------------------------------------------------------------------------- 1 | use core::fmt; 2 | use std::{ 3 | net::{IpAddr, Ipv4Addr, SocketAddr}, 4 | path::PathBuf, 5 | str::FromStr, 6 | }; 7 | 8 | use http::{StatusCode, Uri}; 9 | use indexmap::IndexMap; 10 | use serde::{Deserialize, Deserializer, de::Visitor}; 11 | use url::Url; 12 | 13 | use crate::{ 14 | Error, 15 | config::{ 16 | ChildProcess, ListConfig, ListenerProtocol, ServiceConfig, ServiceDiscoveryConfig, StaticSiteServiceConfig, 17 | StaticSiteServiceNotFound, TlsConfig, UpstreamConfig, 18 | }, 19 | service_discovery::service_registry::Upstream, 20 | }; 21 | 22 | // TODO: error on map duplicate key 23 | // https://docs.rs/serde_with/latest/serde_with/rust/maps_duplicate_key_is_error/index.html 24 | #[derive(Clone, Debug, Deserialize)] 25 | pub struct ConfigFile { 26 | pub listeners: IndexMap, 27 | pub services: IndexMap, 28 | #[serde(default)] 29 | pub rules: IndexMap, 30 | pub tls: Option, 31 | pub service_discovery: Option, 32 | pub lists: Option>, 33 | pub child_process: Option, 34 | } 35 | 36 | #[derive(Clone, Debug, Deserialize)] 37 | pub struct ListenerConfigFile { 38 | pub address: ListenerAddressConfigFile, 39 | pub services: Option>, 40 | } 41 | 42 | #[derive(Clone, Debug)] 43 | pub struct ListenerAddressConfigFile { 44 | pub socket_address: SocketAddr, 45 | pub protocol: ListenerProtocol, 46 | } 47 | 48 | #[derive(Clone, Debug, Deserialize)] 49 | pub struct ServiceConfigFile { 50 | // #[serde(default)] 51 | // pub provider: Provider, 52 | // pub servers: Vec, 53 | // #[serde(default)] 54 | // pub listeners: Vec, 55 | #[serde(default)] 56 | pub route: Option, 57 | #[serde(default)] 58 | pub http_proxy: Option>, 59 | #[serde(default)] 60 | pub r#static: Option, 61 | #[serde(default)] 62 | pub tcp_proxy: Option>, 63 | // #[serde(default)] 64 | // pub rules: Vec, 65 | } 66 | 67 | #[derive(Clone, Debug, Deserialize)] 68 | pub struct ServiceConfigFileStatic { 69 | #[serde(default)] 70 | pub root: String, 71 | 72 | #[serde(default)] 73 | pub not_found: ServiceConfigFileStaticNotFound, 74 | } 75 | 76 | #[derive(Clone, Debug, Deserialize)] 77 | pub struct ServiceConfigFileStaticNotFound { 78 | #[serde(default)] 79 | pub file: Option, 80 | 81 | #[serde(default = "default_service_static_not_found_status")] 82 | pub status: u16, 83 | } 84 | 85 | // #[derive(Clone, Debug, Deserialize)] 86 | // pub struct TcpProxyConfigFile { 87 | // #[serde(default)] 88 | // pub upstreams: Vec, 89 | // } 90 | 91 | // #[derive(Clone, Debug, Deserialize)] 92 | // pub struct HttpProxyConfigFile { 93 | // #[serde(default)] 94 | // pub upstreams: Vec, 95 | // } 96 | 97 | #[derive(Clone, Debug, Deserialize)] 98 | pub struct RuleConfigFile { 99 | pub expression: Option, 100 | pub actions: Vec, 101 | } 102 | 103 | impl Default for ServiceConfigFileStaticNotFound { 104 | fn default() -> Self { 105 | Self { 106 | file: None, 107 | status: default_service_static_not_found_status(), 108 | } 109 | } 110 | } 111 | 112 | impl<'de> Deserialize<'de> for ListenerAddressConfigFile { 113 | fn deserialize(deserializer: D) -> Result 114 | where 115 | D: Deserializer<'de>, 116 | { 117 | struct SVisitor; 118 | 119 | impl<'de> Visitor<'de> for SVisitor { 120 | type Value = ListenerAddressConfigFile; 121 | 122 | fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 123 | formatter.write_str("Listener") 124 | } 125 | 126 | fn visit_str(self, value: &str) -> Result 127 | where 128 | E: serde::de::Error, 129 | { 130 | parse_listener_address(value).map_err(|err| serde::de::Error::custom(err.to_string())) 131 | } 132 | 133 | fn visit_string(self, value: String) -> Result 134 | where 135 | E: serde::de::Error, 136 | { 137 | parse_listener_address(&value).map_err(|err| serde::de::Error::custom(err.to_string())) 138 | } 139 | } 140 | 141 | deserializer.deserialize_string(SVisitor) 142 | } 143 | } 144 | 145 | fn parse_listener_address(listener_address: &str) -> Result { 146 | let listener_uri = Uri::from_str(listener_address) 147 | .map_err(|err| Error::Config(format!("config: error parsing listener [{listener_address}]: {err}")))?; 148 | 149 | let protocol = listener_uri 150 | .scheme_str() 151 | .map(|scheme| scheme.to_string()) 152 | .unwrap_or(ListenerProtocol::Http.to_string()) 153 | .parse::() 154 | .map_err(|err| { 155 | Error::Config(format!("config: listeners.[{listener_address}]: protocol is not valid: {err}")) 156 | })?; 157 | 158 | if !listener_uri.path().is_empty() { 159 | Error::Config(format!("config: listeners.[{listener_address}]: path must be empty")); 160 | } 161 | 162 | let authority = listener_uri.authority().ok_or(Error::Config(format!( 163 | "listener address {} is not valid: authority is missing", 164 | &listener_address 165 | )))?; 166 | 167 | let port = match (authority.port_u16(), protocol) { 168 | (Some(port), _) => port, 169 | (None, ListenerProtocol::Http) => 80, 170 | (None, ListenerProtocol::Https) => 443, 171 | _ => { 172 | return Err(Error::Config(format!( 173 | "listener address {} is not valid: port is missing", 174 | &listener_address 175 | ))); 176 | } 177 | }; 178 | 179 | let ip_address: IpAddr = authority 180 | .host() 181 | .parse() 182 | .map_err(|err| Error::Config(format!("listener address {} is not valid: {err}", &listener_address)))?; 183 | 184 | return Ok(ListenerAddressConfigFile { 185 | socket_address: SocketAddr::new(ip_address, port), 186 | protocol, 187 | }); 188 | } 189 | 190 | pub fn parse_service(service_name: String, service: ServiceConfigFile) -> Result { 191 | if [ 192 | service.http_proxy.is_some(), 193 | service.tcp_proxy.is_some(), 194 | service.r#static.is_some(), 195 | ] 196 | .iter() 197 | .filter(|&&is_some| is_some) 198 | .count() 199 | != 1 200 | { 201 | return Err(Error::Config(format!( 202 | "invalid service definition for {service_name}: services must have exactly 1 http_proxy, tcp_proxy or static field" 203 | ))); 204 | } 205 | 206 | let r#static = match &service.r#static { 207 | Some(s) => { 208 | let root = PathBuf::from(&s.root); 209 | let not_found_file = s.not_found.file.clone().map(|file| { 210 | let mut not_found_path = root.clone(); 211 | not_found_path.push(file); 212 | not_found_path 213 | }); 214 | 215 | let status = StatusCode::from_u16(s.not_found.status).map_err(|_| { 216 | Error::Config(format!( 217 | "services.[{service_name}].static.not_found.status: Not a valid HTTP status code" 218 | )) 219 | })?; 220 | Some(StaticSiteServiceConfig { 221 | root: root, 222 | not_found: StaticSiteServiceNotFound { 223 | status_code: status, 224 | file: not_found_file, 225 | }, 226 | }) 227 | } 228 | None => None, 229 | }; 230 | 231 | let http_proxy = service 232 | .http_proxy 233 | .map(|upstreams| { 234 | upstreams 235 | .iter() 236 | .map(|upstream| parse_upstream(&upstream)) 237 | .collect::, _>>() 238 | }) 239 | .map_or(Ok(None), |r| r.map(Some))?; 240 | 241 | // TCP proxy 242 | if service.tcp_proxy.is_some() && service.route.is_some() { 243 | return Err(Error::Config(format!( 244 | "Invalid service definition for {service_name}: TCP proxy can't have a route" 245 | ))); 246 | } 247 | let tcp_proxy = service 248 | .tcp_proxy 249 | .map(|upstreams| { 250 | upstreams 251 | .iter() 252 | .map(|upstream| parse_upstream(&upstream)) 253 | .collect::, _>>() 254 | }) 255 | .map_or(Ok(None), |r| r.map(Some))?; 256 | 257 | let route = service 258 | .route 259 | .map(|route| { 260 | let compiled_route = rules::compile_expression(&route)?; 261 | Ok(compiled_route) 262 | }) 263 | .map(|r| r.map(Some)) 264 | .unwrap_or(Ok(None)) 265 | .map_err(|err: rules::Error| Error::Config(format!("error parsing route for service {service_name}: {err}")))?; 266 | 267 | return Ok(ServiceConfig { 268 | name: service_name, 269 | route, 270 | http_proxy, 271 | r#static: r#static, 272 | tcp_proxy, 273 | }); 274 | } 275 | 276 | const fn default_service_static_not_found_status() -> u16 { 277 | return 404; 278 | } 279 | 280 | fn parse_upstream(upstream_str: &str) -> Result { 281 | let url = 282 | Url::parse(upstream_str).map_err(|err| Error::Config(format!("{upstream_str} is not a valid URL: {err}")))?; 283 | let hostname = url.host_str().unwrap_or_default(); 284 | if hostname.is_empty() { 285 | return Err(Error::Config(format!("{upstream_str} is not a valid URL: host is missing"))); 286 | } 287 | if !hostname.is_ascii() { 288 | return Err(Error::Config(format!( 289 | "{upstream_str} is not a valid URL: only ascii hostnames are currently supported" 290 | ))); 291 | } 292 | 293 | let protocol = match url.scheme() { 294 | protocol @ ("tcp" | "http" | "https") => protocol, 295 | _ => { 296 | return Err(Error::Config(format!( 297 | "{upstream_str} is not a valid URL: {} is not a valid protocol", 298 | url.scheme() 299 | ))); 300 | } 301 | }; 302 | 303 | let port = url 304 | .port() 305 | .or_else(|| match protocol { 306 | "http" => Some(80), 307 | "https" => Some(443), 308 | _ => None, 309 | }) 310 | .ok_or(Error::Config(format!("{upstream_str} is not a valid URL: port is missing")))?; 311 | 312 | let tls = protocol == "https"; 313 | 314 | if hostname == "localhost" { 315 | return Ok(UpstreamConfig::IPAddress(Upstream { 316 | socket_address: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), port), 317 | hostname: hostname.to_string(), 318 | tls, 319 | })); 320 | } else if let Ok(ip) = hostname.parse::() { 321 | return Ok(UpstreamConfig::IPAddress(Upstream { 322 | socket_address: SocketAddr::new(ip, port), 323 | hostname: hostname.to_string(), 324 | tls, 325 | })); 326 | } else { 327 | return Ok(UpstreamConfig::Domain { 328 | hostname: hostname.to_string(), 329 | tls, 330 | port, 331 | }); 332 | } 333 | } 334 | -------------------------------------------------------------------------------- /pingoo/listeners/http_listener.rs: -------------------------------------------------------------------------------- 1 | use std::{net::SocketAddr, str::FromStr, sync::Arc}; 2 | 3 | use ::rules::Action; 4 | use cookie::Cookie; 5 | use http::Request; 6 | use hyper::service::service_fn; 7 | use hyper_util::{ 8 | rt::{TokioExecutor, TokioIo}, 9 | server::{conn::auto, graceful}, 10 | }; 11 | use tokio::sync::watch; 12 | use tracing::{debug, error}; 13 | 14 | use crate::{ 15 | Error, 16 | captcha::{CAPTCHA_VERIFIED_COOKIE, CaptchaManager, generate_captcha_client_id}, 17 | config::ListenerConfig, 18 | geoip::{self, GeoipDB, GeoipRecord}, 19 | listeners::{GRACEFUL_SHUTDOWN_TIMEOUT, Listener, accept_tcp_connection, bind_tcp_socket}, 20 | rules, 21 | services::{ 22 | HttpService, 23 | http_utils::{ 24 | HOSTNAME_MAX_LENGTH, RequestContext, RequestExtensionContext, USER_AGENT_MAX_LENGTH, get_path, 25 | new_blocked_response, new_not_found_error, 26 | }, 27 | }, 28 | }; 29 | 30 | pub struct HttpListener { 31 | name: Arc, 32 | address: SocketAddr, 33 | socket: Option, 34 | services: Arc>>, 35 | rules: Arc>, 36 | lists: Arc, 37 | geoip: Option>, 38 | captcha_manager: Arc, 39 | } 40 | 41 | impl HttpListener { 42 | pub fn new( 43 | config: ListenerConfig, 44 | services: Vec>, 45 | rules: Arc>, 46 | lists: Arc, 47 | geoip: Option>, 48 | captcha_manager: Arc, 49 | ) -> Self { 50 | return HttpListener { 51 | name: Arc::new(config.name), 52 | address: config.address, 53 | socket: None, 54 | services: Arc::new(services), 55 | rules, 56 | lists, 57 | geoip, 58 | captcha_manager, 59 | }; 60 | } 61 | } 62 | 63 | #[async_trait::async_trait] 64 | impl Listener for HttpListener { 65 | fn bind(&mut self) -> Result<(), Error> { 66 | let socket = bind_tcp_socket(self.address, &self.name)?; 67 | self.socket = Some(socket); 68 | return Ok(()); 69 | } 70 | 71 | async fn listen(self: Box, mut shutdown_signal: watch::Receiver<()>) { 72 | let tcp_socket = self 73 | .socket 74 | .expect("You need to bind the listener before calling listen()"); 75 | 76 | // GracefulShutdown watches individual connections. It can be awaited by calling `.shutdown()` 77 | // which resolves once all in-flight connections have completed. 78 | // references: 79 | // - https://docs.rs/hyper-util/latest/hyper_util/server/graceful/struct.GracefulShutdown.html 80 | // - https://github.com/hyperium/hyper-util/blob/master/examples/server_graceful.rs 81 | let graceful_shutdown = graceful::GracefulShutdown::new(); 82 | 83 | loop { 84 | tokio::select! { 85 | accept_tcp_res = accept_tcp_connection(&tcp_socket, &self.name) => { 86 | let (tcp_stream, client_socket_addr) = match accept_tcp_res { 87 | Ok(connection) => connection, 88 | Err(_) => continue, 89 | }; 90 | 91 | tokio::task::spawn(serve_http_requests( 92 | TokioIo::new(tcp_stream), 93 | self.services.clone(), 94 | client_socket_addr, 95 | self.address, 96 | self.name.clone(), 97 | self.rules.clone(), 98 | self.lists.clone(), 99 | self.geoip.clone(), 100 | self.captcha_manager.clone(), 101 | false, 102 | graceful_shutdown.watcher(), 103 | )); 104 | }, 105 | _ = shutdown_signal.changed() => { 106 | break; 107 | } 108 | } 109 | } 110 | 111 | tokio::select! { 112 | _ = graceful_shutdown.shutdown() => { 113 | debug!("listener {} has gracefully shut down", self.name); 114 | }, 115 | _ = tokio::time::sleep(GRACEFUL_SHUTDOWN_TIMEOUT) => {} 116 | } 117 | } 118 | } 119 | 120 | pub(super) async fn serve_http_requests( 121 | tcp_stream: IO, 122 | services: Arc>>, 123 | client_socket_addr: SocketAddr, 124 | listener_address: SocketAddr, 125 | listener_name: Arc, 126 | rules: Arc>, 127 | lists: Arc, 128 | geoip: Option>, 129 | captcha_manager: Arc, 130 | use_tls: bool, 131 | graceful_shutdown_watcher: graceful::Watcher, 132 | ) { 133 | let hyper_handler = service_fn(move |mut req| { 134 | let services = services.clone(); 135 | let rules = rules.clone(); 136 | let lists = lists.clone(); 137 | let geoip = geoip.clone(); 138 | let captcha_manager = captcha_manager.clone(); 139 | async move { 140 | let host = get_host(&req); 141 | let path = get_path(&req).to_string(); 142 | 143 | let geoip_record = match geoip.as_ref() { 144 | Some(geoip_db) => { 145 | let client_ip = client_socket_addr.ip(); 146 | match geoip_db.lookup(client_ip).await { 147 | Ok(geoip_record) => geoip_record, 148 | Err(err) => { 149 | if !matches!(err, geoip::Error::AddressNotFound(_)) { 150 | error!("geoip: error looking up ip {client_ip}: {err}") 151 | } 152 | GeoipRecord::default() 153 | } 154 | } 155 | } 156 | None => GeoipRecord::default(), 157 | }; 158 | 159 | let user_agent = heapless::String::::from_str( 160 | req.headers() 161 | .get("user-agent") 162 | .map(|header| header.to_str().unwrap_or_default().trim()) 163 | .unwrap_or_default(), 164 | ) 165 | .unwrap_or_default(); 166 | 167 | let client_id = generate_captcha_client_id(client_socket_addr.ip(), &user_agent, &host); 168 | 169 | let parsed_cookies = if let Some((_, cookies_header)) = req 170 | .headers() 171 | .iter() 172 | .find(|(header_name, _)| header_name.as_str() == "cookie") 173 | && let Ok(cookie_header_str) = cookies_header.to_str() 174 | { 175 | // TODO: try to avoid allocation 176 | Cookie::split_parse(cookie_header_str) 177 | .flat_map(|cookie| cookie.ok().map(|cookie| cookie.into_owned())) 178 | .collect() 179 | } else { 180 | Vec::new() 181 | }; 182 | 183 | let request_context = Arc::new(RequestContext { 184 | client_address: client_socket_addr, 185 | server_address: listener_address, 186 | asn: geoip_record.asn, 187 | country: geoip_record.country, 188 | geoip_enabled: geoip.is_some(), 189 | tls: use_tls, 190 | host: host, 191 | }); 192 | 193 | req.extensions_mut() 194 | .insert(RequestExtensionContext(request_context.clone())); 195 | 196 | if user_agent.is_empty() || user_agent.len() >= USER_AGENT_MAX_LENGTH { 197 | return Ok(new_blocked_response()); 198 | } 199 | 200 | if path.starts_with("/__pingoo/captcha") { 201 | return Ok(captcha_manager 202 | .serve_captcha_request(req, parsed_cookies, &client_id) 203 | .await); 204 | } 205 | 206 | // apply rules 207 | let request_data = rules::RequestData { 208 | host: &request_context.host, 209 | path: &path, 210 | url: req.uri(), 211 | method: req.method(), 212 | user_agent: &user_agent, 213 | }; 214 | let client_data = rules::ClientData { 215 | ip: request_context.client_address.ip(), 216 | remote_port: request_context.client_address.port() as i32, 217 | asn: request_context.asn as i64, 218 | country: request_context.country, 219 | }; 220 | 221 | // true if the captcha verified cookie is present and valid 222 | let mut captcha_verified = false; 223 | 224 | if let Some(chalenge_verified_cookie) = parsed_cookies 225 | .iter() 226 | .find(|cookie| cookie.name() == CAPTCHA_VERIFIED_COOKIE) 227 | { 228 | if captcha_manager 229 | .validate_captcha_verified_cookie(chalenge_verified_cookie.value(), &client_id) 230 | .is_ok() 231 | { 232 | captcha_verified = true; 233 | } else { 234 | return Ok(captcha_manager.serve_captcha()); 235 | } 236 | } 237 | 238 | // rules_ctx is ued for both rules matching and HTTP requests routing 239 | let mut rules_ctx = ::rules::Context::default(); 240 | // ctx.add_function("", value); 241 | // TODO: log error? 242 | if let Err(err) = rules_ctx.add_variable("http_request", request_data) { 243 | debug!("rules: error adding http_request variable: {err}") 244 | } 245 | if let Err(err) = rules_ctx.add_variable("client", &client_data) { 246 | debug!("rules: error adding client variable: {err}") 247 | } 248 | // TODO: is it really fast? Make sure than no extra clone for the list happen. Only Arc clones 249 | rules_ctx.add_variable_from_value("lists", &*lists); 250 | 251 | for rule in rules.as_ref() { 252 | if rule.match_request(&rules_ctx) { 253 | for action in &rule.actions { 254 | match action { 255 | Action::Block {} => return Ok(new_blocked_response()), 256 | Action::Captcha {} => { 257 | if !captcha_verified { 258 | return Ok(captcha_manager.serve_captcha()); 259 | } 260 | } 261 | } 262 | } 263 | } 264 | } 265 | 266 | for service in services.as_ref() { 267 | if service.match_request(&rules_ctx) { 268 | return Ok(service.handle_http_request(req).await); 269 | } 270 | } 271 | 272 | return Ok::<_, crate::Error>(new_not_found_error()); 273 | } 274 | }); 275 | 276 | if let Err(err) = graceful_shutdown_watcher 277 | .watch(auto::Builder::new(TokioExecutor::new()).serve_connection_with_upgrades(tcp_stream, hyper_handler)) 278 | .await 279 | { 280 | error!(listener = listener_name.as_ref(), "error serving HTTP connection: {err:?}"); 281 | }; 282 | } 283 | 284 | pub fn get_host(req: &Request) -> heapless::String { 285 | // uri.host is present for HTTP/2 requests 286 | if let Some(host) = req.uri().host() { 287 | return heapless::String::from_str(host.trim()).unwrap_or_default(); 288 | } 289 | 290 | // otherwise, in HTTP/1.x it should be present in the Host header 291 | if let Some(host) = req.headers().get(http::header::HOST) { 292 | return heapless::String::from_str(host.to_str().unwrap_or_default().trim()).unwrap_or_default(); 293 | } 294 | 295 | return heapless::String::new(); 296 | } 297 | --------------------------------------------------------------------------------