├── .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
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 | 
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