commit cddd7bbd540535d417ff5f186a437854e1c865d9 Author: Amin.MasterkinG Date: Mon Apr 20 13:53:51 2026 +0330 First commit, (Socks5 server!) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f8ddef --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +Python Edition +config.toml \ No newline at end of file diff --git a/client.go b/client.go new file mode 100644 index 0000000..1382168 --- /dev/null +++ b/client.go @@ -0,0 +1,37 @@ +// ============================================================================== +// MasterHttpRelayVPN +// Author: MasterkinG32 +// Github: https://github.com/masterking32 +// Year: 2026 +// ============================================================================== + +package main + +import ( + "context" + "os" + "os/signal" + "syscall" + + "masterhttprelayvpn/internal/client" + "masterhttprelayvpn/internal/config" + lg "masterhttprelayvpn/internal/logger" +) + +func main() { + logger := lg.New("MasterHttpRelayVPN Client", "INFO") + + cfg, err := config.Load("config.toml") + if err != nil { + logger.Fatalf("load config: %v", err) + } + + app := client.New(cfg, logger) + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + if err := app.Run(ctx); err != nil { + logger.Fatalf("run client: %v", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a150523 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module masterhttprelayvpn + +go 1.26.0 + +require golang.org/x/sys v0.42.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d2913d5 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/internal/client/client.go b/internal/client/client.go new file mode 100644 index 0000000..9231910 --- /dev/null +++ b/internal/client/client.go @@ -0,0 +1,105 @@ +// ============================================================================== +// MasterHttpRelayVPN +// Author: MasterkinG32 +// Github: https://github.com/masterking32 +// Year: 2026 +// ============================================================================== +package client + +import ( + "context" + "fmt" + "net" + "sync" + "time" + + "masterhttprelayvpn/internal/config" + "masterhttprelayvpn/internal/logger" +) + +type Client struct { + cfg config.Config + log *logger.Logger + sessions *SessionStore + + connMu sync.Mutex + conns map[net.Conn]struct{} +} + +func New(cfg config.Config, lg *logger.Logger) *Client { + return &Client{ + cfg: cfg, + log: lg, + sessions: NewSessionStore(), + conns: make(map[net.Conn]struct{}), + } +} + +func (c *Client) Run(ctx context.Context) error { + addr := fmt.Sprintf("%s:%d", c.cfg.SOCKSHost, c.cfg.SOCKSPort) + ln, err := net.Listen("tcp", addr) + if err != nil { + return err + } + defer ln.Close() + + c.log.Infof("SOCKS5 listener started on %s", addr) + + go func() { + <-ctx.Done() + c.log.Infof("shutdown requested, closing listener and active sessions") + _ = ln.Close() + c.closeAllConns() + }() + + var wg sync.WaitGroup + defer wg.Wait() + + for { + conn, err := ln.Accept() + if err != nil { + select { + case <-ctx.Done(): + return nil + default: + } + + if ne, ok := err.(net.Error); ok && ne.Temporary() { + time.Sleep(100 * time.Millisecond) + continue + } + return err + } + + wg.Add(1) + go func() { + defer wg.Done() + c.handleConn(ctx, conn) + }() + } +} + +func (c *Client) registerConn(conn net.Conn) { + c.connMu.Lock() + c.conns[conn] = struct{}{} + c.connMu.Unlock() +} + +func (c *Client) unregisterConn(conn net.Conn) { + c.connMu.Lock() + delete(c.conns, conn) + c.connMu.Unlock() +} + +func (c *Client) closeAllConns() { + c.connMu.Lock() + conns := make([]net.Conn, 0, len(c.conns)) + for conn := range c.conns { + conns = append(conns, conn) + } + c.connMu.Unlock() + + for _, conn := range conns { + _ = conn.Close() + } +} diff --git a/internal/client/session.go b/internal/client/session.go new file mode 100644 index 0000000..649009b --- /dev/null +++ b/internal/client/session.go @@ -0,0 +1,71 @@ +// ============================================================================== +// MasterHttpRelayVPN +// Author: MasterkinG32 +// Github: https://github.com/masterking32 +// Year: 2026 +// ============================================================================== +package client + +import ( + "encoding/hex" + "sync" + "sync/atomic" + "time" +) + +type Session struct { + ID uint64 + CreatedAt time.Time + LastActivityAt time.Time + ClientAddr string + TargetHost string + TargetPort uint16 + AddressType byte + InitialPayload []byte + BytesCaptured int + AuthMethod byte + UsernameUsed string + HandshakeDone bool + ConnectAccepted bool +} + +func (s *Session) InitialPayloadHex() string { + if len(s.InitialPayload) == 0 { + return "" + } + return hex.EncodeToString(s.InitialPayload) +} + +type SessionStore struct { + nextID atomic.Uint64 + mu sync.RWMutex + items map[uint64]*Session +} + +func NewSessionStore() *SessionStore { + return &SessionStore{ + items: make(map[uint64]*Session), + } +} + +func (s *SessionStore) New(clientAddr string) *Session { + id := s.nextID.Add(1) + now := time.Now() + session := &Session{ + ID: id, + CreatedAt: now, + LastActivityAt: now, + ClientAddr: clientAddr, + } + + s.mu.Lock() + s.items[id] = session + s.mu.Unlock() + return session +} + +func (s *SessionStore) Delete(id uint64) { + s.mu.Lock() + delete(s.items, id) + s.mu.Unlock() +} diff --git a/internal/client/socks5.go b/internal/client/socks5.go new file mode 100644 index 0000000..62b252d --- /dev/null +++ b/internal/client/socks5.go @@ -0,0 +1,302 @@ +// ============================================================================== +// MasterHttpRelayVPN +// Author: MasterkinG32 +// Github: https://github.com/masterking32 +// Year: 2026 +// ============================================================================== +package client + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "slices" + "strconv" + "time" +) + +const ( + socksVersion5 = 0x05 + + socksMethodNoAuth = 0x00 + socksMethodUserPass = 0x02 + socksMethodNoAcceptable = 0xFF + + socksCmdConnect = 0x01 + + socksAtypIPv4 = 0x01 + socksAtypDomain = 0x03 + socksAtypIPv6 = 0x04 + + socksReplySuccess = 0x00 + socksReplyGeneralFailure = 0x01 + socksReplyCommandUnsupported = 0x07 + socksReplyAddressUnsupported = 0x08 + + socksUserPassVersion = 0x01 + socksAuthSuccess = 0x00 + socksAuthFailure = 0x01 +) + +func (c *Client) handleConn(ctx context.Context, conn net.Conn) { + c.registerConn(conn) + defer c.unregisterConn(conn) + defer conn.Close() + + session := c.sessions.New(conn.RemoteAddr().String()) + defer c.sessions.Delete(session.ID) + + c.log.Infof("accepted client %s session=%d", conn.RemoteAddr(), session.ID) + + if err := c.handleSOCKS5(ctx, conn, session); err != nil { + c.log.Errorf("session=%d closed: %v", session.ID, err) + return + } +} + +func (c *Client) handleSOCKS5(ctx context.Context, conn net.Conn, session *Session) error { + version := make([]byte, 1) + if _, err := io.ReadFull(conn, version); err != nil { + return err + } + if version[0] != socksVersion5 { + return fmt.Errorf("unsupported SOCKS version: %d", version[0]) + } + + method, err := c.negotiateAuth(conn, session) + if err != nil { + return err + } + + if method == socksMethodUserPass { + if err := c.handleUserPassAuth(conn, session); err != nil { + return err + } + } + + targetHost, targetPort, atyp, err := readConnectRequest(conn) + if err != nil { + return err + } + + session.TargetHost = targetHost + session.TargetPort = targetPort + session.AddressType = atyp + session.ConnectAccepted = true + session.HandshakeDone = true + session.LastActivityAt = time.Now() + + if err := writeSocksReply(conn, socksReplySuccess); err != nil { + return err + } + + c.log.Infof( + "session=%d CONNECT target=%s:%d auth_method=%d", + session.ID, session.TargetHost, session.TargetPort, session.AuthMethod, + ) + + return c.captureInitialPayload(ctx, conn, session) +} + +func (c *Client) negotiateAuth(conn net.Conn, session *Session) (byte, error) { + countBuf := make([]byte, 1) + if _, err := io.ReadFull(conn, countBuf); err != nil { + return 0, err + } + + methodCount := int(countBuf[0]) + methods := make([]byte, methodCount) + if _, err := io.ReadFull(conn, methods); err != nil { + return 0, err + } + + selected := byte(socksMethodNoAcceptable) + if c.cfg.SOCKSAuth { + if slices.Contains(methods, socksMethodUserPass) { + selected = socksMethodUserPass + } + } else { + if slices.Contains(methods, socksMethodNoAuth) { + selected = socksMethodNoAuth + } + } + + if _, err := conn.Write([]byte{socksVersion5, selected}); err != nil { + return 0, err + } + if selected == socksMethodNoAcceptable { + return 0, errors.New("no acceptable auth method") + } + + session.AuthMethod = selected + return selected, nil +} + +func (c *Client) handleUserPassAuth(conn net.Conn, session *Session) error { + header := make([]byte, 2) + if _, err := io.ReadFull(conn, header); err != nil { + return err + } + if header[0] != socksUserPassVersion { + return fmt.Errorf("invalid username/password auth version: %d", header[0]) + } + + username := make([]byte, int(header[1])) + if _, err := io.ReadFull(conn, username); err != nil { + return err + } + + passLen := make([]byte, 1) + if _, err := io.ReadFull(conn, passLen); err != nil { + return err + } + + password := make([]byte, int(passLen[0])) + if _, err := io.ReadFull(conn, password); err != nil { + return err + } + + ok := string(username) == c.cfg.SOCKSUsername && string(password) == c.cfg.SOCKSPassword + session.UsernameUsed = string(username) + if ok { + _, err := conn.Write([]byte{socksUserPassVersion, socksAuthSuccess}) + return err + } + + _, _ = conn.Write([]byte{socksUserPassVersion, socksAuthFailure}) + return errors.New("invalid SOCKS username/password") +} + +func readConnectRequest(conn net.Conn) (string, uint16, byte, error) { + header := make([]byte, 4) + if _, err := io.ReadFull(conn, header); err != nil { + return "", 0, 0, err + } + + if header[0] != socksVersion5 { + return "", 0, 0, fmt.Errorf("invalid request version: %d", header[0]) + } + if header[1] != socksCmdConnect { + _ = writeSocksReply(conn, socksReplyCommandUnsupported) + return "", 0, 0, fmt.Errorf("unsupported SOCKS command: %d", header[1]) + } + if header[2] != 0x00 { + return "", 0, 0, errors.New("non-zero reserved byte in SOCKS request") + } + + atyp := header[3] + host, err := readTargetHost(conn, atyp) + if err != nil { + _ = writeSocksReply(conn, socksReplyAddressUnsupported) + return "", 0, 0, err + } + + portBytes := make([]byte, 2) + if _, err := io.ReadFull(conn, portBytes); err != nil { + return "", 0, 0, err + } + + return host, binary.BigEndian.Uint16(portBytes), atyp, nil +} + +func readTargetHost(conn net.Conn, atyp byte) (string, error) { + switch atyp { + case socksAtypIPv4: + ip := make([]byte, 4) + if _, err := io.ReadFull(conn, ip); err != nil { + return "", err + } + return net.IP(ip).String(), nil + case socksAtypIPv6: + ip := make([]byte, 16) + if _, err := io.ReadFull(conn, ip); err != nil { + return "", err + } + return net.IP(ip).String(), nil + case socksAtypDomain: + size := make([]byte, 1) + if _, err := io.ReadFull(conn, size); err != nil { + return "", err + } + domain := make([]byte, int(size[0])) + if _, err := io.ReadFull(conn, domain); err != nil { + return "", err + } + return string(domain), nil + default: + return "", fmt.Errorf("unsupported address type: %d", atyp) + } +} + +func writeSocksReply(conn net.Conn, reply byte) error { + resp := []byte{ + socksVersion5, + reply, + 0x00, + socksAtypIPv4, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, + } + _, err := conn.Write(resp) + return err +} + +func (c *Client) captureInitialPayload(ctx context.Context, conn net.Conn, session *Session) error { + peekTimeout := 2 * time.Second + idleTimeout := 30 * time.Second + buf := make([]byte, 32*1024) + + if err := conn.SetReadDeadline(time.Now().Add(peekTimeout)); err != nil { + return err + } + + n, err := conn.Read(buf) + if err == nil && n > 0 { + session.InitialPayload = append([]byte(nil), buf[:n]...) + session.BytesCaptured += n + session.LastActivityAt = time.Now() + c.log.Infof( + "session=%d captured initial payload bytes=%d target=%s", + session.ID, n, net.JoinHostPort(session.TargetHost, strconv.Itoa(int(session.TargetPort))), + ) + } else if ne, ok := err.(net.Error); !ok || !ne.Timeout() { + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + } + + for { + select { + case <-ctx.Done(): + return nil + default: + } + + if err := conn.SetReadDeadline(time.Now().Add(idleTimeout)); err != nil { + return err + } + + n, err := conn.Read(buf) + if n > 0 { + session.BytesCaptured += n + session.LastActivityAt = time.Now() + c.log.Debugf("session=%d buffered payload chunk=%d total=%d", session.ID, n, session.BytesCaptured) + } + + if err != nil { + if errors.Is(err, io.EOF) { + return nil + } + if ne, ok := err.(net.Error); ok && ne.Timeout() { + return nil + } + return err + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8b50203 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,95 @@ +// ============================================================================== +// MasterHttpRelayVPN +// Author: MasterkinG32 +// Github: https://github.com/masterking32 +// Year: 2026 +// ============================================================================== + +package config + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" +) + +type Config struct { + AESEncryptionKey string + SOCKSHost string + SOCKSPort int + SOCKSAuth bool + SOCKSUsername string + SOCKSPassword string +} + +func Load(path string) (Config, error) { + cfg := Config{ + SOCKSHost: "127.0.0.1", + SOCKSPort: 1080, + } + + file, err := os.Open(path) + if err != nil { + return Config{}, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + key, value, ok := strings.Cut(line, "=") + if !ok { + return Config{}, fmt.Errorf("invalid config line: %q", line) + } + + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + + switch key { + case "AES_ENCRYPTION_KEY": + cfg.AESEncryptionKey = trimString(value) + case "SOCKS_HOST": + cfg.SOCKSHost = trimString(value) + case "SOCKS_PORT": + port, err := strconv.Atoi(value) + if err != nil { + return Config{}, fmt.Errorf("parse SOCKS_PORT: %w", err) + } + cfg.SOCKSPort = port + case "SOCKS_AUTH": + auth, err := strconv.ParseBool(value) + if err != nil { + return Config{}, fmt.Errorf("parse SOCKS_AUTH: %w", err) + } + cfg.SOCKSAuth = auth + case "SOCKS_USERNAME": + cfg.SOCKSUsername = trimString(value) + case "SOCKS_PASSWORD": + cfg.SOCKSPassword = trimString(value) + } + } + + if err := scanner.Err(); err != nil { + return Config{}, err + } + + if cfg.SOCKSAuth && (cfg.SOCKSUsername == "" || cfg.SOCKSPassword == "") { + return Config{}, fmt.Errorf("SOCKS auth enabled but username/password missing") + } + + if cfg.SOCKSPort < 1 || cfg.SOCKSPort > 65535 { + return Config{}, fmt.Errorf("invalid SOCKS_PORT: %d", cfg.SOCKSPort) + } + + return cfg, nil +} + +func trimString(value string) string { + return strings.Trim(value, `"`) +} diff --git a/internal/logger/color_support_unix.go b/internal/logger/color_support_unix.go new file mode 100644 index 0000000..6c21bba --- /dev/null +++ b/internal/logger/color_support_unix.go @@ -0,0 +1,22 @@ +//go:build !windows + +package logger + +import ( + "io" + "os" +) + +func detectColorSupport(w io.Writer) bool { + f, ok := w.(*os.File) + if !ok { + return false + } + + info, err := f.Stat() + if err != nil { + return false + } + + return (info.Mode() & os.ModeCharDevice) != 0 +} diff --git a/internal/logger/color_support_windows.go b/internal/logger/color_support_windows.go new file mode 100644 index 0000000..079a219 --- /dev/null +++ b/internal/logger/color_support_windows.go @@ -0,0 +1,31 @@ +//go:build windows + +package logger + +import ( + "io" + "os" + + "golang.org/x/sys/windows" +) + +const enableVirtualTerminalProcessing = 0x0004 + +func detectColorSupport(w io.Writer) bool { + f, ok := w.(*os.File) + if !ok { + return false + } + + handle := windows.Handle(f.Fd()) + var mode uint32 + if err := windows.GetConsoleMode(handle, &mode); err != nil { + return false + } + + if mode&enableVirtualTerminalProcessing != 0 { + return true + } + + return windows.SetConsoleMode(handle, mode|enableVirtualTerminalProcessing) == nil +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..313369c --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,321 @@ +// ============================================================================== +// MasterHttpRelayVPN +// Author: MasterkinG32 +// Github: https://github.com/masterking32 +// Year: 2026 +// ============================================================================== + +package logger + +import ( + "fmt" + "io" + "os" + "strings" + "sync" + "time" +) + +type Logger struct { + name string + level int + mu sync.Mutex + consoleWriter io.Writer + fileWriter *os.File + color bool + appNameText string + appNameColored string +} + +const ( + levelDebug = iota + levelInfo + levelWarn + levelError +) + +const ( + LevelDebug = levelDebug + LevelInfo = levelInfo + LevelWarn = levelWarn + LevelError = levelError +) + +var colorTagCodes = map[string]string{ + "black": "\x1b[30m", + "red": "\x1b[31m", + "green": "\x1b[32m", + "yellow": "\x1b[33m", + "blue": "\x1b[34m", + "magenta": "\x1b[35m", + "cyan": "\x1b[36m", + "white": "\x1b[37m", + "gray": "\x1b[90m", + "grey": "\x1b[90m", + "bold": "\x1b[1m", + "reset": "\x1b[0m", +} + +var plainLevelTexts = [...]string{ + levelDebug: "[DEBUG]", + levelInfo: "[INFO]", + levelWarn: "[WARN]", + levelError: "[ERROR]", +} + +var coloredLevelTexts = [...]string{ + levelDebug: "\x1b[35m[DEBUG]\x1b[0m", + levelInfo: "\x1b[32m[INFO]\x1b[0m", + levelWarn: "\x1b[33m[WARN]\x1b[0m", + levelError: "\x1b[31m[ERROR]\x1b[0m", +} + +func New(name, rawLevel string) *Logger { + return NewWithFile(name, rawLevel, "") +} + +func NewWithFile(name, rawLevel, filePath string) *Logger { + appName := "[" + name + "]" + var consoleWriter io.Writer = os.Stdout + var fileWriter *os.File + + if filePath != "" { + f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err == nil { + fileWriter = f + } + } + + return &Logger{ + name: name, + level: parseLevel(rawLevel), + consoleWriter: consoleWriter, + fileWriter: fileWriter, + color: shouldUseColor(), + appNameText: appName, + appNameColored: "\x1b[36m" + appName + "\x1b[0m", + } +} + +func parseLevel(raw string) int { + switch strings.ToUpper(strings.TrimSpace(raw)) { + case "DEBUG": + return levelDebug + case "WARNING", "WARN": + return levelWarn + case "ERROR", "CRITICAL": + return levelError + default: + return levelInfo + } +} + +func (l *Logger) logf(level int, format string, args ...any) { + if l == nil || level < l.level { + return + } + + msg := format + if len(args) != 0 { + msg = fmt.Sprintf(format, args...) + } + + plainMsg := msg + if strings.IndexByte(msg, '<') >= 0 { + plainMsg = stripColorTags(msg) + } + + l.mu.Lock() + defer l.mu.Unlock() + + ts := time.Now().Format("2006/01/02 15:04:05") + + if l.fileWriter != nil { + fileLine := ts + " " + plainLevelTexts[level] + " " + plainMsg + "\n" + if _, err := io.WriteString(l.fileWriter, fileLine); err != nil { + _ = l.fileWriter.Close() + l.fileWriter = nil + } + } + + if l.consoleWriter != nil { + appName := l.appNameText + levelText := plainLevelTexts[level] + finalMsg := plainMsg + + if l.color { + if strings.IndexByte(msg, '<') >= 0 { + finalMsg = renderColorTags(msg) + } else { + finalMsg = msg + } + appName = l.appNameColored + levelText = coloredLevelTexts[level] + } + + consoleLine := ts + " " + appName + " " + levelText + " " + finalMsg + "\n" + _, _ = io.WriteString(l.consoleWriter, consoleLine) + } +} + +func (l *Logger) Debugf(format string, args ...any) { l.logf(levelDebug, format, args...) } +func (l *Logger) Infof(format string, args ...any) { l.logf(levelInfo, format, args...) } +func (l *Logger) Warnf(format string, args ...any) { l.logf(levelWarn, format, args...) } +func (l *Logger) Errorf(format string, args ...any) { l.logf(levelError, format, args...) } + +func (l *Logger) Fatalf(format string, args ...any) { + l.logf(levelError, format, args...) + os.Exit(1) +} + +func (l *Logger) Enabled(level int) bool { + return l != nil && level >= l.level +} + +func stripColorTags(text string) string { + start := strings.IndexByte(text, '<') + if start == -1 { + return text + } + + var b strings.Builder + b.Grow(len(text)) + + for i := 0; i < len(text); { + if text[i] != '<' { + next := strings.IndexByte(text[i:], '<') + if next == -1 { + b.WriteString(text[i:]) + break + } + b.WriteString(text[i : i+next]) + i += next + continue + } + + end := strings.IndexByte(text[i:], '>') + if end == -1 { + b.WriteString(text[i:]) + break + } + + rawTag := text[i : i+end+1] + tag := strings.ToLower(rawTag) + if _, _, ok := parseColorTag(tag); ok { + i += end + 1 + continue + } + + b.WriteString(rawTag) + i += end + 1 + } + + return b.String() +} + +func shouldUseColor() bool { + if strings.TrimSpace(os.Getenv("NO_COLOR")) != "" { + return false + } + if strings.TrimSpace(os.Getenv("FORCE_COLOR")) != "" { + return true + } + return detectColorSupport(os.Stdout) +} + +func renderColorTags(text string) string { + start := strings.IndexByte(text, '<') + if start == -1 { + return text + } + + var b strings.Builder + b.Grow(len(text) + 16) + b.WriteString(text[:start]) + stack := make([]string, 0, 4) + + for i := start; i < len(text); { + if text[i] != '<' { + next := strings.IndexByte(text[i:], '<') + if next == -1 { + b.WriteString(text[i:]) + break + } + b.WriteString(text[i : i+next]) + i += next + continue + } + + end := strings.IndexByte(text[i:], '>') + if end == -1 { + b.WriteString(text[i:]) + break + } + + rawTag := text[i : i+end+1] + tag := strings.ToLower(rawTag) + if name, closing, ok := parseColorTag(tag); ok { + if closing { + if name == "reset" { + stack = stack[:0] + b.WriteString("\x1b[0m") + } else if restoreColorTag(&stack, name) { + b.WriteString("\x1b[0m") + for _, active := range stack { + b.WriteString(colorTagCodes[active]) + } + } else { + b.WriteString(rawTag) + } + } else { + stack = append(stack, name) + b.WriteString(colorTagCodes[name]) + } + } else { + b.WriteString(rawTag) + } + i += end + 1 + } + + if len(stack) != 0 { + b.WriteString("\x1b[0m") + } + + return b.String() +} + +func parseColorTag(tag string) (name string, closing bool, ok bool) { + if len(tag) < 3 || tag[0] != '<' || tag[len(tag)-1] != '>' { + return "", false, false + } + closing = strings.HasPrefix(tag, "= 0; idx-- { + if items[idx] != name { + continue + } + copy(items[idx:], items[idx+1:]) + lastIdx := len(items) - 1 + items[lastIdx] = "" + *stack = items[:lastIdx] + return true + } + return false +} + +func NowUnixNano() int64 { + return time.Now().UnixNano() +} diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go new file mode 100644 index 0000000..935f8f9 --- /dev/null +++ b/internal/logger/logger_test.go @@ -0,0 +1,95 @@ +// ============================================================================== +// MasterHttpRelayVPN +// Author: MasterkinG32 +// Github: https://github.com/masterking32 +// Year: 2026 +// ============================================================================== + +package logger + +import ( + "bytes" + "os" + "strings" + "testing" +) + +func TestParseLevel(t *testing.T) { + tests := []struct { + raw string + want int + }{ + {raw: "debug", want: levelDebug}, + {raw: "INFO", want: levelInfo}, + {raw: "warn", want: levelWarn}, + {raw: "warning", want: levelWarn}, + {raw: "critical", want: levelError}, + {raw: "error", want: levelError}, + {raw: "unknown", want: levelInfo}, + } + + for _, tt := range tests { + if got := parseLevel(tt.raw); got != tt.want { + t.Fatalf("parseLevel(%q) = %d, want %d", tt.raw, got, tt.want) + } + } +} + +func TestRenderColorTags(t *testing.T) { + got := renderColorTags("ok test x") + if !strings.Contains(got, "\x1b[32m") { + t.Fatal("expected green ANSI code in rendered string") + } + if !strings.Contains(got, "\x1b[36m") { + t.Fatal("expected cyan ANSI code in rendered string") + } + if !strings.Contains(got, "x") { + t.Fatal("unknown tags should be preserved") + } +} + +func TestRenderColorTagsRestoresParentColor(t *testing.T) { + got := renderColorTags("Listener 127.0.0.1:5350 Ready") + want := "\x1b[32mListener \x1b[36m127.0.0.1:5350\x1b[0m\x1b[32m Ready\x1b[0m" + if got != want { + t.Fatalf("renderColorTags() = %q, want %q", got, want) + } +} + +func TestLoggerSuppressesBelowLevel(t *testing.T) { + var buf bytes.Buffer + l := &Logger{ + name: "test", + level: levelWarn, + consoleWriter: &buf, + color: false, + appNameText: "[test]", + } + + l.Infof("info message") + l.Warnf("warn message") + + output := buf.String() + if strings.Contains(output, "info message") { + t.Fatal("info message should be suppressed at WARN level") + } + if !strings.Contains(output, "warn message") { + t.Fatal("warn message should be logged at WARN level") + } +} + +func TestShouldUseColorHonorsNoColor(t *testing.T) { + oldNoColor := os.Getenv("NO_COLOR") + oldForceColor := os.Getenv("FORCE_COLOR") + t.Cleanup(func() { + _ = os.Setenv("NO_COLOR", oldNoColor) + _ = os.Setenv("FORCE_COLOR", oldForceColor) + }) + + _ = os.Setenv("FORCE_COLOR", "1") + _ = os.Setenv("NO_COLOR", "1") + + if shouldUseColor() { + t.Fatal("NO_COLOR should disable colors even when FORCE_COLOR is set") + } +}