package domain import ( "errors" "fmt" "net" "net/url" "path/filepath" "strconv" "strings" "time" ) type BrowserKind string const ( BrowserChrome BrowserKind = "chrome" BrowserEdge BrowserKind = "edge" DefaultRemoteDebugPort = 9666 MinRemoteDebugPort = 1024 MaxRemoteDebugPort = 65535 ) func (k BrowserKind) Valid() bool { return k == BrowserChrome || k == BrowserEdge } type InstanceStatus string const ( StatusCreated InstanceStatus = "created" StatusStarting InstanceStatus = "starting" StatusRunning InstanceStatus = "running" StatusStopping InstanceStatus = "stopping" StatusExited InstanceStatus = "exited" StatusFailed InstanceStatus = "failed" StatusUnknown InstanceStatus = "unknown" ) type LaunchSpec struct { Kind BrowserKind Executable string UserDataDir string ProfileDirectory string RemoteDebugPort int TargetURL string ProxyServer string Headless bool ExtraArgs []string } func (s LaunchSpec) Normalize() (LaunchSpec, error) { if !s.Kind.Valid() { return LaunchSpec{}, fmt.Errorf("%w: unsupported browser kind", ErrInvalidLaunchSpec) } userDataDir := strings.TrimSpace(s.UserDataDir) if userDataDir == "" || !filepath.IsAbs(userDataDir) { return LaunchSpec{}, fmt.Errorf("%w: user data dir must be absolute", ErrInvalidLaunchSpec) } if s.RemoteDebugPort != 0 && !ValidRemoteDebugPort(s.RemoteDebugPort) { return LaunchSpec{}, fmt.Errorf("%w: remote debugging port must be between %d and %d", ErrInvalidLaunchSpec, MinRemoteDebugPort, MaxRemoteDebugPort) } target := strings.TrimSpace(s.TargetURL) if target != "" { parsed, err := url.Parse(target) if err != nil || parsed.Host == "" || parsed.User != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { return LaunchSpec{}, fmt.Errorf("%w: target URL must be absolute http or https", ErrInvalidLaunchSpec) } target = parsed.String() } normalized := s normalized.Executable = strings.TrimSpace(s.Executable) normalized.UserDataDir = filepath.Clean(userDataDir) normalized.ProfileDirectory = strings.TrimSpace(s.ProfileDirectory) normalized.TargetURL = target proxy, err := NormalizeProxyServer(s.ProxyServer) if err != nil { return LaunchSpec{}, err } normalized.ProxyServer = proxy normalized.ExtraArgs = append([]string(nil), s.ExtraArgs...) return normalized, nil } // NormalizeProxyServer accepts only non-authenticated Chromium proxy endpoints. // Keeping this validation in domain makes configuration and process launch share // the same security boundary: no proxy userinfo can reach disk or command args. func NormalizeProxyServer(value string) (string, error) { proxy := strings.TrimSpace(value) if proxy == "" { return "", nil } parsed, err := url.Parse(proxy) if err != nil || parsed.User != nil || parsed.Hostname() == "" || parsed.Port() == "" || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" { return "", fmt.Errorf("%w: proxy must be scheme://host:port without credentials", ErrInvalidLaunchSpec) } scheme := strings.ToLower(parsed.Scheme) if scheme != "http" && scheme != "https" && scheme != "socks4" && scheme != "socks5" { return "", fmt.Errorf("%w: unsupported proxy scheme", ErrInvalidLaunchSpec) } port, err := strconv.Atoi(parsed.Port()) if err != nil || port < 1 || port > 65535 { return "", fmt.Errorf("%w: proxy port out of range", ErrInvalidLaunchSpec) } return scheme + "://" + net.JoinHostPort(strings.ToLower(parsed.Hostname()), strconv.Itoa(port)), nil } func ValidRemoteDebugPort(port int) bool { return port >= MinRemoteDebugPort && port <= MaxRemoteDebugPort } type InstanceView struct { ID string Kind BrowserKind Executable string PID int RemoteDebugPort int UserDataDir string Profile string TargetURL string Status InstanceStatus ExitCode *int StartedAt time.Time ChangedAt time.Time IdentityNote string } type EventKind string const ( EventBrowserStarted EventKind = "browser.started" EventBrowserStatusChanged EventKind = "browser.status.changed" EventBrowserExited EventKind = "browser.exited" ) type BrowserEvent struct { Kind EventKind InstanceID string Status InstanceStatus PID int ExitCode *int ErrorCode ErrorCode At time.Time } type ErrorCode string const ( ErrorInvalidLaunchSpec ErrorCode = "invalid_launch_spec" ErrorProfileOccupied ErrorCode = "profile_occupied" ErrorExecutableNotFound ErrorCode = "executable_not_found" ErrorInstanceNotFound ErrorCode = "instance_not_found" ErrorIdentityMismatch ErrorCode = "identity_mismatch" ErrorProcessPermission ErrorCode = "process_permission" ErrorProcessStart ErrorCode = "process_start_failed" ) var ( ErrInvalidLaunchSpec = errors.New("invalid browser launch specification") ErrProfileOccupied = errors.New("browser user data dir is occupied") ErrInstanceNotFound = errors.New("browser instance was not found") ErrIdentityMismatch = errors.New("browser process identity mismatch") )