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 – Return false to drop the request immediately.
  • TCP(stream server.HyStream, reqAddr *string) ([]byte, error) – Inspect the stream and optionally rewrite *reqAddr before 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 newH3sHandlerhandleTCPRequest 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 – Return false to 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 false from LogTraffic to drop connections.
  • All interfaces are defined in core/server/config.go and consumed in core/server/server.go and core/client/client.go.
  • Pass your implementations via the Config struct to server.NewServer() or client.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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →