package main
import (
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"net"
"net/http"
"net/netip"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/Jigsaw-Code/outline-ss-server/ipinfo"
"github.com/google/uuid"
"github.com/lmittmann/tint"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/spf13/cobra"
"golang.org/x/term"
"gopkg.in/yaml.v3"
"sourcecraft.dev/bigbes/outline-distro/internal/api"
"sourcecraft.dev/bigbes/outline-distro/internal/awgserver"
"sourcecraft.dev/bigbes/outline-distro/internal/config"
"sourcecraft.dev/bigbes/outline-distro/internal/metrics"
"sourcecraft.dev/bigbes/outline-distro/internal/ssserver"
"sourcecraft.dev/bigbes/outline-distro/internal/store"
)
var version = "dev"
func main() {
if err := newRootCmd().Execute(); err != nil {
os.Exit(1)
}
}
func newRootCmd() *cobra.Command {
var (
configFile string
verbose bool
)
root := &cobra.Command{
Use: "outline-distro",
Short: "Single-binary Outline Shadowsocks server with Prometheus metrics",
Version: version,
RunE: func(cmd *cobra.Command, args []string) error {
return runServe(configFile, verbose)
},
SilenceUsage: true,
SilenceErrors: true,
}
pf := root.PersistentFlags()
pf.StringVarP(&configFile, "config", "c", "config.yaml", "configuration file path")
pf.BoolVarP(&verbose, "verbose", "v", false, "enable debug logging")
root.AddCommand(
newCompletionCmd(),
newKeyCmd(&configFile),
newServerCmd(&configFile),
)
return root
}
// --- completion ---
func newCompletionCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Generate shell completion scripts",
Long: `Generate shell completion scripts for outline-distro.
To load completions:
Bash:
$ source <(outline-distro completion bash)
# To install permanently:
$ outline-distro completion bash > /etc/bash_completion.d/outline-distro
Zsh:
$ source <(outline-distro completion zsh)
# To install permanently:
$ outline-distro completion zsh > "${fpath[1]}/_outline-distro"
Fish:
$ outline-distro completion fish | source
# To install permanently:
$ outline-distro completion fish > ~/.config/fish/completions/outline-distro.fish
PowerShell:
PS> outline-distro completion powershell | Out-String | Invoke-Expression
`,
DisableFlagsInUseLine: true,
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
switch args[0] {
case "bash":
return cmd.Root().GenBashCompletion(os.Stdout)
case "zsh":
return cmd.Root().GenZshCompletion(os.Stdout)
case "fish":
return cmd.Root().GenFishCompletion(os.Stdout, true)
case "powershell":
return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
default:
return fmt.Errorf("unsupported shell: %s", args[0])
}
},
}
return cmd
}
// --- key commands ---
func newKeyCmd(configFile *string) *cobra.Command {
cmd := &cobra.Command{
Use: "key",
Short: "Manage access keys",
}
cmd.AddCommand(
newKeyListCmd(configFile),
newKeyAddCmd(configFile),
newKeyRemoveCmd(configFile),
newKeyRenameCmd(configFile),
)
return cmd
}
func newKeyListCmd(configFile *string) *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all access keys",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
st, err := openStore(*configFile)
if err != nil {
return err
}
keys := st.ListKeys()
if len(keys) == 0 {
fmt.Println("No access keys configured.")
return nil
}
for _, k := range keys {
limit := "none"
if k.DataLimit != nil {
limit = fmt.Sprintf("%d bytes", k.DataLimit.Bytes)
}
awgInfo := ""
if k.AWG != nil {
awgInfo = fmt.Sprintf(" awg=%s", k.AWG.AllowedIP)
}
fmt.Printf("%-6s %-20s port=%-6d cipher=%s limit=%s%s\n",
k.ID, k.Name, k.Port, k.Method, limit, awgInfo)
}
return nil
},
}
}
func newKeyAddCmd(configFile *string) *cobra.Command {
var (
name string
port int
cipher string
)
cmd := &cobra.Command{
Use: "add",
Short: "Add a new access key",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(*configFile)
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
st, err := openStore(*configFile)
if err != nil {
return err
}
srv := st.GetServer()
password, err := store.GeneratePassword()
if err != nil {
return err
}
id := fmt.Sprintf("%d", srv.NextID)
if err := st.UpdateServer(func(s *store.ServerState) {
s.NextID++
}); err != nil {
return err
}
if cipher == "" {
cipher = srv.DefaultCipher
}
if port == 0 {
port = srv.PortForNewAccessKeys
}
ak := store.AccessKey{
ID: id,
Name: name,
Password: password,
Port: port,
Method: cipher,
}
// Generate AWG credentials if AWG is enabled.
if cfg.AmneziaWG.Enabled && cfg.AmneziaWG.Address != "" {
privKey, err := awgserver.GeneratePrivateKey()
if err != nil {
return fmt.Errorf("generating AWG key: %w", err)
}
pubKey, err := awgserver.PublicKeyFromPrivate(privKey)
if err != nil {
return fmt.Errorf("deriving AWG public key: %w", err)
}
keys := st.ListKeys()
used := make([]string, 0, len(keys))
for _, k := range keys {
if k.AWG != nil {
used = append(used, k.AWG.AllowedIP)
}
}
alloc, err := awgserver.NewIPAllocator(cfg.AmneziaWG.Address)
if err != nil {
return fmt.Errorf("creating IP allocator: %w", err)
}
ip, err := alloc.Allocate(used)
if err != nil {
return fmt.Errorf("allocating AWG IP: %w", err)
}
ak.AWG = &store.AWGKeyData{
PrivateKey: privKey,
PublicKey: pubKey,
AllowedIP: ip,
}
}
if err := st.CreateKey(ak); err != nil {
return err
}
fmt.Printf("Created key %s (port=%d, cipher=%s)\n", id, port, cipher)
fmt.Printf(" ss://%s\n", password)
if ak.AWG != nil {
fmt.Printf(" awg: %s (pubkey=%s)\n", ak.AWG.AllowedIP, ak.AWG.PublicKey)
}
return nil
},
}
cmd.Flags().StringVarP(&name, "name", "n", "", "key name")
cmd.Flags().IntVarP(&port, "port", "p", 0, "port (default: server's default port)")
cmd.Flags().StringVar(&cipher, "cipher", "", "cipher (default: server's default cipher)")
return cmd
}
func newKeyRemoveCmd(configFile *string) *cobra.Command {
return &cobra.Command{
Use: "remove <id>",
Short: "Remove an access key",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
st, err := openStore(*configFile)
if err != nil {
return err
}
if err := st.DeleteKey(args[0]); err != nil {
return err
}
fmt.Printf("Removed key %s\n", args[0])
return nil
},
}
}
func newKeyRenameCmd(configFile *string) *cobra.Command {
return &cobra.Command{
Use: "rename <id> <name>",
Short: "Rename an access key",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
st, err := openStore(*configFile)
if err != nil {
return err
}
return st.UpdateKey(args[0], func(k *store.AccessKey) {
k.Name = args[1]
})
},
}
}
// --- server commands ---
func newServerCmd(configFile *string) *cobra.Command {
cmd := &cobra.Command{
Use: "server",
Short: "Server configuration commands",
}
cmd.AddCommand(
newServerInfoCmd(configFile),
newServerSetPortCmd(configFile),
newServerSetHostnameCmd(configFile),
newServerSetNameCmd(configFile),
)
return cmd
}
func newServerInfoCmd(configFile *string) *cobra.Command {
return &cobra.Command{
Use: "info",
Short: "Show server configuration",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
st, err := openStore(*configFile)
if err != nil {
return err
}
srv := st.GetServer()
fmt.Printf("Server ID: %s\n", srv.ID)
fmt.Printf("Name: %s\n", srv.Name)
fmt.Printf("Hostname: %s\n", srv.Hostname)
fmt.Printf("Default Port: %d\n", srv.PortForNewAccessKeys)
fmt.Printf("Default Cipher: %s\n", srv.DefaultCipher)
fmt.Printf("Metrics Enabled: %v\n", srv.MetricsEnabled)
fmt.Printf("Access Keys: %d\n", len(st.ListKeys()))
if srv.AccessKeyDataLimit != nil {
fmt.Printf("Data Limit: %d bytes\n", srv.AccessKeyDataLimit.Bytes)
}
return nil
},
}
}
func newServerSetPortCmd(configFile *string) *cobra.Command {
return &cobra.Command{
Use: "set-port <port>",
Short: "Set the default port for new access keys",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
st, err := openStore(*configFile)
if err != nil {
return err
}
var port int
if _, err := fmt.Sscanf(args[0], "%d", &port); err != nil || port < 1 || port > 65535 {
return fmt.Errorf("invalid port: %s", args[0])
}
if err := st.UpdateServer(func(s *store.ServerState) {
s.PortForNewAccessKeys = port
}); err != nil {
return err
}
fmt.Printf("Default port set to %d\n", port)
return nil
},
}
}
func newServerSetHostnameCmd(configFile *string) *cobra.Command {
return &cobra.Command{
Use: "set-hostname <hostname>",
Short: "Set the hostname for access key URLs",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
st, err := openStore(*configFile)
if err != nil {
return err
}
if err := st.UpdateServer(func(s *store.ServerState) {
s.Hostname = args[0]
}); err != nil {
return err
}
fmt.Printf("Hostname set to %s\n", args[0])
return nil
},
}
}
func newServerSetNameCmd(configFile *string) *cobra.Command {
return &cobra.Command{
Use: "set-name <name>",
Short: "Set the server display name",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
st, err := openStore(*configFile)
if err != nil {
return err
}
if err := st.UpdateServer(func(s *store.ServerState) {
s.Name = args[0]
}); err != nil {
return err
}
fmt.Printf("Server name set to %s\n", args[0])
return nil
},
}
}
// --- helpers ---
func openStore(configFile string) (*store.YAMLFileStore, error) {
cfg, err := config.Load(configFile)
if err != nil {
return nil, fmt.Errorf("loading config: %w", err)
}
return store.NewYAMLFileStore(
cfg.StateFile(),
"", cfg.Server.Name, cfg.Server.Hostname,
cfg.Shadowsocks.DefaultCipher, cfg.Shadowsocks.DefaultPort,
)
}
// --- access info YAML ---
type accessInfo struct {
APIURL string `yaml:"api_url"`
CertSHA256 string `yaml:"cert_sha256"`
}
func writeAccessInfo(apiAddr, secret, certFile, infoFile string) error {
certSHA256 := ""
if certFile != "" {
cert, err := tls.LoadX509KeyPair(certFile, certFile)
if err == nil && len(cert.Certificate) > 0 {
hash := sha256.Sum256(cert.Certificate[0])
certSHA256 = hex.EncodeToString(hash[:])
}
}
info := accessInfo{
APIURL: fmt.Sprintf("https://%s/%s", apiAddr, secret),
CertSHA256: certSHA256,
}
data, err := yaml.Marshal(info)
if err != nil {
return err
}
return os.WriteFile(infoFile, data, 0600)
}
// --- serve ---
func runServe(configFile string, verbose bool) error {
logLevel := new(slog.LevelVar)
logHandler := tint.NewHandler(
os.Stderr,
&tint.Options{NoColor: !term.IsTerminal(int(os.Stderr.Fd())), Level: logLevel},
)
logger := slog.New(logHandler)
slog.SetDefault(logger)
if verbose {
logLevel.Set(slog.LevelDebug)
}
cfg, err := config.Load(configFile)
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
// Generate server ID if not set.
serverID := cfg.Server.ID
if serverID == "" {
serverID = uuid.New().String()
}
// Generate API secret if not set.
apiSecret := cfg.API.Secret
if apiSecret == "" {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return fmt.Errorf("generating API secret: %w", err)
}
apiSecret = hex.EncodeToString(buf)
logger.Info("Generated API secret.", "secret", apiSecret)
}
// Parse NAT timeout.
natTimeout, err := time.ParseDuration(cfg.Shadowsocks.NATTimeout)
if err != nil {
return fmt.Errorf("invalid NAT timeout: %w", err)
}
// Pick a random unused port for Shadowsocks if not configured.
ssDefaultPort := cfg.Shadowsocks.DefaultPort
if ssDefaultPort == 0 {
p, err := pickRandomPort()
if err != nil {
return fmt.Errorf("picking random port for shadowsocks: %w", err)
}
ssDefaultPort = p
logger.Info("Selected random port for new access keys.", "port", ssDefaultPort)
}
// Set up IP info for geo-metrics.
var ip2info ipinfo.IPInfoMap
if cfg.Shadowsocks.IPCountryDB != "" || cfg.Shadowsocks.IPASNDB != "" {
mmdb, err := ipinfo.NewMMDBIPInfoMap(cfg.Shadowsocks.IPCountryDB, cfg.Shadowsocks.IPASNDB)
if err != nil {
return fmt.Errorf("loading IP info databases: %w", err)
}
defer mmdb.Close()
ip2info = mmdb
}
// Set up metrics.
serverMetrics := metrics.NewServerMetrics()
registry, transferTracker, _, err := metrics.SetupRegistry(
ip2info,
serverMetrics,
version,
cfg.Metrics.NodeExporterCollectors,
logger,
)
if err != nil {
return fmt.Errorf("setting up metrics: %w", err)
}
// Set up persistent store.
st, err := store.NewYAMLFileStore(
cfg.StateFile(),
serverID,
cfg.Server.Name,
cfg.Server.Hostname,
cfg.Shadowsocks.DefaultCipher,
ssDefaultPort,
)
if err != nil {
return fmt.Errorf("initializing store: %w", err)
}
// Set up Shadowsocks server.
ss := ssserver.New(
natTimeout,
cfg.Shadowsocks.ReplayHistory,
serverMetrics,
transferTracker,
logger,
)
// Start with existing keys.
if keys := st.ListKeys(); len(keys) > 0 {
if err := ss.SyncKeys(keys); err != nil {
return fmt.Errorf("starting Shadowsocks with existing keys: %w", err)
}
logger.Info("Loaded existing access keys.", "count", len(keys))
}
// Set up AmneziaWG server (optional).
var awg *awgserver.Server
if cfg.AmneziaWG.Enabled {
// Ensure server has AWG keypair.
srv := st.GetServer()
if srv.AWGPrivateKey == "" {
privKey, err := awgserver.GeneratePrivateKey()
if err != nil {
return fmt.Errorf("generating AWG server key: %w", err)
}
pubKey, err := awgserver.PublicKeyFromPrivate(privKey)
if err != nil {
return fmt.Errorf("deriving AWG server public key: %w", err)
}
if err := st.UpdateServer(func(s *store.ServerState) {
s.AWGPrivateKey = privKey
s.AWGPublicKey = pubKey
}); err != nil {
return fmt.Errorf("persisting AWG server key: %w", err)
}
logger.Info("Generated AWG server keypair.")
srv = st.GetServer()
}
// Parse H1-H4 ranges.
h1, err := awgserver.ParseHeaderRange(cfg.AmneziaWG.H1)
if err != nil {
return fmt.Errorf("parsing AWG H1: %w", err)
}
h2, err := awgserver.ParseHeaderRange(cfg.AmneziaWG.H2)
if err != nil {
return fmt.Errorf("parsing AWG H2: %w", err)
}
h3, err := awgserver.ParseHeaderRange(cfg.AmneziaWG.H3)
if err != nil {
return fmt.Errorf("parsing AWG H3: %w", err)
}
h4, err := awgserver.ParseHeaderRange(cfg.AmneziaWG.H4)
if err != nil {
return fmt.Errorf("parsing AWG H4: %w", err)
}
// Build server address from subnet (take .1).
alloc, err := awgserver.NewIPAllocator(cfg.AmneziaWG.Address)
if err != nil {
return fmt.Errorf("parsing AWG address: %w", err)
}
serverAddr := fmt.Sprintf("%s/%d", alloc.ServerIP().String(), netip.MustParsePrefix(cfg.AmneziaWG.Address).Bits())
awgCfg := awgserver.Config{
ListenPort: cfg.AmneziaWG.ListenPort,
TUNName: cfg.AmneziaWG.TUNName,
Address: serverAddr,
MTU: cfg.AmneziaWG.MTU,
PrivateKey: srv.AWGPrivateKey,
Domain: cfg.AmneziaWG.Domain,
CertCache: cfg.AmneziaWG.CertCache,
ACMEHTTPPort: cfg.AmneziaWG.ACMEHTTPPort,
Jc: cfg.AmneziaWG.Jc,
Jmin: cfg.AmneziaWG.Jmin,
Jmax: cfg.AmneziaWG.Jmax,
S1: cfg.AmneziaWG.S1,
S2: cfg.AmneziaWG.S2,
S3: cfg.AmneziaWG.S3,
S4: cfg.AmneziaWG.S4,
H1: h1,
H2: h2,
H3: h3,
H4: h4,
}
awg = awgserver.New(awgCfg, logger)
if err := awg.Start(); err != nil {
return fmt.Errorf("starting AWG server: %w", err)
}
if keys := st.ListKeys(); len(keys) > 0 {
if err := awg.SyncKeys(keys); err != nil {
return fmt.Errorf("syncing AWG peers: %w", err)
}
}
logger.Info("AmneziaWG server started.", "port", cfg.AmneziaWG.ListenPort)
}
// Start Prometheus metrics + /healthz endpoint.
metricsHandler := promhttp.HandlerFor(registry, promhttp.HandlerOpts{})
metricsMux := http.NewServeMux()
metricsMux.Handle("/metrics", metricsHandler)
metricsMux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{
"healthy": true,
"version": version,
"accessKeys": len(st.ListKeys()),
})
})
metricsServer := &http.Server{Addr: cfg.Metrics.ListenAddr, Handler: metricsMux}
go func() {
logger.Info("Prometheus metrics server started.", "address", cfg.Metrics.ListenAddr)
if err := metricsServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("Metrics server failed.", "err", err)
}
}()
// Start REST API.
var awgCfgPtr *config.AmneziaWGConfig
if cfg.AmneziaWG.Enabled {
awgCfgPtr = &cfg.AmneziaWG
}
handler := api.NewHandler(st, ss, awg, awgCfgPtr, transferTracker, version, logger)
router := api.NewRouter(apiSecret, handler)
apiServer := &http.Server{Addr: cfg.API.ListenAddr, Handler: router}
go func() {
logger.Info("Management API started.", "address", cfg.API.ListenAddr, "prefix", "/"+apiSecret)
if err := apiServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("API server failed.", "err", err)
}
}()
// Write access info.
infoFile := filepath.Join(filepath.Dir(cfg.StateFile()), "access.yaml")
if err := writeAccessInfo(cfg.API.ListenAddr, apiSecret, cfg.API.CertFile, infoFile); err != nil {
logger.Warn("Failed to write access info.", "err", err)
} else {
logger.Info("Access info written.", "file", infoFile)
}
logger.Info("outline-distro started.",
"version", version,
"api", cfg.API.ListenAddr,
"metrics", cfg.Metrics.ListenAddr,
)
// Wait for shutdown signal.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
logger.Info("Shutting down...")
if awg != nil {
awg.Stop()
}
ss.Stop()
apiServer.Close()
metricsServer.Close()
return nil
}
func pickRandomPort() (int, error) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, err
}
port := ln.Addr().(*net.TCPAddr).Port
ln.Close()
return port, nil
}