How to Modify Hysteria Core for Custom Features: Plugin Architecture Guide
Yes, Hysteria 2 exposes a plugin architecture via Go interfaces that lets you add custom authentication, routing, traffic accounting, and logging without forking the apernet/hysteria repository.
Hysteria is a high-performance QUIC-based proxy tunnel. While the stock binary covers most deployment scenarios, the core library is deliberately modular. You can modify Hysteria core functionality by implementing Go interfaces defined in core/server/config.go and injecting them into the Config struct when constructing a server or client.
Core Extension Points
The Hysteria server and client rely on interface-driven configuration. If you leave a field nil, the core falls back to safe defaults (defaultOutbound, no logging, etc.). To customize behavior, instantiate your own types that satisfy these contracts.
Authentication (Authenticator)
The Authenticator interface controls whether a client is permitted to connect. It is defined in core/server/config.go and invoked during the initial QUIC handshake.
The method signature is:
Authenticate(addr net.Addr, auth string, tx uint64) (bool, string)
Return true and a unique client identifier string to accept the connection, or false to reject. The tx parameter indicates transmit bytes at the time of auth, useful for replay protection.
Request Filtering and Rewriting (RequestHook)
To inspect or mutate the first packet of a stream, implement the RequestHook interface. The server calls these methods in core/server/server.go (lines 66-84 for the auth flow and 190-203 for TCP handling).
The interface requires three methods:
Check(isUDP bool, reqAddr string) bool– Returnfalseto drop the request immediately.TCP(stream server.HyStream, reqAddr *string) ([]byte, error)– Inspect the stream and optionally rewrite*reqAddrbefore routing.UDP(data []byte, reqAddr *string) error– Same for UDP sessions.
Outbound Connectivity (Outbound)
The Outbound interface lets you replace the default TCP and UDP dialers. This is useful for chaining proxies, custom DNS resolution, or traffic shaping. Defined in core/server/config.go (lines 52-59), it is instantiated inside newH3sHandler → handleTCPRequest in core/server/server.go (lines 90-105).
type Outbound interface {
TCP(reqAddr string) (net.Conn, error)
UDP(reqAddr string) (server.UDPConn, error)
}
Traffic Accounting and Quotas (TrafficLogger)
For per-client metrics or hard bandwidth limits, implement TrafficLogger from core/server/config.go (lines 38-48). The core consults this interface during every read/write operation.
Key methods:
LogTraffic(id string, tx, rx uint64) bool– Returnfalseto force-disconnect the client (e.g., when a quota is exceeded).LogOnlineState(id string, online bool)– Track connect/disconnect events.TraceStream(s server.HyStream, stats *server.StreamStats)– Attach to specific streams for granular monitoring.
Event Logging (EventLogger)
The EventLogger interface provides hooks for connection lifecycle events (Connect, Disconnect, TCPRequest, etc.), useful for audit trails or external alerting systems.
Congestion and TLS Configuration
You can also modify Hysteria core transport behavior via CongestionConfig (selecting bbr, cubic, or brutal algorithms) and TLSConfig/QUICConfig structs to override certificates, receive windows, and idle timeouts.
Practical Implementation Examples
Custom RequestHook for Dynamic DNS Routing
This example rewrites the destination address based on a live DNS lookup, useful for geo-load balancing or bypassing censored DNS.
// file: myhook.go
package myhook
import (
"context"
"net"
"github.com/apernet/hysteria/core/v2/server"
)
type DNSHook struct{}
func (h *DNSHook) Check(isUDP bool, reqAddr string) bool { return true }
func (h *DNSHook) TCP(stream server.HyStream, reqAddr *string) ([]byte, error) {
host, port, _ := net.SplitHostPort(*reqAddr)
ips, err := net.DefaultResolver.LookupIP(context.Background(), "ip4", host)
if err == nil && len(ips) > 0 {
*reqAddr = net.JoinHostPort(ips[0].String(), port)
}
return nil, nil
}
func (h *DNSHook) UDP(data []byte, reqAddr *string) error { return nil }
Inject the hook when creating the server:
cfg := &server.Config{
// ... TLS and QUIC fields ...
Authenticator: myAuth,
RequestHook: &myhook.DNSHook{},
}
srv, err := server.NewServer(cfg)
if err != nil {
log.Fatal(err)
}
go srv.Serve()
Custom Outbound for SOCKS5 Proxy Chaining
Replace the default direct dialer with a SOCKS5 proxy for all upstream connections.
// file: socks5outbound.go
package socks5out
import (
"net"
"github.com/armon/go-socks5"
"github.com/apernet/hysteria/core/v2/server"
)
type Socks5Outbound struct {
Proxy *socks5.Server
}
func (o *Socks5Outbound) TCP(reqAddr string) (net.Conn, error) {
return o.Proxy.Dial("tcp", reqAddr)
}
func (o *Socks5Outbound) UDP(reqAddr string) (server.UDPConn, error) {
// Delegate to default for UDP, or implement custom logic
return server.DefaultOutbound{}.UDP(reqAddr)
}
Wire it into the configuration:
proxy, _ := socks5.New(&socks5.Config{})
cfg := &server.Config{
Outbound: &socks5out.Socks5Outbound{Proxy: proxy},
}
srv, _ := server.NewServer(cfg)
_ = srv.Serve()
Traffic Quota Enforcement with TrafficLogger
Enforce a hard limit per client (e.g., 10 MiB) by returning false from LogTraffic when the cap is reached.
// file: quota_logger.go
package quota
import (
"sync"
"github.com/apernet/hysteria/core/v2/server"
)
type QuotaLogger struct {
limit uint64
mu sync.Mutex
used map[string]uint64
}
func NewQuotaLogger(limit uint64) *QuotaLogger {
return &QuotaLogger{limit: limit, used: make(map[string]uint64)}
}
func (q *QuotaLogger) LogTraffic(id string, tx, rx uint64) bool {
q.mu.Lock()
defer q.mu.Unlock()
q.used[id] += tx + rx
return q.used[id] <= q.limit // false triggers disconnect
}
func (q *QuotaLogger) LogOnlineState(id string, online bool) {}
func (q *QuotaLogger) TraceStream(s server.HyStream, _ *server.StreamStats) {}
func (q *QuotaLogger) UntraceStream(s server.HyStream) {}
Usage:
cfg := &server.Config{
TrafficLogger: quota.NewQuotaLogger(10 * 1024 * 1024), // 10 MiB
}
srv, _ := server.NewServer(cfg)
_ = srv.Serve()
Client-Side Modifications
The client follows the same pattern. The client.NewClient function in core/client/client.go accepts a *Config defined in core/client/config.go. While the client exposes fewer hooks than the server, you can still customize TLSConfig, QUICConfig, and CongestionConfig to control handshake parameters and congestion behavior on the dialer side.
Summary
- Hysteria 2 uses a plugin architecture based on Go interfaces; you do not need to fork the repository to add custom features.
- Implement Authenticator for custom auth backends, RequestHook for packet inspection/rewriting, and Outbound for custom dialers.
- Use TrafficLogger to enforce quotas or export metrics; return
falsefromLogTrafficto drop connections. - All interfaces are defined in
core/server/config.goand consumed incore/server/server.goandcore/client/client.go. - Pass your implementations via the
Configstruct toserver.NewServer()orclient.NewClient().
Frequently Asked Questions
Do I need to fork the Hysteria repository to add custom features?
No. According to the apernet/hysteria source code, you can implement the extension interfaces (Authenticator, RequestHook, Outbound, TrafficLogger) in your own Go package, import the core modules, and inject your types via the Config struct. This keeps your custom logic separate from upstream updates.
Which interface should I implement for per-client bandwidth quotas?
Implement the TrafficLogger interface defined in core/server/config.go (lines 38-48). The LogTraffic(id string, tx, rx uint64) bool method receives cumulative byte counts; returning false signals the core to terminate that client's connection immediately, effectively enforcing your quota limit.
Can I modify both server and client behavior this way?
Yes. Both server.NewServer() and client.NewClient() accept a Config struct that exposes TLSConfig, QUICConfig, and CongestionConfig. However, the server side offers richer extension points (authentication hooks, traffic logging, request rewriting) compared to the client, which primarily allows transport-level customization.
Where are the default implementations if I don't provide a custom plugin?
If you leave interface fields nil in the Config struct, core/server/server.go and core/client/client.go automatically instantiate safe defaults. For example, defaultOutbound in core/server/config.go (lines 68-84) provides standard TCP dialers and full-cone UDP listeners, ensuring the server or client remains functional without custom code.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →