Răsfoiți Sursa

generate env files from Config struct tags

Mike 2 ani în urmă
părinte
comite
a1012eefad

+ 1 - 1
.gitignore

@@ -1,4 +1,4 @@
 .idea/
 *.db
-
+*.sqlite
 dist/

+ 9 - 7
.goreleaser.yaml

@@ -5,7 +5,7 @@ before:
     - go mod tidy
 
 builds:
-  - id: retro_aim_server
+  - binary: retro_aim_server
     goos:
       - darwin
       - linux
@@ -13,7 +13,7 @@ builds:
     goarch:
       - amd64
       - arm64
-    main: ./cmd
+    main: ./cmd/server
     ignore:
       - goos: linux
         goarch: arm64
@@ -34,15 +34,17 @@ archives:
     format_overrides:
       - goos: windows
         format: zip
+    wrap_in_directory: true
     files:
       - LICENSE
-      - src: files/package/settings.env
+      - src: config/settings.{{- if eq .Os "windows" }}bat{{ else }}env{{ end }}
         strip_parent: true
-      - src: files/package/run.{{- if eq .Os "windows" }}bat{{ else }}sh{{ end }}
+      - src: scripts/run.{{- if eq .Os "windows" }}cmd{{ else }}sh{{ end }}
         strip_parent: true
     name_template: >-
-      {{ .ProjectName }}.{{ .Version }}.
-      {{- if eq .Os "darwin"}}mac
+      {{ .Binary }}.{{ .Version }}.
+      {{- if eq .Os "darwin"}}macos
       {{- else }}{{ .Os }}{{ end }}.
-      {{- if eq .Arch "amd64" }}x86_64
+      {{- if eq .Os "darwin"}}{{- if eq .Arch "amd64" }}intel_x86_64{{ else }}apple_silicon{{ end }}
+      {{- else if eq .Arch "amd64" }}x86_64
       {{- else }}{{ .Arch }}{{ end }}

+ 1 - 1
README.md

@@ -37,7 +37,7 @@ Server configuration is set through environment variables. The following are the
 DISABLE_AUTH=true \
 OSCAR_HOST=192.168.64.1 \
 DB_PATH=./aim.db \
-go run ./cmd/main.go
+go run ./cmd/server/main.go
 ```
 
 ### User Management

+ 83 - 0
cmd/config_generator/main.go

@@ -0,0 +1,83 @@
+// This program generates env config scripts from config.Config struct tags for
+// unix and windows platforms.
+// Usage: go run ./cmd/config_generator [platform] [filename]
+// Example: go run ./cmd/config_generator windows settings.bat
+package main
+
+import (
+	"fmt"
+	"io"
+	"os"
+	"reflect"
+	"strings"
+
+	"github.com/mitchellh/go-wordwrap"
+	"github.com/mk6i/retro-aim-server/config"
+)
+
+var platformKeywords = map[string]struct {
+	comment    string
+	assignment string
+}{
+	"windows": {
+		comment:    "rem ",
+		assignment: "set ",
+	},
+	"unix": {
+		comment:    "# ",
+		assignment: "export ",
+	},
+}
+
+func main() {
+	args := os.Args[1:]
+	if len(args) < 2 {
+		fmt.Fprintln(os.Stderr, "usage: go run cmd/config_generator [platform] [filename]")
+		os.Exit(1)
+	}
+
+	keywords, ok := platformKeywords[args[0]]
+	if !ok {
+		fmt.Fprintf(os.Stderr, "unable to find platform `%s`\n", os.Args[1])
+		os.Exit(1)
+	}
+	fmt.Println("writing to", args[1])
+	f, err := os.Create(args[1])
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "error creating file: %s\n", err.Error())
+		os.Exit(1)
+	}
+	defer f.Close()
+
+	configType := reflect.TypeOf(config.Config{})
+	for i := 0; i < configType.NumField(); i++ {
+		field := configType.Field(i)
+		comment := field.Tag.Get("description")
+		if err := writeComment(f, comment, 80, keywords.comment); err != nil {
+			fmt.Fprintf(os.Stderr, "error writing to file: %s\n", err.Error())
+			os.Exit(1)
+		}
+
+		varName := field.Tag.Get("envconfig")
+		val := field.Tag.Get("default")
+		if err := writeAssignment(f, keywords.assignment, varName, val); err != nil {
+			fmt.Fprintf(os.Stderr, "error writing to file: %s\n", err.Error())
+			os.Exit(1)
+		}
+	}
+}
+
+func writeComment(w io.Writer, comment string, width uint, keyword string) error {
+	// adjust wrapping threshold to accommodate comment keyword length
+	width = width - uint(len(keyword))
+	comment = wordwrap.WrapString(comment, width)
+	// prepend lines with comment keyword
+	comment = strings.ReplaceAll(comment, "\n", fmt.Sprintf("\n%s", keyword))
+	_, err := fmt.Fprintf(w, "%s%s\n", keyword, comment)
+	return err
+}
+
+func writeAssignment(w io.Writer, keyword string, varName string, val string) error {
+	_, err := fmt.Fprintf(w, "%s%s=%s\n\n", keyword, varName, val)
+	return err
+}

+ 2 - 1
cmd/main.go → cmd/server/main.go

@@ -6,6 +6,7 @@ import (
 	"os"
 	"sync"
 
+	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/handler"
 	"github.com/mk6i/retro-aim-server/state"
 
@@ -14,7 +15,7 @@ import (
 )
 
 func main() {
-	var cfg server.Config
+	var cfg config.Config
 	err := envconfig.Process("", &cfg)
 	if err != nil {
 		_, _ = fmt.Fprintf(os.Stderr, "unable to process app config: %s", err.Error())

+ 20 - 0
config/config.go

@@ -0,0 +1,20 @@
+package config
+
+import "fmt"
+
+//go:generate go run github.com/mk6i/retro-aim-server/cmd/config_generator windows settings.bat
+//go:generate go run github.com/mk6i/retro-aim-server/cmd/config_generator unix settings.env
+type Config struct {
+	BOSPort     int    `envconfig:"BOS_PORT" default:"5191" description:"The port that the BOS service binds to."`
+	BUCPPort    int    `envconfig:"BUCP_PORT" default:"5190" description:"The port that the auth service binds to."`
+	ChatPort    int    `envconfig:"CHAT_PORT" default:"5192" description:"The port that the chat service binds to."`
+	DBPath      string `envconfig:"DB_PATH" default:"oscar.sqlite" description:"The path to the SQLite database file. The file and DB schema are auto-created if they doesn't exist."`
+	DisableAuth bool   `envconfig:"DISABLE_AUTH" default:"true" description:"Disable password check and auto-create new users at login time. Useful for quickly creating new accounts during development without having to register new users via the management API."`
+	FailFast    bool   `envconfig:"FAIL_FAST" default:"true" description:"Crash the server in case it encounters a client message type it doesn't recognize. This makes failures obvious for debugging purposes."`
+	LogLevel    string `envconfig:"LOG_LEVEL" default:"info" description:"Set logging granularity. Possible values: 'trace', 'debug', 'info', 'warn', 'error'."`
+	OSCARHost   string `envconfig:"OSCAR_HOST" default:"127.0.0.1" description:"The hostname that AIM clients connect to in order to reach OSCAR services (BOS, BUCP, chat, etc). Make sure the hostname is reachable by all clients. For local development, the default loopback address should work provided the server and AIM client(s) are running on the same machine. For LAN-only clients, a private IP address (e.g. 192.168..) or hostname should suffice. For clients connecting over the Internet, specify your public IP address and ensure that TCP ports 5190-5192 are open on your firewall."`
+}
+
+func Address(host string, port int) string {
+	return fmt.Sprintf("%s:%d", host, port)
+}

+ 35 - 0
config/settings.bat

@@ -0,0 +1,35 @@
+rem The port that the BOS service binds to.
+set BOS_PORT=5191
+
+rem The port that the auth service binds to.
+set BUCP_PORT=5190
+
+rem The port that the chat service binds to.
+set CHAT_PORT=5192
+
+rem The path to the SQLite database file. The file and DB schema are
+rem auto-created if they doesn't exist.
+set DB_PATH=oscar.sqlite
+
+rem Disable password check and auto-create new users at login time. Useful for
+rem quickly creating new accounts during development without having to register
+rem new users via the management API.
+set DISABLE_AUTH=true
+
+rem Crash the server in case it encounters a client message type it doesn't
+rem recognize. This makes failures obvious for debugging purposes.
+set FAIL_FAST=true
+
+rem Set logging granularity. Possible values: 'trace', 'debug', 'info', 'warn',
+rem 'error'.
+set LOG_LEVEL=info
+
+rem The hostname that AIM clients connect to in order to reach OSCAR services
+rem (BOS, BUCP, chat, etc). Make sure the hostname is reachable by all clients.
+rem For local development, the default loopback address should work provided the
+rem server and AIM client(s) are running on the same machine. For LAN-only
+rem clients, a private IP address (e.g. 192.168..) or hostname should suffice.
+rem For clients connecting over the Internet, specify your public IP address and
+rem ensure that TCP ports 5190-5192 are open on your firewall.
+set OSCAR_HOST=127.0.0.1
+

+ 18 - 11
files/package/settings.env → config/settings.env

@@ -1,28 +1,35 @@
 # The port that the BOS service binds to.
 export BOS_PORT=5191
+
+# The port that the auth service binds to.
+export BUCP_PORT=5190
+
 # The port that the chat service binds to.
 export CHAT_PORT=5192
+
 # The path to the SQLite database file. The file and DB schema are auto-created
 # if they doesn't exist.
 export DB_PATH=oscar.sqlite
+
 # Disable password check and auto-create new users at login time. Useful for
 # quickly creating new accounts during development without having to register
 # new users via the management API.
 export DISABLE_AUTH=true
+
 # Crash the server in case it encounters a client message type it doesn't
 # recognize. This makes failures obvious for debugging purposes.
 export FAIL_FAST=true
-# Set logging granularity. Possible values: `trace`, `debug`, `info`, `warn`,
-# `error`.
-export LOG_LEVEL=trace
+
+# Set logging granularity. Possible values: 'trace', 'debug', 'info', 'warn',
+# 'error'.
+export LOG_LEVEL=info
+
 # The hostname that AIM clients connect to in order to reach OSCAR services
 # (BOS, BUCP, chat, etc). Make sure the hostname is reachable by all clients.
-#  - For local development, the default loopback address should work provided
-#    the server and AIM client(s) are running on the same machine.
-#  - For LAN-only clients, a private IP address (e.g. 192.168..) or hostname
-#    should suffice.
-#  - For clients connecting over the Internet, specify your public IP address
-#    and ensure that TCP ports 5190-5192 are open on your firewall.
+# For local development, the default loopback address should work provided the
+# server and AIM client(s) are running on the same machine. For LAN-only
+# clients, a private IP address (e.g. 192.168..) or hostname should suffice. For
+# clients connecting over the Internet, specify your public IP address and
+# ensure that TCP ports 5190-5192 are open on your firewall.
 export OSCAR_HOST=127.0.0.1
-# The port that the auth service binds to.
-export BUCP_PORT=5190
+

+ 0 - 27
files/package/run.bat

@@ -1,27 +0,0 @@
-@echo off
-REM This script launches Retro AIM Server. By default, it assumes that the
-REM executable and configuration file are located in the same directory as this
-REM script.
-
-setlocal enabledelayedexpansion
-
-REM Get the directory of the script
-for %%I in (%0) do set "SCRIPT_DIR=%%~dpI"
-set "ENV_FILE=!SCRIPT_DIR!\settings.env"
-set "EXEC_FILE=!SCRIPT_DIR!\retro-aim-server.exe"
-
-REM Load the settings file
-if exist !ENV_FILE! (
-    call "!ENV_FILE!"
-) else (
-    echo error: environment file '!ENV_FILE!' not found.
-    exit /b 1
-)
-
-REM Start Retro AIM Server
-if exist !EXEC_FILE! (
-    call "!EXEC_FILE!"
-) else (
-    echo error: executable '!EXEC_FILE!' not found.
-    exit /b 1
-)

+ 1 - 0
go.mod

@@ -6,6 +6,7 @@ require (
 	github.com/google/uuid v1.5.0
 	github.com/kelseyhightower/envconfig v1.4.0
 	github.com/mattn/go-sqlite3 v1.14.19
+	github.com/mitchellh/go-wordwrap v1.0.1
 	github.com/stretchr/testify v1.8.4
 )
 

+ 2 - 0
go.sum

@@ -7,6 +7,8 @@ github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dv
 github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
 github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI=
 github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
+github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
+github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=

+ 4 - 4
handler/auth.go

@@ -6,13 +6,13 @@ import (
 	"errors"
 
 	"github.com/google/uuid"
+	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/mk6i/retro-aim-server/server"
 	"github.com/mk6i/retro-aim-server/state"
 )
 
 // NewAuthService creates a new instance of AuthService.
-func NewAuthService(cfg server.Config, sessionManager SessionManager, messageRelayer MessageRelayer, feedbagManager FeedbagManager, userManager UserManager, chatRegistry ChatRegistry) *AuthService {
+func NewAuthService(cfg config.Config, sessionManager SessionManager, messageRelayer MessageRelayer, feedbagManager FeedbagManager, userManager UserManager, chatRegistry ChatRegistry) *AuthService {
 	return &AuthService{
 		chatRegistry:   chatRegistry,
 		config:         cfg,
@@ -26,7 +26,7 @@ func NewAuthService(cfg server.Config, sessionManager SessionManager, messageRel
 // AuthService provides user BUCP login and session management services.
 type AuthService struct {
 	chatRegistry   ChatRegistry
-	config         server.Config
+	config         config.Config
 	feedbagManager FeedbagManager
 	messageRelayer MessageRelayer
 	sessionManager SessionManager
@@ -171,7 +171,7 @@ func (s AuthService) BUCPLoginRequestHandler(bodyIn oscar.SNAC_0x17_0x02_BUCPLog
 	if loginOK {
 		sess := s.sessionManager.AddSession(newUUIDFn().String(), screenName)
 		snacPayloadOut.AppendList([]oscar.TLV{
-			oscar.NewTLV(oscar.TLVReconnectHere, server.Address(s.config.OSCARHost, s.config.BOSPort)),
+			oscar.NewTLV(oscar.TLVReconnectHere, config.Address(s.config.OSCARHost, s.config.BOSPort)),
 			oscar.NewTLV(oscar.TLVAuthorizationCookie, sess.ID()),
 		})
 	} else {

+ 19 - 19
handler/auth_test.go

@@ -5,8 +5,8 @@ import (
 	"testing"
 
 	"github.com/google/uuid"
+	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/mk6i/retro-aim-server/server"
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/mock"
@@ -25,7 +25,7 @@ func TestAuthService_BUCPLoginRequestHandler(t *testing.T) {
 		// name is the unit test name
 		name string
 		// cfg is the app configuration
-		cfg server.Config
+		cfg config.Config
 		// inputSNAC is the SNAC sent from the client to the server
 		inputSNAC oscar.SNAC_0x17_0x02_BUCPLoginRequest
 		// mockParams is the list of params sent to mocks that satisfy this
@@ -40,7 +40,7 @@ func TestAuthService_BUCPLoginRequestHandler(t *testing.T) {
 	}{
 		{
 			name: "user provides valid credentials and logs in successfully",
-			cfg: server.Config{
+			cfg: config.Config{
 				OSCARHost: "127.0.0.1",
 				BOSPort:   1234,
 			},
@@ -89,7 +89,7 @@ func TestAuthService_BUCPLoginRequestHandler(t *testing.T) {
 		},
 		{
 			name: "user logs in with non-existent screen name--account is created and logged in successfully",
-			cfg: server.Config{
+			cfg: config.Config{
 				OSCARHost:   "127.0.0.1",
 				BOSPort:     1234,
 				DisableAuth: true,
@@ -147,7 +147,7 @@ func TestAuthService_BUCPLoginRequestHandler(t *testing.T) {
 		},
 		{
 			name: "user logs in with invalid password--account is created and logged in successfully",
-			cfg: server.Config{
+			cfg: config.Config{
 				OSCARHost:   "127.0.0.1",
 				BOSPort:     1234,
 				DisableAuth: true,
@@ -205,7 +205,7 @@ func TestAuthService_BUCPLoginRequestHandler(t *testing.T) {
 		},
 		{
 			name: "user provides invalid password--account creation fails due to user creation runtime error",
-			cfg: server.Config{
+			cfg: config.Config{
 				DisableAuth: true,
 			},
 			inputSNAC: oscar.SNAC_0x17_0x02_BUCPLoginRequest{
@@ -233,7 +233,7 @@ func TestAuthService_BUCPLoginRequestHandler(t *testing.T) {
 		},
 		{
 			name: "user provides invalid password--account creation fails due to user upsert runtime error",
-			cfg: server.Config{
+			cfg: config.Config{
 				DisableAuth: true,
 			},
 			inputSNAC: oscar.SNAC_0x17_0x02_BUCPLoginRequest{
@@ -267,7 +267,7 @@ func TestAuthService_BUCPLoginRequestHandler(t *testing.T) {
 		},
 		{
 			name: "user provides invalid password and receives invalid login response",
-			cfg: server.Config{
+			cfg: config.Config{
 				OSCARHost: "127.0.0.1",
 				BOSPort:   1234,
 			},
@@ -368,7 +368,7 @@ func TestAuthService_BUCPChallengeRequestHandler(t *testing.T) {
 		// name is the unit test name
 		name string
 		// cfg is the app configuration
-		cfg server.Config
+		cfg config.Config
 		// inputSNAC is the SNAC sent from the client to the server
 		inputSNAC oscar.SNAC_0x17_0x06_BUCPChallengeRequest
 		// mockParams is the list of params sent to mocks that satisfy this
@@ -381,7 +381,7 @@ func TestAuthService_BUCPChallengeRequestHandler(t *testing.T) {
 	}{
 		{
 			name: "login with valid username, expect OK login response",
-			cfg: server.Config{
+			cfg: config.Config{
 				OSCARHost: "127.0.0.1",
 				BOSPort:   1234,
 			},
@@ -417,7 +417,7 @@ func TestAuthService_BUCPChallengeRequestHandler(t *testing.T) {
 		},
 		{
 			name: "login with invalid username, expect OK login response (Cfg.DisableAuth=true)",
-			cfg: server.Config{
+			cfg: config.Config{
 				OSCARHost:   "127.0.0.1",
 				BOSPort:     1234,
 				DisableAuth: true,
@@ -451,7 +451,7 @@ func TestAuthService_BUCPChallengeRequestHandler(t *testing.T) {
 		},
 		{
 			name: "login with invalid username, expect failed login response (Cfg.DisableAuth=false)",
-			cfg: server.Config{
+			cfg: config.Config{
 				OSCARHost: "127.0.0.1",
 				BOSPort:   1234,
 			},
@@ -545,7 +545,7 @@ func TestAuthService_RetrieveChatSession_HappyPath(t *testing.T) {
 		Retrieve(chatID).
 		Return(state.ChatRoom{}, sessionManager, nil)
 
-	svc := NewAuthService(server.Config{}, nil, nil, nil, nil, chatRegistry)
+	svc := NewAuthService(config.Config{}, nil, nil, nil, nil, chatRegistry)
 
 	have, err := svc.RetrieveChatSession(chatID, sess.ID())
 	assert.NoError(t, err)
@@ -561,7 +561,7 @@ func TestAuthService_RetrieveChatSession_ChatNotFound(t *testing.T) {
 		Retrieve(chatID).
 		Return(state.ChatRoom{}, nil, state.ErrChatRoomNotFound)
 
-	svc := NewAuthService(server.Config{}, nil, nil, nil, nil, chatRegistry)
+	svc := NewAuthService(config.Config{}, nil, nil, nil, nil, chatRegistry)
 
 	_, err := svc.RetrieveChatSession(chatID, sess.ID())
 	assert.ErrorIs(t, err, state.ErrChatRoomNotFound)
@@ -581,7 +581,7 @@ func TestAuthService_RetrieveChatSession_SessionNotFound(t *testing.T) {
 		Retrieve(chatID).
 		Return(state.ChatRoom{}, sessionManager, nil)
 
-	svc := NewAuthService(server.Config{}, nil, nil, nil, nil, chatRegistry)
+	svc := NewAuthService(config.Config{}, nil, nil, nil, nil, chatRegistry)
 
 	have, err := svc.RetrieveChatSession(chatID, sess.ID())
 	assert.NoError(t, err)
@@ -596,7 +596,7 @@ func TestAuthService_RetrieveBOSSession_HappyPath(t *testing.T) {
 		RetrieveSession(sess.ID()).
 		Return(sess)
 
-	svc := NewAuthService(server.Config{}, sessionManager, nil, nil, nil, nil)
+	svc := NewAuthService(config.Config{}, sessionManager, nil, nil, nil, nil)
 
 	have, err := svc.RetrieveBOSSession(sess.ID())
 	assert.NoError(t, err)
@@ -611,7 +611,7 @@ func TestAuthService_RetrieveBOSSession_SessionNotFound(t *testing.T) {
 		RetrieveSession(sess.ID()).
 		Return(nil)
 
-	svc := NewAuthService(server.Config{}, sessionManager, nil, nil, nil, nil)
+	svc := NewAuthService(config.Config{}, sessionManager, nil, nil, nil, nil)
 
 	have, err := svc.RetrieveBOSSession(sess.ID())
 	assert.NoError(t, err)
@@ -751,7 +751,7 @@ func TestAuthService_SignoutChat(t *testing.T) {
 				Retrieve(tt.chatRoom.Cookie).
 				Return(tt.chatRoom, chatSessionManager, tt.wantErr)
 
-			svc := NewAuthService(server.Config{}, nil, nil, nil, nil, chatRegistry)
+			svc := NewAuthService(config.Config{}, nil, nil, nil, nil, chatRegistry)
 
 			err := svc.SignoutChat(nil, tt.userSession, tt.chatRoom.Cookie)
 			assert.ErrorIs(t, err, tt.wantErr)
@@ -856,7 +856,7 @@ func TestAuthService_Signout(t *testing.T) {
 				sessionManager.EXPECT().RemoveSession(params.sess)
 			}
 
-			svc := NewAuthService(server.Config{}, sessionManager, messageRelayer, feedbagManager, nil, nil)
+			svc := NewAuthService(config.Config{}, sessionManager, messageRelayer, feedbagManager, nil, nil)
 
 			err := svc.Signout(nil, tt.userSession)
 			assert.ErrorIs(t, err, tt.wantErr)

+ 4 - 3
handler/oservice.go

@@ -7,19 +7,20 @@ import (
 	"fmt"
 	"time"
 
+	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/oscar"
 	"github.com/mk6i/retro-aim-server/server"
 	"github.com/mk6i/retro-aim-server/state"
 )
 
 // NewOServiceService creates a new instance of OServiceService.
-func NewOServiceService(cfg server.Config, messageRelayer MessageRelayer, feedbagManager FeedbagManager) *OServiceService {
+func NewOServiceService(cfg config.Config, messageRelayer MessageRelayer, feedbagManager FeedbagManager) *OServiceService {
 	return &OServiceService{cfg: cfg, messageRelayer: messageRelayer, feedbagManager: feedbagManager}
 }
 
 // OServiceService provides handlers for the OService food group.
 type OServiceService struct {
-	cfg            server.Config
+	cfg            config.Config
 	feedbagManager FeedbagManager
 	messageRelayer MessageRelayer
 }
@@ -366,7 +367,7 @@ func (s OServiceServiceForBOS) ServiceRequestHandler(_ context.Context, sess *st
 		Body: oscar.SNAC_0x01_0x05_OServiceServiceResponse{
 			TLVRestBlock: oscar.TLVRestBlock{
 				TLVList: oscar.TLVList{
-					oscar.NewTLV(oscar.OServiceTLVTagsReconnectHere, server.Address(s.cfg.OSCARHost, s.cfg.ChatPort)),
+					oscar.NewTLV(oscar.OServiceTLVTagsReconnectHere, config.Address(s.cfg.OSCARHost, s.cfg.ChatPort)),
 					oscar.NewTLV(oscar.OServiceTLVTagsLoginCookie, server.ChatCookie{
 						Cookie: []byte(room.Cookie),
 						SessID: sess.ID(),

+ 11 - 10
handler/oservice_test.go

@@ -4,6 +4,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/mk6i/retro-aim-server/config"
 	"github.com/stretchr/testify/mock"
 
 	"github.com/mk6i/retro-aim-server/oscar"
@@ -17,7 +18,7 @@ func TestReceiveAndSendServiceRequest(t *testing.T) {
 		// name is the unit test name
 		name string
 		// config is the application config
-		cfg server.Config
+		cfg config.Config
 		// chatRoom is the chat room the user connects to
 		chatRoom *state.ChatRoom
 		// userSession is the session of the user requesting the chat service
@@ -46,7 +47,7 @@ func TestReceiveAndSendServiceRequest(t *testing.T) {
 		},
 		{
 			name: "request info for connecting to chat room, return chat service and chat room metadata",
-			cfg: server.Config{
+			cfg: config.Config{
 				OSCARHost: "127.0.0.1",
 				ChatPort:  1234,
 			},
@@ -100,7 +101,7 @@ func TestReceiveAndSendServiceRequest(t *testing.T) {
 		},
 		{
 			name: "request info for connecting to non-existent chat room, return SNAC error",
-			cfg: server.Config{
+			cfg: config.Config{
 				OSCARHost: "127.0.0.1",
 				ChatPort:  1234,
 			},
@@ -300,7 +301,7 @@ func TestSetUserInfoFieldsHandler(t *testing.T) {
 			//
 			// send input SNAC
 			//
-			svc := NewOServiceService(server.Config{}, messageRelayer, feedbagManager)
+			svc := NewOServiceService(config.Config{}, messageRelayer, feedbagManager)
 			outputSNAC, err := svc.SetUserInfoFieldsHandler(nil, tc.userSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(oscar.SNAC_0x01_0x1E_OServiceSetUserInfoFields))
 			assert.ErrorIs(t, err, tc.expectErr)
@@ -316,7 +317,7 @@ func TestSetUserInfoFieldsHandler(t *testing.T) {
 }
 
 func TestOServiceService_RateParamsQueryHandler(t *testing.T) {
-	svc := NewOServiceService(server.Config{}, nil, nil)
+	svc := NewOServiceService(config.Config{}, nil, nil)
 
 	have := svc.RateParamsQueryHandler(nil, oscar.SNACFrame{RequestID: 1234})
 	want := oscar.SNACMessage{
@@ -510,7 +511,7 @@ func TestOServiceService_RateParamsQueryHandler(t *testing.T) {
 }
 
 func TestOServiceServiceForBOS_WriteOServiceHostOnline(t *testing.T) {
-	svc := NewOServiceServiceForBOS(*NewOServiceService(server.Config{}, nil, nil), nil)
+	svc := NewOServiceServiceForBOS(*NewOServiceService(config.Config{}, nil, nil), nil)
 
 	want := oscar.SNACMessage{
 		Frame: oscar.SNACFrame{
@@ -535,7 +536,7 @@ func TestOServiceServiceForBOS_WriteOServiceHostOnline(t *testing.T) {
 }
 
 func TestOServiceServiceForChat_WriteOServiceHostOnline(t *testing.T) {
-	svc := NewOServiceServiceForChat(*NewOServiceService(server.Config{}, nil, nil), nil)
+	svc := NewOServiceServiceForChat(*NewOServiceService(config.Config{}, nil, nil), nil)
 
 	want := oscar.SNACMessage{
 		Frame: oscar.SNACFrame{
@@ -555,7 +556,7 @@ func TestOServiceServiceForChat_WriteOServiceHostOnline(t *testing.T) {
 }
 
 func TestOServiceService_ClientVersionsHandler(t *testing.T) {
-	svc := NewOServiceService(server.Config{}, nil, nil)
+	svc := NewOServiceService(config.Config{}, nil, nil)
 
 	want := oscar.SNACMessage{
 		Frame: oscar.SNACFrame{
@@ -578,7 +579,7 @@ func TestOServiceService_ClientVersionsHandler(t *testing.T) {
 }
 
 func TestOServiceService_UserInfoQueryHandler(t *testing.T) {
-	svc := NewOServiceService(server.Config{}, nil, nil)
+	svc := NewOServiceService(config.Config{}, nil, nil)
 	sess := newTestSession("test-user")
 
 	want := oscar.SNACMessage{
@@ -659,7 +660,7 @@ func TestOServiceService_IdleNotificationHandler(t *testing.T) {
 				RelayToScreenNames(mock.Anything, tt.recipientBuddies, tt.broadcastMessage).
 				Maybe()
 
-			svc := NewOServiceService(server.Config{}, messageRelayer, feedbagManager)
+			svc := NewOServiceService(config.Config{}, messageRelayer, feedbagManager)
 
 			haveErr := svc.IdleNotificationHandler(nil, tt.sess, tt.bodyIn)
 			assert.ErrorIs(t, tt.wantErr, haveErr)

+ 27 - 0
scripts/run.cmd

@@ -0,0 +1,27 @@
+@echo off
+setlocal enabledelayedexpansion
+
+rem This script launches Retro AIM Server under Windows. Because it assumes
+rem that the executable and settings.bat file are located in the same directory
+rem as this script, the script can be run from any directory.
+
+set SCRIPT_DIR=%~dp0
+set ENV_FILE=%SCRIPT_DIR%settings.bat
+set EXEC_FILE=%SCRIPT_DIR%retro_aim_server.exe
+
+rem Load the settings file.
+if exist "%ENV_FILE%" (
+    call "%ENV_FILE%"
+) else (
+    echo error: environment file '%ENV_FILE%' not found.
+    exit /b 1
+)
+
+rem Start Retro AIM Server.
+if exist "%EXEC_FILE%" (
+    echo starting...
+    start /b "" "%EXEC_FILE%"
+) else (
+    echo error: executable '%EXEC_FILE%' not found.
+    exit /b 1
+)

+ 4 - 4
files/package/run.sh → scripts/run.sh

@@ -1,12 +1,12 @@
 #!/bin/sh
-# This script launches Retro AIM Server. By default, it assumes that the
-# executable and configuration file are located in the same directory as this
-# script.
+# This script launches Retro AIM Server under MacOS/Linux. Because it assumes
+# that the executable and settings.env file are located in the same directory
+# as this script, the script can be run from any directory.
 set -e
 
 SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
 ENV_FILE="$SCRIPT_DIR/settings.env"
-EXEC_FILE="$SCRIPT_DIR/retro-aim-server"
+EXEC_FILE="$SCRIPT_DIR/retro_aim_server"
 
 # Load the settings file.
 if [ -f "$ENV_FILE" ]; then

+ 22 - 0
scripts/run_dev.sh

@@ -0,0 +1,22 @@
+#!/bin/sh
+# This script launches Retro AIM Server using go run with the environment vars
+# defined in config/settings.env under MacOS/Linux. The script can be run from
+# any working directory--it assumes the location of config/command files
+# relative to the path of this script.
+set -e
+
+SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+ENV_FILE="$SCRIPT_DIR/../config/settings.env"
+REPO_ROOT="$SCRIPT_DIR/.."
+
+# Load the settings file.
+if [ -f "$ENV_FILE" ]; then
+    . "$ENV_FILE"
+else
+    echo "error: environment file '$ENV_FILE' not found."
+    exit 1
+fi
+
+# Run Retro AIM Server from repo root.
+cd "$REPO_ROOT"
+go run ./cmd/server

+ 2 - 1
server/alert_test.go

@@ -5,6 +5,7 @@ import (
 	"context"
 	"testing"
 
+	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/oscar"
 	"github.com/stretchr/testify/assert"
 )
@@ -60,7 +61,7 @@ func TestAlertRouter_RouteAlert(t *testing.T) {
 
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
-			router := NewAlertRouter(NewLogger(Config{}))
+			router := NewAlertRouter(NewLogger(config.Config{}))
 
 			bufIn := &bytes.Buffer{}
 			assert.NoError(t, oscar.Marshal(tc.input.Body, bufIn))

+ 2 - 1
server/bos_router.go

@@ -5,6 +5,7 @@ import (
 	"errors"
 	"io"
 
+	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/oscar"
 	"github.com/mk6i/retro-aim-server/state"
 )
@@ -14,7 +15,7 @@ type BOSRootRouter struct {
 	AlertRouter
 	BuddyRouter
 	ChatNavRouter
-	Config
+	config.Config
 	FeedbagRouter
 	ICBMRouter
 	LocateRouter

+ 3 - 2
server/bos_service.go

@@ -7,6 +7,7 @@ import (
 	"net"
 	"os"
 
+	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/oscar"
 	"github.com/mk6i/retro-aim-server/state"
 )
@@ -26,7 +27,7 @@ type BOSRouter interface {
 type BOSService struct {
 	AuthHandler
 	BOSRouter
-	Config
+	config.Config
 	OServiceBOSRouter
 }
 
@@ -34,7 +35,7 @@ type BOSService struct {
 // authentication handshake sequences are handled by this method. The remaining
 // requests are relayed to BOSRouter.
 func (rt BOSService) Start() {
-	addr := Address("", rt.Config.BOSPort)
+	addr := config.Address("", rt.Config.BOSPort)
 	listener, err := net.Listen("tcp", addr)
 	if err != nil {
 		rt.Logger.Error("unable to bind BOS server address", "err", err.Error())

+ 3 - 2
server/bucp.go

@@ -7,6 +7,7 @@ import (
 	"os"
 
 	"github.com/google/uuid"
+	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/oscar"
 	"github.com/mk6i/retro-aim-server/state"
 )
@@ -24,7 +25,7 @@ type AuthHandler interface {
 // the first service that the AIM client connects to in the login flow.
 type BUCPAuthService struct {
 	AuthHandler
-	Config
+	config.Config
 	RouteLogger
 }
 
@@ -32,7 +33,7 @@ type BUCPAuthService struct {
 // validates users credentials and, upon success, provides an auth cookie and
 // hostname information for connecting to the BOS service.
 func (rt BUCPAuthService) Start() {
-	addr := Address("", rt.Config.BUCPPort)
+	addr := config.Address("", rt.Config.BUCPPort)
 	listener, err := net.Listen("tcp", addr)
 	if err != nil {
 		rt.Logger.Error("unable to bind OSCAR server address", "err", err.Error())

+ 2 - 1
server/buddy_test.go

@@ -5,6 +5,7 @@ import (
 	"log/slog"
 	"testing"
 
+	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/oscar"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/mock"
@@ -73,7 +74,7 @@ func TestBuddyRouter_RouteBuddy(t *testing.T) {
 				Return(tc.output).
 				Maybe()
 
-			router := NewBuddyRouter(NewLogger(Config{}), svc)
+			router := NewBuddyRouter(NewLogger(config.Config{}), svc)
 
 			bufIn := &bytes.Buffer{}
 			assert.NoError(t, oscar.Marshal(tc.input.Body, bufIn))

+ 2 - 1
server/chat_nav_test.go

@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"testing"
 
+	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/oscar"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/mock"
@@ -125,7 +126,7 @@ func TestChatNavRouter_RouteChatNavRouter(t *testing.T) {
 				Return(tc.output, tc.handlerErr).
 				Maybe()
 
-			router := NewChatNavRouter(svc, NewLogger(Config{}))
+			router := NewChatNavRouter(svc, NewLogger(config.Config{}))
 
 			bufIn := &bytes.Buffer{}
 			assert.NoError(t, oscar.Marshal(tc.input.Body, bufIn))

+ 3 - 2
server/chat_service.go

@@ -8,6 +8,7 @@ import (
 	"net"
 	"os"
 
+	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/oscar"
 	"github.com/mk6i/retro-aim-server/state"
 )
@@ -28,13 +29,13 @@ type ChatServiceRouter interface {
 type ChatService struct {
 	AuthHandler
 	ChatServiceRouter
-	Config
+	config.Config
 	OServiceChatRouter
 }
 
 // Start creates a TCP server that implements that chat flow.
 func (rt ChatService) Start() {
-	addr := Address("", rt.Config.ChatPort)
+	addr := config.Address("", rt.Config.ChatPort)
 	listener, err := net.Listen("tcp", addr)
 	if err != nil {
 		rt.Logger.Error("unable to bind chat server address", "err", err.Error())

+ 2 - 1
server/chat_service_router.go

@@ -5,13 +5,14 @@ import (
 	"errors"
 	"io"
 
+	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/oscar"
 	"github.com/mk6i/retro-aim-server/state"
 )
 
 type ChatServiceRooterRouter struct {
 	ChatRouter
-	Config
+	config.Config
 	OServiceChatRouter
 }
 

+ 2 - 1
server/chat_test.go

@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"testing"
 
+	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/oscar"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/mock"
@@ -78,7 +79,7 @@ func TestChatRouter_RouteChat(t *testing.T) {
 				Return(tc.output, tc.handlerErr).
 				Maybe()
 
-			router := NewChatRouter(NewLogger(Config{}), svc)
+			router := NewChatRouter(NewLogger(config.Config{}), svc)
 
 			bufIn := &bytes.Buffer{}
 			assert.NoError(t, oscar.Marshal(tc.input.Body, bufIn))

+ 0 - 18
server/config.go

@@ -1,18 +0,0 @@
-package server
-
-import "fmt"
-
-type Config struct {
-	BOSPort     int    `envconfig:"BOS_PORT" default:"5191"`
-	BUCPPort    int    `envconfig:"BUCP_PORT" default:"5190"`
-	ChatPort    int    `envconfig:"CHAT_PORT" default:"5192"`
-	DBPath      string `envconfig:"DB_PATH" required:"true"`
-	DisableAuth bool   `envconfig:"DISABLE_AUTH" default:"false"`
-	FailFast    bool   `envconfig:"FAIL_FAST" default:"false"`
-	LogLevel    string `envconfig:"LOG_LEVEL" default:"info"`
-	OSCARHost   string `envconfig:"OSCAR_HOST" required:"true"`
-}
-
-func Address(host string, port int) string {
-	return fmt.Sprintf("%s:%d", host, port)
-}

+ 4 - 3
server/connection_test.go

@@ -8,6 +8,7 @@ import (
 	"sync"
 	"testing"
 
+	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/oscar"
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/stretchr/testify/assert"
@@ -16,7 +17,7 @@ import (
 func TestHandleChatConnection_Notification(t *testing.T) {
 
 	ctx := context.Background()
-	cfg := Config{}
+	cfg := config.Config{}
 	logger := NewLogger(cfg)
 
 	sessionManager := state.NewInMemorySessionManager(logger)
@@ -79,7 +80,7 @@ func TestHandleChatConnection_Notification(t *testing.T) {
 func TestHandleChatConnection_ClientRequestFLAP(t *testing.T) {
 
 	ctx := context.Background()
-	cfg := Config{}
+	cfg := config.Config{}
 	logger := NewLogger(cfg)
 
 	sessionManager := state.NewInMemorySessionManager(logger)
@@ -134,7 +135,7 @@ func TestHandleChatConnection_ClientRequestFLAP(t *testing.T) {
 func TestHandleChatConnection_SessionClosed(t *testing.T) {
 
 	ctx := context.Background()
-	cfg := Config{}
+	cfg := config.Config{}
 	logger := NewLogger(cfg)
 
 	sessionManager := state.NewInMemorySessionManager(logger)

+ 2 - 1
server/feedbag_test.go

@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"testing"
 
+	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/oscar"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/mock"
@@ -268,7 +269,7 @@ func TestFeedbagRouter_RouteFeedbag(t *testing.T) {
 				StartClusterHandler(mock.Anything, tc.input.Frame, tc.input.Body).
 				Maybe()
 
-			router := NewFeedbagRouter(NewLogger(Config{}), svc)
+			router := NewFeedbagRouter(NewLogger(config.Config{}), svc)
 
 			bufIn := &bytes.Buffer{}
 			assert.NoError(t, oscar.Marshal(tc.input.Body, bufIn))

+ 2 - 1
server/icbm_test.go

@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"testing"
 
+	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/oscar"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/mock"
@@ -171,7 +172,7 @@ func TestICBMRouter_RouteICBM(t *testing.T) {
 					Maybe()
 			}
 
-			router := NewICBMRouter(NewLogger(Config{}), svc)
+			router := NewICBMRouter(NewLogger(config.Config{}), svc)
 
 			bufIn := &bytes.Buffer{}
 			assert.NoError(t, oscar.Marshal(tc.input.Body, bufIn))

+ 2 - 1
server/locate_test.go

@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"testing"
 
+	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/oscar"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/mock"
@@ -207,7 +208,7 @@ func TestLocateRouter_RouteLocate(t *testing.T) {
 				Return(tc.output, tc.handlerErr).
 				Maybe()
 
-			router := NewLocateRouter(svc, NewLogger(Config{}))
+			router := NewLocateRouter(svc, NewLogger(config.Config{}))
 
 			bufIn := &bytes.Buffer{}
 			assert.NoError(t, oscar.Marshal(tc.input.Body, bufIn))

+ 2 - 1
server/logging.go

@@ -6,6 +6,7 @@ import (
 	"os"
 	"strings"
 
+	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/oscar"
 )
 
@@ -17,7 +18,7 @@ var levelNames = map[slog.Leveler]string{
 	LevelTrace: "TRACE",
 }
 
-func NewLogger(cfg Config) *slog.Logger {
+func NewLogger(cfg config.Config) *slog.Logger {
 	var level slog.Level
 	switch strings.ToLower(cfg.LogLevel) {
 	case "trace":

+ 2 - 1
server/mgmt_api.go

@@ -8,6 +8,7 @@ import (
 	"os"
 
 	"github.com/google/uuid"
+	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/state"
 )
 
@@ -25,7 +26,7 @@ func StartManagementAPI(userManager UserManager, logger *slog.Logger) {
 	mux.HandleFunc("/user", uh.ServeHTTP)
 
 	//todo make port configurable
-	addr := Address("", 8080)
+	addr := config.Address("", 8080)
 	logger.Info("starting management API server", "addr", addr)
 	if err := http.ListenAndServe(addr, mux); err != nil {
 		logger.Error("unable to bind management API address address", "err", err.Error())

+ 3 - 2
server/oservice_test.go

@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"testing"
 
+	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/oscar"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/mock"
@@ -252,7 +253,7 @@ func TestOServiceRouter_RouteOService_ForBOS(t *testing.T) {
 				Return(tc.handlerErr).
 				Maybe()
 
-			router := NewOServiceRouterForBOS(NewLogger(Config{}), svc, svcBOS)
+			router := NewOServiceRouterForBOS(NewLogger(config.Config{}), svc, svcBOS)
 
 			bufIn := &bytes.Buffer{}
 			assert.NoError(t, oscar.Marshal(tc.input.Body, bufIn))
@@ -530,7 +531,7 @@ func TestOServiceRouter_RouteOService_ForChat(t *testing.T) {
 				Return(tc.handlerErr).
 				Maybe()
 
-			router := NewOServiceRouterForChat(NewLogger(Config{}), svc, svcBOS)
+			router := NewOServiceRouterForChat(NewLogger(config.Config{}), svc, svcBOS)
 
 			bufIn := &bytes.Buffer{}
 			assert.NoError(t, oscar.Marshal(tc.input.Body, bufIn))