Feature/outline glue (#106)

* feat: added outline integration layer

* chore: make the function used in RegisterFallbackParser a standalone one

* fix: check if domain has a dot prior trimming it

* fix: use net.JoinHostPort instead of plain concat
This commit is contained in:
Yaroslav Gurov 2025-12-19 03:14:48 +01:00 committed by GitHub
parent e796d477d8
commit 449d7cffd4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 400 additions and 4 deletions

77
outline/dialer.go Normal file
View file

@ -0,0 +1,77 @@
package outline
import (
"context"
"fmt"
"net"
"net/netip"
"github.com/Jigsaw-Code/outline-sdk/transport"
"github.com/amnezia-vpn/amneziawg-go/conn"
"github.com/amnezia-vpn/amneziawg-go/device"
"github.com/amnezia-vpn/amneziawg-go/tun/netstack"
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
)
type DialerOptions struct {
Ipc string
Prefixes []netip.Prefix
Mtu int
Dns []netip.Addr
}
func NewStreamDialer(opts DialerOptions) (*StreamDialer, error) {
var localAddresses []netip.Addr
for _, prefix := range opts.Prefixes {
localAddresses = append(localAddresses, prefix.Addr())
}
tun, tnet, err := netstack.CreateNetTUN(localAddresses, opts.Dns, opts.Mtu)
if err != nil {
return nil, fmt.Errorf("failed to create network tun: %v", err)
}
awgLogger := device.Logger{
Verbosef: func(format string, args ...any) {
},
Errorf: func(format string, args ...any) {
},
}
dev := device.NewDevice(tun, conn.NewDefaultBind(), &awgLogger)
if err := dev.IpcSet(opts.Ipc); err != nil {
return nil, fmt.Errorf("failed to configure device: %v", err)
}
if err := dev.Up(); err != nil {
return nil, fmt.Errorf("failed to start awg device: %v", err)
}
return &StreamDialer{
tnet: tnet,
}, nil
}
var _ transport.StreamDialer = (*StreamDialer)(nil)
type StreamDialer struct {
tnet *netstack.Net
}
func (d *StreamDialer) DialStream(ctx context.Context, raddr string) (transport.StreamConn, error) {
host, port, err := net.SplitHostPort(raddr)
if err != nil {
return nil, fmt.Errorf("failed to parse raddr: %v", err)
}
if l := len(host); l > 0 && host[l-1] == '.' {
host = host[:l-1]
raddr = net.JoinHostPort(host, port)
}
conn, err := d.tnet.DialContext(ctx, "tcp", raddr)
if err != nil {
return nil, err
}
return conn.(*gonet.TCPConn), nil
}

224
outline/fallback.go Normal file
View file

@ -0,0 +1,224 @@
package outline
import (
"context"
"encoding/base64"
"encoding/hex"
"fmt"
"net/netip"
"strconv"
"strings"
"github.com/Jigsaw-Code/outline-sdk/transport"
"github.com/Jigsaw-Code/outline-sdk/x/mobileproxy"
"github.com/Jigsaw-Code/outline-sdk/x/smart"
"github.com/goccy/go-yaml"
)
type DeviceConfig struct {
PrivateKey string `yaml:"private_key"`
Address []string `yaml:"address"`
Dns []string `yaml:"dns"`
Mtu int `yaml:"mtu,omitempty"`
Jc int `yaml:"jc,omitempty"`
Jmin int `yaml:"jmin,omitempty"`
Jmax int `yaml:"jmax,omitempty"`
S1 int `yaml:"s1,omitempty"`
S2 int `yaml:"s2,omitempty"`
S3 int `yaml:"s3,omitempty"`
S4 int `yaml:"s4,omitempty"`
H1 string `yaml:"h1,omitempty"`
H2 string `yaml:"h2,omitempty"`
H3 string `yaml:"h3,omitempty"`
H4 string `yaml:"h4,omitempty"`
I1 string `yaml:"i1,omitempty"`
I2 string `yaml:"i2,omitempty"`
I3 string `yaml:"i3,omitempty"`
I4 string `yaml:"i4,omitempty"`
I5 string `yaml:"i5,omitempty"`
Peers []PeerConfig `yaml:"peers,omitempty"`
}
type PeerConfig struct {
PublicKey string `yaml:"public_key"`
PresharedKey string `yaml:"preshared_key,omitempty"`
Endpoint string `yaml:"endpoint"`
AllowedIPs []string `yaml:"allowed_ips"`
PersistentKeepaliveInterval uint16 `yaml:"persistent_keepalive_interval,omitempty"`
}
func mapYamlToConfig(y smart.YAMLNode) (*DeviceConfig, error) {
bytes, err := yaml.Marshal(y)
if err != nil {
return nil, fmt.Errorf("failed to marshal yaml: %v", err)
}
var cfg DeviceConfig
if err = yaml.Unmarshal(bytes, &cfg); err != nil {
return nil, fmt.Errorf("failed to unmarshal yaml: %v", err)
}
return &cfg, nil
}
func genIpcString(cfg *DeviceConfig) (string, error) {
privateKeyBytes, err := base64.StdEncoding.DecodeString(cfg.PrivateKey)
if err != nil {
return "", fmt.Errorf("failed to decode private key: %v", err)
}
var b strings.Builder
b.WriteString("private_key=")
b.WriteString(hex.EncodeToString(privateKeyBytes))
if cfg.Jc != 0 {
b.WriteString("\njc=")
b.WriteString(strconv.Itoa(cfg.Jc))
}
if cfg.Jmin != 0 {
b.WriteString("\njmin=")
b.WriteString(strconv.Itoa(cfg.Jmin))
}
if cfg.Jmax != 0 {
b.WriteString("\njmax=")
b.WriteString(strconv.Itoa(cfg.Jmax))
}
if cfg.S1 != 0 {
b.WriteString("\ns1=")
b.WriteString(strconv.Itoa(cfg.S1))
}
if cfg.S2 != 0 {
b.WriteString("\ns2=")
b.WriteString(strconv.Itoa(cfg.S2))
}
if cfg.S3 != 0 {
b.WriteString("\ns3=")
b.WriteString(strconv.Itoa(cfg.S3))
}
if cfg.S4 != 0 {
b.WriteString("\ns4=")
b.WriteString(strconv.Itoa(cfg.S4))
}
if cfg.H1 != "" {
b.WriteString("\nh1=")
b.WriteString(cfg.H1)
}
if cfg.H2 != "" {
b.WriteString("\nh2=")
b.WriteString(cfg.H2)
}
if cfg.H3 != "" {
b.WriteString("\nh3=")
b.WriteString(cfg.H3)
}
if cfg.H4 != "" {
b.WriteString("\nh4=")
b.WriteString(cfg.H4)
}
if cfg.I1 != "" {
b.WriteString("\ni1=")
b.WriteString(cfg.I1)
}
if cfg.I2 != "" {
b.WriteString("\ni2=")
b.WriteString(cfg.I2)
}
if cfg.I3 != "" {
b.WriteString("\ni3=")
b.WriteString(cfg.I3)
}
if cfg.I4 != "" {
b.WriteString("\ni4=")
b.WriteString(cfg.I4)
}
if cfg.I5 != "" {
b.WriteString("\ni5=")
b.WriteString(cfg.I5)
}
for _, peer := range cfg.Peers {
publicKeyBytes, err := base64.StdEncoding.DecodeString(peer.PublicKey)
if err != nil {
return "", fmt.Errorf("failed to decode public key: %v", err)
}
b.WriteString("\npublic_key=")
b.WriteString(hex.EncodeToString(publicKeyBytes))
b.WriteString("\nendpoint=")
b.WriteString(peer.Endpoint)
for _, allowedIp := range peer.AllowedIPs {
b.WriteString("\nallowed_ip=")
b.WriteString(allowedIp)
}
if peer.PresharedKey != "" {
presharedKeyBytes, err := base64.StdEncoding.DecodeString(peer.PresharedKey)
if err != nil {
return "", fmt.Errorf("failed to decode preshared key: %v", err)
}
b.WriteString("\npreshared_key=")
b.WriteString(hex.EncodeToString(presharedKeyBytes))
}
if peer.PersistentKeepaliveInterval != 0 {
b.WriteString("\npersistent_keepalive_interval=")
b.WriteString(strconv.Itoa(int(peer.PersistentKeepaliveInterval)))
}
}
return b.String(), nil
}
func FallbackParser(ctx context.Context, y smart.YAMLNode) (transport.StreamDialer, string, error) {
cfg, err := mapYamlToConfig(y)
if err != nil {
return nil, "", fmt.Errorf("failed to map yaml to config: %v", err)
}
ipc, err := genIpcString(cfg)
if err != nil {
return nil, "", fmt.Errorf("faield to generate ipc config: %v", err)
}
var prefixes []netip.Prefix
for _, address := range cfg.Address {
prefix, err := netip.ParsePrefix(address)
if err != nil {
return nil, "", fmt.Errorf("failed to parse address: %v", err)
}
prefixes = append(prefixes, prefix)
}
var dns []netip.Addr
for _, saddr := range cfg.Dns {
addr, err := netip.ParseAddr(saddr)
if err != nil {
return nil, "", fmt.Errorf("failed to parse dns: %v", err)
}
dns = append(dns, addr)
}
if cfg.Mtu == 0 {
cfg.Mtu = 1408
}
dialer, err := NewStreamDialer(DialerOptions{
Ipc: ipc,
Prefixes: prefixes,
Mtu: cfg.Mtu,
Dns: dns,
})
if err != nil {
return nil, "", fmt.Errorf("failed to create dialer: %v", err)
}
return dialer, ipc, nil
}
func RegisterFallbackParser(opt *mobileproxy.SmartDialerOptions, name string) {
opt.RegisterFallbackParser(name, FallbackParser)
}

52
outline/fallback_test.go Normal file
View file

@ -0,0 +1,52 @@
package outline_test
import (
"testing"
"github.com/Jigsaw-Code/outline-sdk/x/mobileproxy"
awg "github.com/amnezia-vpn/amneziawg-go/outline"
)
const cfg = `
dns:
- {system: {}}
tls:
- ""
fallback:
- awg:
address: [10.0.0.0/32]
dns: [8.8.8.8, 8.8.4.4]
private_key: +CdqlYvjqZ3OUr4mLWvGJo1h67CWpQwMIxA5OpyiJUM=
jc: 4
jmin: 50
jmax: 100
s1: 87
s2: 65
s3: 43
s4: 21
h1: 1000000000-1000000001
h2: 2000000000-2000000002
h3: 3000000000-3000000003
h4: 4000000000-4000000004
peers:
- public_key: EGxNYihRLKQ9nvdOE5j5aZ7rtw3ttzJS1xxaJpgYYHI=
preshared_key: 2OiSh6rP3t/g39jgJNGK70B+nize821yIFNtUqi8/XU=
endpoint: 123.123.123.123:51820
allowed_ips: [0.0.0.0/0, ::/0]
persistent_keepalive_interval: 25
`
var testDomains = mobileproxy.NewListFromLines("example.com")
func Test_outlineIntegration(t *testing.T) {
opts := mobileproxy.NewSmartDialerOptions(testDomains, cfg)
opts.SetLogWriter(mobileproxy.NewStderrLogWriter())
awg.RegisterFallbackParser(opts, "awg")
dialer, err := opts.NewStreamDialer()
if err != nil {
t.Fatal(err)
}
if _, err = mobileproxy.RunProxy("", dialer); err != nil {
t.Fatal(err)
}
}