中文 | English
dae-ppdn is a personal fork of dae-next, continuously enhanced with features and performance optimizations based on the author's real-world usage and needs.
Like upstream dae, it is a high-performance transparent proxy solution that leverages eBPF in the Linux kernel for traffic splitting — direct traffic bypasses the proxy entirely, achieving near-zero performance overhead.
dae (daeuniverse/dae) — Original project
└─ dae-next (LostAttractor/dae) — Refactored outbound interface, new config options
└─ dae-ppdn (this repo) — HTTP APIs, DNS enhancements, extended protocol support
| Category | Change |
|---|---|
dial_mode replacement |
Replaced with three independent config options: dial_target_override, reroute_mode, sniff_verify_mode |
Group priority |
Nodes in a group can now define a priority with weight, order, and latency parameters (e.g. priority: '0,2(,300ms)') |
min_moving_avg EMA params |
The min_moving_avg policy now supports configurable EMA algorithm parameters: min_moving_avg(window, alpha) (e.g. min_moving_avg(10s, 0.2)) |
| Metrics | Restructured internal metrics |
| Outbound interface | Refactored — all protocols require re-adaptation (progress tracked at LostAttractor/dae) |
Config compatibility: dae-ppdn is compatible with dae-next configs. dae-next is not compatible with dae configs.
| Category | Additions |
|---|---|
| HTTP APIs | RESTful command server via command_port (redirect control, priority management, dynamic DNS static entries) |
udp_sniff_ports |
Configurable UDP port list for traffic sniffing (default: 443) |
DNS dns_cache_tag |
Dialer-level DNS cache domain annotation (-l), allowing dialers on the same VPS/region to share DNS cache |
DNS ecs |
EDNS0 Client Subnet control: global dns.ecs default (strip) plus per-dialer [ecs: ...] annotation (strip/<cidr>/pass) — stops client-subnet leaks and CDN region mismatch through proxies |
DNS static |
User-defined static DNS entries with A, AAAA, TXT records — hot-reloadable via HTTP API |
DNS via |
Route DNS queries through a specific outbound group (e.g. proxy_dns(via: ai)) |
DNS race |
Query multiple upstreams concurrently, first response wins — define race groups in the upstream section |
DNS response routing |
Match DNS responses by answering upstream / response code / answer IPs / qtype / client and accept, reject, or re-resolve via another upstream |
DNS mac + sip |
Per-client DNS filtering by MAC address or source IP |
| DNS pool tuning | Configurable udp_pool_size, udp_pool_ttl, tcp_pool_size, tcp_pool_ttl |
| Protocols | Extended support: Trojan, SSR, SS, SS2022, VLESS, VMess, AnyTLS, Tuic (v5), Juicity, Hysteria2 |
| VLESS mux | Supports both v2ray-native mux and sing-box smux |
Note: Some protocol parameters may have incomplete support. Refer to the source for specifics.
Some performance optimizations are inspired by kdae. Notably, eBPF maps use BPF_MAP_TYPE_HASH instead of BPF_MAP_TYPE_LRU_HASH — the kernel never silently evicts entries, avoiding state inconsistency between kernel and userspace. Instead, a userspace Janitor (control/bpf_map_janitor.go) periodically scans and cleans up stale entries with explicit timeouts per map type.
Each node in a group can carry a priority annotation in the filter line, controlling its relative weight in node selection:
group {
hk {
filter: subtag(sub1) && name(regex:'香港') [priority: '0,2(,300ms)']
filter: subtag(sub2) && name(regex:'香港') [priority: '0,1(,200ms)']
filter: subtag(sub3) && name(regex:'香港')
policy: min_moving_avg
}
}The priority value consists of a default priority and optional conditional priorities based on observed latency:
priority: '<default>[,<pri>(<latency_low>,<latency_high>)[; ...]]'
| Part | Meaning |
|---|---|
<default> |
Base priority used when no latency condition matches |
<pri>(<low>,<high>) |
Override priority when the node's observed latency falls within [low, high] |
In the example above:
sub1nodes default to priority 0, but jump to priority 2 when latency is below 300ms — with amin_moving_avgpolicy, higher-priority nodes are preferred, so sub1 dominates when it's fast.sub2nodes default to priority 0, jump to priority 1 when below 200ms — a tighter latency requirement with lower reward.sub3nodes have no annotation, meaning priority defaults to 0 with no conditional boost.
This mechanism allows fine-grained traffic steering: give higher-quality nodes more traffic during good periods, without permanently de-prioritizing other nodes.
Outbound redirect allows one group to transparently forward all traffic to another group. A redirect group has no nodes, filters, or policies of its own — it acts as a lightweight alias.
Redirect is resolved at two levels:
- Config level — declare
redirect: <target>in agroupblock. All traffic routed to the source group is forwarded to the target group via a direct dialer. - Runtime level — change redirect targets on the fly via the HTTP API, no config reload required.
group {
# These groups forward traffic to region-specific groups
macbook {
redirect: tw
}
wg {
redirect: hk
}
ai {
redirect: sg
}
# Real groups with nodes and policies
hk {
filter: subtag(sub1) && name(regex:'香港')
policy: min_moving_avg(10s, 0.2)
}
tw {
filter: subtag(sub1) && name(regex:'台湾')
policy: min_moving_avg(10s, 0.2)
}
sg {
filter: subtag(sub1) && name(regex:'新加坡')
policy: min_moving_avg(10s, 0.2)
}
}
routing {
# Route specific devices/traffic to the redirect groups
mac('11:22:33:44:55:66') -> macbook
sip(10.0.0.1/24) -> wg
domain(keyword:gemini, keyword:openai) -> ai
fallback: hk
}Redirect groups are zero-cost: they carry no nodes, skip connectivity checks, and when redirected to direct or block, traffic is short-circuited entirely in eBPF kernel space without touching userspace.
dae update allows hot-updating specific parts of the running configuration without a full reload — existing connections are not interrupted. It works by re-reading the config file and applying only the changed sections.
dae update sub # Re-fetch subscriptions, refresh node list
dae update dns # Apply DNS config changes (upstreams, routing rules)
dae update routing # Apply routing rule changesSubscription update re-resolves all subscriptions and hot-swaps dialers, but the overall group structure must be preserved — structural changes require a full reload (SIGUSR1):
| Allowed | Rejected (use SIGUSR1 for full reload) |
|---|---|
| Add/remove nodes via subscription | Add or delete a group block |
Modify filter rules and annotations |
Rename a group |
Change policy parameters |
Change group count |
| Change node priority / latency params | Switch a group between redirect and normal |
Modify redirect target (redirect: x → redirect: y) |
|
| Add/remove subscription blocks |
Note: Only one
dae updatecommand can run at a time — concurrent updates are silently skipped.
Hot-swaps the entire DNS subsystem (upstreams, routing, cache settings). The command will fail if a via target references a group that does not exist.
Hot-swaps the routing rule table. The command will fail if a routing rule references a group (outbound) that does not exist.
When command_port is set in the global config, dae-ppdn exposes RESTful HTTP endpoints:
# List all outbounds with redirect targets
curl http://localhost:1111/redirect
# Redirect an outbound to a different group
curl -X PUT http://localhost:1111/redirect/fb \
-H "Content-Type: text/plain" -d 'tw'
# Remove a redirect (point back to itself)
curl -X PUT http://localhost:1111/redirect/fb \
-H "Content-Type: text/plain" -d 'fb'# List all priorities
curl http://localhost:1111/priority
# Change a specific dialer's priority (via query params)
curl -X PUT "http://localhost:1111/priority?outbound=hk&subtag=sub3&dialer=香港" \
-H "Content-Type: text/plain" -d '0,3(,300ms)'Combining redirect and priority APIs enables manual node switching: redirect a group to a single-node group, or boost a specific node's priority to maximum to force traffic through it.
# Find which domains resolved to a given IP
curl http://localhost:1111/lookup/1.2.3.4Static DNS entries defined in dns.static can be updated at runtime without restart:
# List all static entries
curl http://localhost:1111/static
# Get a specific entry
curl http://localhost:1111/static/local_nas
# Update an entry (multi-field)
curl -X PUT http://localhost:1111/static/local_nas \
-H "Content-Type: text/plain" \
-d $'a: 192.168.16.42\naaaa: fdfe::be24:11ff:fe64:1234\nttl: 3600'Static DNS responses are not cached, so updates take effect immediately.
Dialers sharing the same -l tag use a shared DNS cache domain. This is useful when multiple dialers reside on the same VPS in the same region — they can safely share cached DNS results, reducing upstream queries:
group {
mix {
filter: subtag(sub1) && name(keyword:'新加坡') [dns_cache_tag: 'sg']
filter: subtag(sub2) && name(keyword:'东京') [dns_cache_tag: 'jp']
policy: min_moving_avg
}
}Queries forwarded through a proxy while carrying the client's ECS option make CDNs schedule by the client's region, yet the connection actually leaves from the exit IP: answers and connection path disagree, and the client subnet leaks through the tunnel. dae strips the client subnet by default (dns { ecs: 'strip' }), and allows a per-dialer override via the filter annotation:
group {
mix {
filter: subtag(sub1) [ecs: 'strip'] # remove the client subnet before forwarding (default)
filter: subtag(sub2) [ecs: '203.0.113.0/24'] # announce this subnet so CDN answers match that region
filter: subtag(sub3) [ecs: 'pass'] # forward the client's ECS option as-is
policy: min_moving_avg
}
}Dialers applying different ECS policies never share cached DNS answers — the effective policy is mixed into the cache key, and a CIDR value has its host bits masked automatically.
Define static A, AAAA, and TXT records directly in the config. Then reference them in DNS routing via static(name):
dns {
static {
local_nas {
a: 192.168.16.42
aaaa: 'fdfe::be24:11ff:fe64:3598'
ttl: 600
}
}
routing {
request {
qname(suffix:mydomain.xyz) -> static(local_nas)
}
}
}Force a DNS upstream query to use a specific outbound group:
qname(keyword:gemini, keyword:openai) -> proxy_dns(via: ai)Define a race group in the upstream section — it queries its upstreams
concurrently and uses the first response:
upstream {
race_dns: 'race(udp://1.1.1.1:53, udp://8.8.8.8:53)'
}Reference it from routing by tag:
qname(geosite:gfw) -> race_dnsMembers may be raw links or the tags of other upstreams. The group can also be bound to an outbound group at the reference site — every member is then dialed through that group while still picking the best node inside it:
qname(geosite:gfw) -> race_dns(via: ai)Cached answers are picked in config order: the first member with a fresh entry answers without dialing. Otherwise the first expired entry is served as a stale answer while every member holding one is refreshed in the background — one flight per member, so that refresh races the group as well.
AAAA queries skip members whose dialer cannot proxy IPv6 and are answered
empty only when no member can. The race covers the forwarding step: response
rules run on the winning answer, and a rule that sends the query back to the
group starts another round against its members. Tags must be unique, and a
group with a single member is not a group at all: at load time its tag binds
straight to that upstream, exactly as if it had been declared directly.
The response block matches a DNS response — by the upstream that answered
it, the response code, the IPs in the answer, the qtype, or the client — and
then accepts it, rejects it, or re-resolves the query through another upstream.
The canonical use is anti-poisoning: if a domestic upstream answers a non-CN IP
for a foreign name, re-query through another upstream — a race group is allowed
as the target, in which case its members are queried concurrently again:
response {
rcode(nxdomain) -> accept # names that don't exist: accept, don't re-resolve
!upstream(race_dns) && !ip(geoip:private) && !ip(geoip:cn) -> race_dns
fallback: accept
}Notes:
rcode(...)matches the response code:noerror,formerr,servfail,nxdomain,notimp,refused, ... (case-insensitive) or the number (rcode(3)). An NXDOMAIN carries no answer IPs, soip(...)never matches it — without an rcode rule, every NXDOMAIN from that upstream triggers a re-resolution round-trip. Trade-off: a name its upstream answers with NXDOMAIN is no longer recovered by the re-resolution.upstream(<race tag>)matches a response answered by any member of that race group (the group itself never answers; responses are attributed to the member that did). A race group works as the re-resolution target too; the lookup-depth bound spans the expansion, so a rule that does not exclude the group's members converges to SERVFAIL instead of looping forever.upstream(...)matches the upstream the response came from. A bare name also covers thevia:-bound shadow members: a response answered throughrace_dns(via: ai)matchesupstream(cf_dns),upstream('cf_dns(ai)'), or the group as a whole (upstream(race_dns)).- Excluding the re-resolution target with
!upstream(...)is required: it stops the re-resolved answer from matching the same rule again and exhausting the lookup-depth bound (3), which would turn the query into SERVFAIL. !ip(geoip:private)keeps LAN/static answers out of the re-resolution: they are intentional answers, and public upstreams would only replace them with NXDOMAIN.- Re-resolved answers are cached like ordinary ones, so subsequent queries hit the cache and only re-run the (cheap) response matching.
!negation, multiple values (upstream(a, b)= a OR b), and&&chaining with the other matchers (qtype,rcode,ip,mac,sip,qname) all work.- Everything else falls back to
fallback:(acceptorreject).
Per-client DNS filtering based on MAC address or source IP:
qtype(aaaa) && mac('11:22:33:44:55:66') -> reject
qtype(aaaa) && sip(192.168.1.100) -> rejectControl DNS connection pooling behavior per forwarder (keyed by upstream + dialer + target):
dns {
udp_pool_size: 10 # UDP connections per forwarder
udp_pool_ttl: 10m # TTL for idle UDP connections
tcp_pool_size: 2 # TCP connections per forwarder
tcp_pool_ttl: 30s # TTL for idle TCP connections
}| Protocol | Schemes | Notes |
|---|---|---|
| HTTP(S) | http, https |
Generic HTTP CONNECT proxy |
| Socks5 | socks, socks5 |
|
| Shadowsocks | ss, shadowsocks |
AEAD, stream ciphers, SS2022 (2022-blake3-aes-*), simple-obfs, v2ray-plugin (WS+TLS) |
| ShadowsocksR | ssr, shadowsocksr |
|
| VMess | vmess |
AEAD, alterID=0; TCP, WS, TLS, gRPC, Meek, HTTPUpgrade |
| VLESS | vless |
Reality, gRPC, Meek, HTTPUpgrade, mux, smux |
| Trojan | trojan, trojan-go |
trojan-gfw and trojan-go |
| Tuic | tuic |
v5 |
| Juicity | juicity |
|
| Hysteria2 | hysteria2, hy2 |
|
| AnyTLS | anytls |
|
| Proxy chain | -> |
Chain multiple protocols with -> separator |
See docs/en/proxy-protocols.md for URI schemas and details.
VLESS supports both v2ray-native mux and sing-box smux for connection multiplexing. Add these query parameters to the VLESS node link:
| Parameter | Type | Default | Description |
|---|---|---|---|
mux / multiplex |
bool | false |
Enable v2ray-native mux |
smux |
bool | false |
Enable sing-box smux |
mux_concurrency |
int | 8 |
Max concurrent streams per connection |
mux_idle_timeout |
int | 300 |
Idle timeout in seconds before closing a sub-connection |
Note:
muxandsmuxare incompatible withflow(e.g.xtls-rprx-vision).
Example:
vless://uuid@host:port?type=tcp&security=reality&smux=1&mux_concurrency=4&mux_idle_timeout=120#NodeName
AnyTLS supports maintaining a pool of idle sessions to avoid the overhead of establishing new connections. Configure via URL query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
minIdleSession |
int | 0 |
Minimum idle sessions kept alive for immediate use |
idleCheckInterval |
duration | — | How often to scan for expired idle sessions (e.g. 30s) |
idleTimeout |
duration | — | Max lifetime of an idle session before recycling (e.g. 5m) |
Example:
anytls://user@host:port?minIdleSession=2&idleCheckInterval=30s&idleTimeout=5m#NodeName
Please refer to the Quick Start Guide for kernel requirements, installation, and minimal configuration.
A full example config is available at example_next.dae.
- Original dae project by daeuniverse
- dae-next by LostAttractor
- All contributors