Project uses two VPS nodes with separate roles. ingress receives the client
connection and applies routing rules. egress is the remote exit node and DNS
resolver. The current transport between nodes is SSH TUN.
flowchart LR
C[Client<br/>Shadowrocket iOS / macOS]
subgraph L[local country]
I[ingress VPS<br/>Xray<br/>routing rules]
LN[Internet<br/>local exit]
end
subgraph R[remote country]
E[egress VPS<br/>SSH TUN server<br/>Unbound DNS]
RN[Internet<br/>remote exit]
end
C --> I
I -->|DIRECT by default<br/>all non-listed traffic| LN
I -->|PROXY whitelist<br/>SSH TUN transport| E
E --> RN
The scheme is designed to avoid a full-tunnel VPN mode on the client. Traffic is
DIRECT by default, and only explicitly listed domains are sent through
PROXY. This keeps normal network behavior for most applications and limits
remote egress routing to the services selected in the rules.
The client connects only to the ingress VPS in the local country. From the local network point of view, the visible VPN endpoint is local infrastructure. Traffic that is not listed in the routing policy exits normally through the local country. Only whitelist traffic is carried from ingress to egress over the transport layer and exits from the remote country.
The egress node also runs Unbound. It is used by the SSH TUN path as the remote DNS resolver and forwards queries to configured upstream DNS-over-TLS servers.
Unbound can load RPZ blocklists on the egress side. RPZ is used as a DNS-level
denylist layer for things such as malware domains, tracker domains, advertising
domains, or unwanted encrypted-DNS endpoints. Matching domains are answered with
NXDOMAIN.
These lists are configured in:
tcp/group_vars/egress.yml
Public examples only include placeholder RPZ sources:
tcp/group_vars/egress.example.yml
Routing and DNS filtering are separate layers: routing decides whether traffic is
DIRECT or PROXY, while egress Unbound applies DNS filtering only for traffic
that uses the remote egress DNS path.
Nitka runs from a local control machine and deploys two remote VPS nodes.
Supported control machines:
- macOS with Docker Desktop
- Linux with Docker Engine and the Docker Compose plugin
Required local tools:
- Bash
- Docker with
docker compose - curl
- ssh-keygen and ssh-keyscan
- tar, base64, sed, and awk
Remote VPS requirements:
- Debian-based Linux
- public IPv4 address
- initial SSH access as
rootor a sudo-capable user
Docker is installed on the remote nodes by the playbooks.
From the repository root:
./run.shThe default entrypoint is an interactive menu:
1. Setup flow
2. Deploy
3. Operations
4. Current node state
5. Client links
6. State path
7. Help
For a first deployment, open Setup flow and start the setup wizard. It asks
for:
- ingress VPS IP, SSH user, SSH port, and auth method
- egress VPS IP, SSH user, SSH port, and auth method
- final confirmation before applying the deployment plan
Then it runs:
- init
- Docker runner build
- bootstrap
- SSH hardening
- egress deploy
- ingress deploy
- show generated VLESS links
The same setup flow can also be started directly:
./run.sh --setupThe repository contains public code and examples only. Real runtime files are stored outside the Git checkout in encrypted Ansible Vault state:
~/.local/state/nitka/vault/secrets.env.vault
On first setup, Nitka asks the user to create a Vault password. That password is
not stored by the project. It is required later when run.sh needs to decrypt
or update infrastructure state, for example during deploys, rotations, backup
restore, or when printing sensitive client links.
After both nodes are installed, the Vault contains the infrastructure state needed to reproduce and manage the deployment:
- ingress and egress connection details
- generated management SSH keys
- SSH TUN transport keypair
- generated Xray UUIDs, ports, and Reality keys
- routing policy and Shadowrocket profile
- generated client links
Public examples:
group_vars/ingress.example.ymlgroup_vars/egress.example.ymlinventory/ingress.example.ymlinventory/egress.example.ymlrouting/rules.example.ymlshadowrocket.example.conf
During a run, the encrypted state is materialized into the local generated cache and mounted into the Ansible runner as:
/workspace/tcp/.generated
Back up state for transfer to another machine:
./run.sh --backup-stateRestore it after cloning the repository:
./run.sh --restore-state <archive>.
├── run.sh # menu, setup wizard, vault backup/restore
├── Dockerfile # portable Ansible runner image
├── compose.yml # local runner compose file
└── tcp/ # Ansible project
├── inventory/*.example.yml # public node inventory examples
├── group_vars/*.example.yml # public node variable examples
├── routing/rules.example.yml # public routing policy example
├── shadowrocket.example.conf # public client profile example
├── ingress.yml # deploy ingress node
├── egress.yml # deploy egress node
├── bootstrap.yml # create management users and keys
├── harden_ssh.yml # harden SSH with rollback protection
├── debug_ssh.yml # manual SSH diagnostics
└── roles/
├── ingress/ # Xray, clashrs, ssh_tun_client, watchdog
├── egress/ # ssh_tun_server and Unbound DNS
├── system_base/ # Docker, timers, image updater, audit helper
└── transports/ssh_tun/ # SSH TUN transport layer
The public example policy lives in routing/rules.example.yml. The real policy
is restored from Vault as routing/rules.yml and is not tracked by git.
Routing is whitelist-based:
DIRECTis the default pathPROXYis used only for explicitly listed domains- changing
routing/rules.ymland redeploying ingress updates the rendered Xray config
The public example profile is shadowrocket.example.conf. The real
shadowrocket.conf is restored from Vault and is not tracked by git.
The interactive menu is the primary interface. Direct commands are available for automation, debugging, and repeatable operations. The complete command list with descriptions is available with:
./run.sh --helpMost common direct commands:
./run.sh --setup
./run.sh --deploy
./run.sh --show-links
./run.sh --rotate xray
./run.sh --rotate ssh-tun
./run.sh --state-path
./run.sh --lockManual deployment targets:
./run.sh --egress # deploy only the egress node
./run.sh --ingress # deploy only the ingress node
./run.sh --syntax # run Ansible syntax checks./run.sh --sync-egress-host-key is a recovery operation for SSH TUN host-key
pinning. It shows the current pinned fingerprint and the live egress fingerprint,
updates encrypted state only after confirmation, and then deploys ingress.
Persistent state is stored outside the Git checkout.
Default path inside the materialized local state:
~/.local/state/nitka/
Important files:
vault/secrets.env.vault # encrypted Ansible Vault source of truth
materialized/ # ephemeral working cache, deleted after commands
The Vault password is chosen by the user during setup. Some menu actions and
direct commands prompt for it before showing or changing sensitive data. The
materialized cache is temporary working data; ./run.sh --lock removes it
without deleting the encrypted Vault.
The Docker runner mounts materialized/ as:
/workspace/tcp/.generated
This keeps the Ansible roles compatible with the existing .generated layout
while ensuring that permanent secrets are not stored in the project directory.
If the Git checkout is deleted and cloned again, credentials remain in:
~/.local/state/nitka/vault/secrets.env.vault
Show the active state paths:
./run.sh --state-pathUse a custom encrypted state location:
NITKA_STATE_ROOT=/secure/path/nitka ./run.sh --setupRemove the ephemeral materialized cache:
./run.sh --lock./run.sh --init creates the local runtime configuration for a first deployment.
It asks for:
- ingress VPS IP: public entrypoint where clients connect to Xray
- ingress initial SSH user and port
- ingress initial SSH authentication method: private key path or password
- egress VPS IP: exit node used for proxied internet traffic
- egress initial SSH user and port
- egress initial SSH authentication method: private key path or password
User and port prompts show defaults explicitly:
Ingress initial SSH user [default: root, press Enter]:
Ingress initial SSH port [default: 22, press Enter]:
Egress initial SSH user [default: root, press Enter]:
Egress initial SSH port [default: 22, press Enter]:
Ingress and egress can use different users, ports, private keys, or passwords.
If a private key path is provided, the key is copied into the ephemeral
materialized bootstrap directory with 0600 permissions and then stored in the
encrypted Vault as base64.
If password authentication is selected, the password is written only into the ephemeral generated inventory and persisted in the encrypted Vault.
The init command also generates local management SSH keypairs under the ephemeral materialized management directory. They are persisted in the encrypted Vault as base64 and restored only when a command needs them.
./run.sh --bootstrap connects through the initial SSH access collected by init
and creates dedicated management users:
- ingress node:
ingress - egress node:
egress
It installs the generated management public keys, configures passwordless sudo,
verifies sudo -n true, and then rewrites inventory to use:
.generated/management/ingress/id_ed25519.generated/management/egress/id_ed25519
SSH daemon hardening is intentionally separate from bootstrap because it changes access policy and must use an emergency rollback timer.
./run.sh --harden-ssh is normally called by ./run.sh --setup after bootstrap.
For each node it asks whether to change the management SSH port:
- default answer is
yes - if the VPS is behind NAT or provider port forwarding, answer
no - if a new port is generated, the wizard asks for explicit confirmation before applying it
The generated sshd_config:
- disables root login
- disables password authentication
- keeps public-key authentication only
- disables SSH forwarding and SFTP
- uses hardened ciphers, MACs, and supported KEX algorithms
- restricts access with
AllowGroups
AllowGroups always includes the node admin group:
- ingress:
ingress-admin - egress:
egress-admin
If the initial SSH user was not root, its primary group is preserved too, for
example:
AllowGroups egress-admin debian
Root is never preserved in AllowGroups.
Before reloading SSH, the playbook creates an emergency rollback timer. If the
new connection check fails, the old sshd_config is restored automatically after
about 2 minutes. The rollback timer is cancelled only after the new generated
management user/key/port is verified from the control side.
Runtime ports are generated during init with bot-pattern filtering:
- SSH TUN egress port
- XHTTP inbound port
- Reality inbound port
The generator avoids repeated digits, simple sequences, mirror-like patterns, and too-similar adjacent digits.
The SSH TUN transport keypair is generated automatically on the control side and materialized at:
.generated/egress/ssh/id_ed25519.generated/egress/ssh/id_ed25519.pub
The Xray UUIDs, ports, and Reality keypair are generated automatically and materialized at:
.generated/ingress/state/
File transfer over SSH is configured with transfer_method = piped in
ansible.cfg, so Ansible avoids relying on remote scp/sftp support.
These materialized files are an ephemeral working cache. The persistent source
of truth is the encrypted Ansible Vault state under
~/.local/state/nitka/vault/secrets.env.vault.
Rotate Xray client credentials and print new Shadowrocket-compatible links:
./run.sh --rotate xrayRotate the internal SSH TUN transport keypair and redeploy both nodes:
./run.sh --rotate ssh-tunRemove only this project from both nodes:
./run.sh --uninstallThis stops Compose only inside the configured project directories and removes:
ingress_remote_diregress_remote_dirnitka-ingress-watchdog.servicenitka-ingress-watchdog.timernitka-os-updater.servicenitka-os-updater.timer- project Docker updater units
It does not stop or remove unrelated Docker containers.
The system_base role prepares each Debian node before deploying the stack:
- configures Debian and Docker apt sources
- installs Docker CE and the Docker Compose plugin
- sets timezone to UTC
- enables a kernel-aware
nitka-os-updater.timer - enables a project-specific Docker updater timer
The Docker updater is service-aware:
- external images are pulled explicitly
- Xray and clash-rs use local aliases,
local/xray-core:autoandlocal/clash-rs:latest - those aliases are retagged from the newest GitHub release whose tag matches
vX.Y.Z - build services are rebuilt only when their base image changed or the service image is missing
- build services use
docker compose build --pull --no-cache - services are recreated one by one with
docker compose up -d --no-deps --no-build --force-recreate - health is checked after each rollout
- previous image IDs are retagged for rollback if rollout fails
- image and builder pruning happens only after a successful rollout
Ingress updater cascade:
ssh_tun_clientchange recreatesclashrsandxrayclashrschange recreatesxray
Egress updater cascade:
ssh_tun_serverchange recreatesunbound
This avoids stale Docker network namespaces for services using
network_mode: service:*.
- The SSH TUN transport is the current validated transport path.
- Some service families, especially Google and OpenAI, are still routed mainly by domains rather than by complete public IP coverage.
The reserved transport role namespace is:
roles/transports/ssh_tun/
The current SSH TUN implementation lives in this role. It is attached from the playbooks with:
roles:
- role: transports/ssh_tun
vars:
ssh_tun_endpoint: server
- egressand:
roles:
- role: transports/ssh_tun
vars:
ssh_tun_endpoint: client
- ingress