├── state ├── migrations │ ├── 0007_icq_fields.down.sql │ ├── 0004_chat_rooms.down.sql │ ├── 0006_chat_cookie.down.sql │ ├── 0008_offline_msgs.down.sql │ ├── 0015_bart_item_type.down.sql │ ├── 0011_toc_config.up.sql │ ├── 0013_is_bot.up.sql │ ├── 0015_bart_item_type.up.sql │ ├── 0006_chat_cookie.up.sql │ ├── 0012_suspended_status.up.sql │ ├── 0014_warn_state.down.sql │ ├── 0001_initialize_schema.down.sql │ ├── 0025_profile_mime_type_update_time.down.sql │ ├── 0017_web_preferences.down.sql │ ├── 0025_profile_mime_type_update_time.up.sql │ ├── 0014_warn_state.up.sql │ ├── 0023_web_api_keys.down.sql │ ├── 0008_offline_msgs.up.sql │ ├── 0016_webapi_tokens.down.sql │ ├── 0021_vanity_urls.down.sql │ ├── 0004_chat_rooms.up.sql │ ├── 0024_fix_warn_timestamp.down.sql │ ├── 0018_oscar_bridge_sessions.down.sql │ ├── 0005_user_settings.up.sql │ ├── 0020_buddy_feeds.down.sql │ ├── 0019_api_analytics.down.sql │ ├── 0024_fix_warn_timestamp.up.sql │ ├── 0022_web_chat_rooms.down.sql │ ├── 0003_formatted_screen_name.down.sql │ ├── 0002_hash_columns.down.sql │ ├── 0002_hash_columns.up.sql │ ├── 0005_user_settings.down.sql │ ├── 0016_webapi_tokens.up.sql │ ├── 0010_feedbag_pd.down.sql │ ├── 0026_offline_message_fk_index.down.sql │ ├── 0001_initialize_schema.up.sql │ ├── 0003_formatted_screen_name.up.sql │ ├── 0023_web_api_keys.up.sql │ ├── 0010_feedbag_pd.up.sql │ ├── 0026_offline_message_fk_index.up.sql │ ├── 0017_web_preferences.up.sql │ ├── 0018_oscar_bridge_sessions.up.sql │ ├── 0021_vanity_urls.up.sql │ ├── 0020_buddy_feeds.up.sql │ ├── 0019_api_analytics.up.sql │ └── 0022_web_chat_rooms.up.sql ├── chat_test.go ├── cookie.go ├── webapi_auth.go └── chat.go ├── .gitignore ├── codecov.yml ├── server ├── oscar │ ├── helpers_test.go │ ├── mock_chat_session_manager_test.go │ ├── mock_online_notifier_test.go │ ├── mock_response_writer_test.go │ ├── mock_stats_service_test.go │ ├── mock_rate_limit_updater_test.go │ ├── mock_user_lookup_service_test.go │ ├── middleware │ │ └── logger.go │ └── mock_chat_service_test.go ├── webapi │ ├── handlers │ │ └── expressions.go │ ├── handler.go │ └── oscar_config.go ├── http │ ├── mock_chat_room_deleter_test.go │ ├── mock_chat_session_retriever_test.go │ ├── mock_chat_room_creator_test.go │ ├── mock_message_relayer_test.go │ ├── mock_chat_room_retriever_test.go │ ├── mock_profile_retriever_test.go │ ├── mock_feedbag_retriever_test.go │ └── mock_session_retriever_test.go ├── toc │ ├── mock_dir_search_service_test.go │ ├── mock_admin_service_test.go │ ├── mock_chat_service_test.go │ └── mock_cookie_baker_test.go └── kerberos │ ├── kerberos.go │ └── mock_auth_test.go ├── Dockerfile ├── config ├── ssl │ ├── stunnel.conf │ └── settings.env ├── ras.service └── settings.env ├── Dockerfile.certgen ├── scripts ├── run_stunnel.sh └── run_dev.sh ├── wire ├── snacs_string_test.go ├── user.go └── snacs_test.go ├── foodgroup ├── stats_test.go ├── stats.go ├── user_lookup.go ├── mock_session_retriever_test.go ├── user_lookup_test.go └── mock_cookie_baker_test.go ├── cmd ├── snac_buster │ └── main.go ├── server │ └── main.go └── config_generator │ └── main.go ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── go.yml ├── LICENSE ├── go.mod ├── docker-compose.yaml ├── docs ├── SYSTEMD.md ├── RELEASE.md ├── BUILD.md ├── DOCKER.md ├── LINUX.md ├── CLIENT.md ├── RENDEZVOUS.md ├── WINDOWS.md ├── MACOS.md └── AIM_6_7.md ├── .goreleaser.yaml ├── Dockerfile.stunnel └── Makefile /state/migrations/0007_icq_fields.down.sql: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /state/migrations/0004_chat_rooms.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE chatRoom; -------------------------------------------------------------------------------- /state/migrations/0006_chat_cookie.down.sql: -------------------------------------------------------------------------------- 1 | -- nothing to do here -------------------------------------------------------------------------------- /state/migrations/0008_offline_msgs.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE offlineMessage; -------------------------------------------------------------------------------- /state/migrations/0015_bart_item_type.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE bartItem DROP COLUMN type; 2 | -------------------------------------------------------------------------------- /state/migrations/0011_toc_config.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users 2 | ADD COLUMN tocConfig TEXT NOT NULL DEFAULT ''; -------------------------------------------------------------------------------- /state/migrations/0013_is_bot.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users 2 | ADD COLUMN isBot BOOLEAN NOT NULL DEFAULT false; -------------------------------------------------------------------------------- /state/migrations/0015_bart_item_type.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE bartItem ADD COLUMN type INTEGER NOT NULL DEFAULT 0; 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | *.db 3 | *.sqlite 4 | dist/ 5 | *.DS_Store 6 | certs/ 7 | .vscode/ 8 | wapi-docs/ 9 | .cursor/ -------------------------------------------------------------------------------- /state/migrations/0006_chat_cookie.up.sql: -------------------------------------------------------------------------------- 1 | UPDATE chatRoom 2 | SET cookie = exchange || '-' || '0' || '-' || name; 3 | -------------------------------------------------------------------------------- /state/migrations/0012_suspended_status.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users 2 | ADD COLUMN suspendedStatus int NOT NULL DEFAULT 0; 3 | -------------------------------------------------------------------------------- /state/migrations/0014_warn_state.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users 2 | DROP COLUMN lastWarnLevel; 3 | 4 | ALTER TABLE users 5 | DROP COLUMN lastWarnUpdate; 6 | -------------------------------------------------------------------------------- /state/migrations/0001_initialize_schema.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS bartItem; 2 | DROP TABLE IF EXISTS profile; 3 | DROP TABLE IF EXISTS feedbag; 4 | DROP TABLE IF EXISTS user; -------------------------------------------------------------------------------- /state/migrations/0025_profile_mime_type_update_time.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE profile 2 | DROP COLUMN updateTime; 3 | 4 | ALTER TABLE profile 5 | DROP COLUMN mimeType; 6 | 7 | -------------------------------------------------------------------------------- /state/migrations/0017_web_preferences.down.sql: -------------------------------------------------------------------------------- 1 | -- Drop the web_preferences table and its index 2 | DROP INDEX IF EXISTS idx_web_preferences_screen_name; 3 | DROP TABLE IF EXISTS web_preferences; 4 | -------------------------------------------------------------------------------- /state/migrations/0025_profile_mime_type_update_time.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE profile 2 | ADD COLUMN mimeType TEXT; 3 | 4 | ALTER TABLE profile 5 | ADD COLUMN updateTime INTEGER NOT NULL DEFAULT 0; 6 | 7 | -------------------------------------------------------------------------------- /state/migrations/0014_warn_state.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users 2 | ADD COLUMN lastWarnUpdate DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00'; 3 | 4 | ALTER TABLE users 5 | ADD COLUMN lastWarnLevel INTEGER NOT NULL DEFAULT 0; 6 | -------------------------------------------------------------------------------- /state/migrations/0023_web_api_keys.down.sql: -------------------------------------------------------------------------------- 1 | -- Drop indexes 2 | DROP INDEX IF EXISTS idx_web_api_keys_is_active; 3 | DROP INDEX IF EXISTS idx_web_api_keys_dev_key; 4 | 5 | -- Drop table 6 | DROP TABLE IF EXISTS web_api_keys; 7 | 8 | -------------------------------------------------------------------------------- /state/migrations/0008_offline_msgs.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE offlineMessage 2 | ( 3 | sender VARCHAR(16) NOT NULL, 4 | recipient VARCHAR(16) NOT NULL, 5 | message BLOB NOT NULL, 6 | sent TIMESTAMP NOT NULL 7 | ); -------------------------------------------------------------------------------- /state/migrations/0016_webapi_tokens.down.sql: -------------------------------------------------------------------------------- 1 | -- Drop Web API tokens table and indices 2 | DROP INDEX IF EXISTS idx_webapi_tokens_screen_name; 3 | DROP INDEX IF EXISTS idx_webapi_tokens_expires_at; 4 | DROP TABLE IF EXISTS webapi_tokens; 5 | -------------------------------------------------------------------------------- /state/migrations/0021_vanity_urls.down.sql: -------------------------------------------------------------------------------- 1 | -- Rollback Migration: 0020_vanity_urls 2 | -- Description: Remove vanity URL tables 3 | -- Date: 2024-12-28 4 | 5 | DROP TABLE IF EXISTS vanity_url_redirects; 6 | DROP TABLE IF EXISTS vanity_urls; 7 | -------------------------------------------------------------------------------- /state/migrations/0004_chat_rooms.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE chatRoom 2 | ( 3 | cookie TEXT PRIMARY KEY, 4 | exchange INTEGER, 5 | name TEXT, 6 | created TIMESTAMP, 7 | creator TEXT, 8 | UNIQUE (exchange, name) 9 | ); 10 | -------------------------------------------------------------------------------- /state/migrations/0024_fix_warn_timestamp.down.sql: -------------------------------------------------------------------------------- 1 | -- Revert lastWarnUpdate back to DATETIME type 2 | 3 | ALTER TABLE users DROP COLUMN lastWarnUpdate; 4 | 5 | ALTER TABLE users ADD COLUMN lastWarnUpdate DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00'; 6 | 7 | -------------------------------------------------------------------------------- /state/migrations/0018_oscar_bridge_sessions.down.sql: -------------------------------------------------------------------------------- 1 | -- Drop the OSCAR bridge sessions table and its indexes 2 | 3 | DROP INDEX IF EXISTS idx_oscar_bridge_screen_name; 4 | DROP INDEX IF EXISTS idx_oscar_bridge_last_accessed; 5 | DROP TABLE IF EXISTS oscar_bridge_sessions; 6 | -------------------------------------------------------------------------------- /state/migrations/0005_user_settings.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users 2 | ADD COLUMN confirmStatus BOOL DEFAULT FALSE; 3 | ALTER TABLE users 4 | ADD COLUMN emailAddress VARCHAR(320) NOT NULL DEFAULT ''; 5 | ALTER TABLE users 6 | ADD COLUMN regStatus INT NOT NULL DEFAULT 3; -------------------------------------------------------------------------------- /state/migrations/0020_buddy_feeds.down.sql: -------------------------------------------------------------------------------- 1 | -- Rollback Migration: 0019_buddy_feeds 2 | -- Description: Remove buddy feed tables 3 | -- Date: 2024-12-28 4 | 5 | DROP TABLE IF EXISTS buddy_feed_subscriptions; 6 | DROP TABLE IF EXISTS buddy_feed_items; 7 | DROP TABLE IF EXISTS buddy_feeds; 8 | -------------------------------------------------------------------------------- /state/migrations/0019_api_analytics.down.sql: -------------------------------------------------------------------------------- 1 | -- Rollback Migration: 0018_api_analytics 2 | -- Description: Remove Web API usage analytics tables 3 | -- Date: 2024-12-28 4 | 5 | DROP TABLE IF EXISTS api_quotas; 6 | DROP TABLE IF EXISTS api_usage_stats; 7 | DROP TABLE IF EXISTS api_usage_logs; 8 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | round: down 3 | range: 70..79 4 | status: 5 | project: 6 | default: 7 | informational: true 8 | patch: 9 | default: 10 | informational: true 11 | ignore: 12 | - server/webapi 13 | - state/webapi* 14 | - state/web_api* -------------------------------------------------------------------------------- /state/migrations/0024_fix_warn_timestamp.up.sql: -------------------------------------------------------------------------------- 1 | -- Change lastWarnUpdate from DATETIME to INTEGER (Unix timestamp) 2 | -- The old data is corrupted (Go string representation), so we'll drop and recreate 3 | 4 | ALTER TABLE users DROP COLUMN lastWarnUpdate; 5 | 6 | ALTER TABLE users ADD COLUMN lastWarnUpdate INTEGER NOT NULL DEFAULT 0; 7 | 8 | -------------------------------------------------------------------------------- /state/migrations/0022_web_chat_rooms.down.sql: -------------------------------------------------------------------------------- 1 | -- Rollback migration 0021: Web API Chat Rooms Support 2 | DROP TABLE IF EXISTS web_chat_messages; 3 | DROP TABLE IF EXISTS web_chat_participants; 4 | DROP TABLE IF EXISTS web_chat_sessions; 5 | DROP TABLE IF EXISTS web_chat_rooms; 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /server/oscar/helpers_test.go: -------------------------------------------------------------------------------- 1 | package oscar 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/stretchr/testify/mock" 7 | ) 8 | 9 | // matchContext matches any instance of Context interface. 10 | func matchContext() interface{} { 11 | return mock.MatchedBy(func(ctx any) bool { 12 | _, ok := ctx.(context.Context) 13 | return ok 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /state/migrations/0003_formatted_screen_name.down.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE user 2 | ( 3 | screenName VARCHAR(16) PRIMARY KEY, 4 | authKey TEXT, 5 | strongMD5Pass TEXT, 6 | weakMD5Pass TEXT 7 | ); 8 | 9 | INSERT INTO user 10 | SELECT displayScreenName, 11 | authKey, 12 | strongMD5Pass, 13 | weakMD5Pass 14 | FROM users; 15 | 16 | DROP TABLE users; -------------------------------------------------------------------------------- /state/migrations/0002_hash_columns.down.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE user_backup 2 | ( 3 | screenName VARCHAR(16) PRIMARY KEY, 4 | authKey TEXT, 5 | passHash TEXT 6 | ); 7 | 8 | INSERT INTO user_backup (screenName, authKey, passHash) 9 | SELECT screenName, authKey, strongMD5Pass 10 | FROM user; 11 | 12 | DROP TABLE user; 13 | 14 | ALTER TABLE user_backup 15 | RENAME TO user; 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.25.5-alpine AS builder 2 | 3 | WORKDIR /app 4 | 5 | COPY go.mod go.sum ./ 6 | RUN go mod download 7 | 8 | COPY . . 9 | 10 | RUN go build -o open_oscar_server ./cmd/server 11 | 12 | FROM alpine:latest 13 | 14 | WORKDIR /app 15 | 16 | COPY --from=builder /app/open_oscar_server /app/ 17 | 18 | EXPOSE 5190 8080 9898 1088 19 | 20 | CMD ["/app/open_oscar_server"] 21 | -------------------------------------------------------------------------------- /config/ssl/stunnel.conf: -------------------------------------------------------------------------------- 1 | foreground = yes 2 | debug = 7 3 | 4 | [ssl_proxy] 5 | options = NO_SSLv2 6 | options = NO_SSLv3 7 | options = NO_TLSv1_1 8 | ciphers = ALL 9 | accept = 443 10 | connect = retro-aim-server:1088 11 | cert = /etc/stunnel/certs/server.pem 12 | 13 | [oscar_proxy] 14 | options = NO_SSLv2 15 | options = NO_SSLv3 16 | options = NO_TLSv1_1 17 | ciphers = ALL 18 | accept = 5193 19 | connect = retro-aim-server:5190 20 | cert = /etc/stunnel/certs/server.pem -------------------------------------------------------------------------------- /Dockerfile.certgen: -------------------------------------------------------------------------------- 1 | # use the last version of alpine that has a version of nss-tools with support 2 | # for the legacy dbm format compiled in. 3 | FROM alpine:3.16 4 | 5 | RUN apk add --no-cache \ 6 | openssl \ 7 | nss-tools \ 8 | bash \ 9 | ca-certificates \ 10 | curl 11 | 12 | WORKDIR /certs 13 | 14 | # Set default database type to legacy DBM (instead of sqlite), the only type 15 | # AIM 6 supports. 16 | ENV NSS_DEFAULT_DB_TYPE=dbm 17 | 18 | CMD ["/bin/bash"] 19 | -------------------------------------------------------------------------------- /state/migrations/0002_hash_columns.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE user 2 | RENAME COLUMN passHash TO strongMD5Pass; 3 | 4 | ALTER TABLE user 5 | ADD COLUMN weakMD5Pass TEXT; 6 | 7 | -- The cleartext passwords don't exist, so it's not possible to create weak MD5 8 | -- hashes. Fill in the values with a placeholder value. The administrator will 9 | -- require everyone to reset their passwords if they want to log in with AIM 10 | -- 3.5 thru AIM4.7 11 | UPDATE user 12 | SET weakMD5Pass = strongMD5Pass; -------------------------------------------------------------------------------- /scripts/run_stunnel.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | if [ -z "$1" ]; then 6 | echo "Usage: $0 /path/to/server.pem" 7 | exit 1 8 | fi 9 | 10 | PEM_PATH="$1" 11 | 12 | docker run --rm -it \ 13 | --add-host=host.docker.internal:host-gateway \ 14 | --add-host=retro-aim-server:host-gateway \ 15 | -v "$PEM_PATH:/etc/stunnel/certs/server.pem:ro" \ 16 | -v "$(pwd)/config/ssl/stunnel.conf:/etc/stunnel/stunnel.conf:ro" \ 17 | -p 443:443 \ 18 | -p 5193:5193 \ 19 | ras-stunnel:5.75-openssl-1.0.2u stunnel.conf -------------------------------------------------------------------------------- /state/migrations/0005_user_settings.down.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE users_backup 2 | ( 3 | identScreenName VARCHAR(16) PRIMARY KEY, 4 | displayScreenName TEXT, 5 | authKey TEXT, 6 | strongMD5Pass TEXT, 7 | weakMD5Pass TEXT 8 | ); 9 | 10 | INSERT INTO users_backup (identScreenName, displayScreenName, authKey, strongMD5Pass, weakMD5Pass) 11 | SELECT identScreenName, displayScreenName, authKey, strongMD5Pass, weakMD5Pass 12 | FROM users; 13 | 14 | DROP TABLE users; 15 | 16 | ALTER TABLE users_backup 17 | RENAME TO users; 18 | -------------------------------------------------------------------------------- /state/migrations/0016_webapi_tokens.up.sql: -------------------------------------------------------------------------------- 1 | -- Create table for storing Web API authentication tokens 2 | CREATE TABLE IF NOT EXISTS webapi_tokens ( 3 | token TEXT PRIMARY KEY, 4 | screen_name TEXT NOT NULL, 5 | expires_at TIMESTAMP NOT NULL, 6 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 7 | ); 8 | 9 | -- Index for cleaning up expired tokens 10 | CREATE INDEX IF NOT EXISTS idx_webapi_tokens_expires_at ON webapi_tokens(expires_at); 11 | 12 | -- Index for looking up tokens by screen name 13 | CREATE INDEX IF NOT EXISTS idx_webapi_tokens_screen_name ON webapi_tokens(screen_name); 14 | -------------------------------------------------------------------------------- /config/ras.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Open OSCAR Server 3 | 4 | [Service] 5 | Type=simple 6 | User=ras 7 | Group=ras 8 | WorkingDirectory=/opt/ras 9 | StateDirectory=ras 10 | Environment="API_LISTENER=127.0.0.1:8080" 11 | Environment="OSCAR_ADVERTISED_LISTENERS_PLAIN=EXTERNAL://127.0.0.1:5190" 12 | Environment="OSCAR_LISTENERS=EXTERNAL://0.0.0.0:5190" 13 | Environment="TOC_LISTENERS=0.0.0.0:9898" 14 | Environment="DB_PATH=/var/lib/ras/oscar.sqlite" 15 | Environment="DISABLE_AUTH=true" 16 | Environment="LOG_LEVEL=info" 17 | ExecStart=/opt/ras/open_oscar_server 18 | Restart=on-failure 19 | 20 | [Install] 21 | WantedBy=multi-user.target 22 | -------------------------------------------------------------------------------- /state/migrations/0010_feedbag_pd.down.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE feedbag_new 2 | ( 3 | screenName VARCHAR(16), 4 | groupID INTEGER, 5 | itemID INTEGER, 6 | classID INTEGER, 7 | name TEXT, 8 | attributes BLOB, 9 | lastModified INTEGER, 10 | UNIQUE (screenName, groupID, itemID) 11 | ); 12 | 13 | INSERT INTO feedbag_new (screenName, groupID, itemID, classID, name, attributes, lastModified) 14 | SELECT screenName, groupID, itemID, classID, name, attributes, lastModified 15 | FROM feedbag; 16 | 17 | DROP TABLE feedbag; 18 | 19 | ALTER TABLE feedbag_new 20 | RENAME TO feedbag; 21 | 22 | DROP TABLE buddyListMode; 23 | DROP TABLE clientSideBuddyList; 24 | -------------------------------------------------------------------------------- /wire/snacs_string_test.go: -------------------------------------------------------------------------------- 1 | package wire 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestFoodGroupName_HappyPath(t *testing.T) { 9 | assert.Equal(t, "OService", FoodGroupName(OService)) 10 | } 11 | 12 | func TestFoodGroupName_InvalidFoodGroup(t *testing.T) { 13 | assert.Equal(t, "unknown", FoodGroupName(2142)) 14 | } 15 | 16 | func TestSubGroupName_HappyPath(t *testing.T) { 17 | assert.Equal(t, "OServiceServiceRequest", SubGroupName(OService, OServiceServiceRequest)) 18 | } 19 | 20 | func TestSubGroupName_InvalidFoodGroup(t *testing.T) { 21 | assert.Equal(t, "unknown", SubGroupName(2142, OServiceServiceRequest)) 22 | } 23 | -------------------------------------------------------------------------------- /state/migrations/0026_offline_message_fk_index.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users 2 | DROP COLUMN offlineMsgCount; 3 | 4 | DROP INDEX IF EXISTS idx_offlineMessage_sender; 5 | DROP INDEX IF EXISTS idx_offlineMessage_recipient; 6 | 7 | ALTER TABLE offlineMessage 8 | RENAME TO offlineMessage_new; 9 | 10 | CREATE TABLE offlineMessage 11 | ( 12 | sender VARCHAR(16) NOT NULL, 13 | recipient VARCHAR(16) NOT NULL, 14 | message BLOB NOT NULL, 15 | sent TIMESTAMP NOT NULL 16 | ); 17 | 18 | INSERT INTO offlineMessage (sender, recipient, message, sent) 19 | SELECT sender, recipient, message, sent 20 | FROM offlineMessage_new; 21 | 22 | DROP TABLE offlineMessage_new; 23 | -------------------------------------------------------------------------------- /state/migrations/0001_initialize_schema.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS user 2 | ( 3 | screenName VARCHAR(16) PRIMARY KEY, 4 | authKey TEXT, 5 | passHash TEXT 6 | ); 7 | 8 | CREATE TABLE IF NOT EXISTS feedbag 9 | ( 10 | screenName VARCHAR(16), 11 | groupID INTEGER, 12 | itemID INTEGER, 13 | classID INTEGER, 14 | name TEXT, 15 | attributes BLOB, 16 | lastModified INTEGER, 17 | UNIQUE (screenName, groupID, itemID) 18 | ); 19 | 20 | CREATE TABLE IF NOT EXISTS profile 21 | ( 22 | screenName VARCHAR(16) PRIMARY KEY, 23 | body TEXT 24 | ); 25 | 26 | CREATE TABLE IF NOT EXISTS bartItem 27 | ( 28 | hash CHAR(16) PRIMARY KEY, 29 | body BLOB 30 | ); -------------------------------------------------------------------------------- /foodgroup/stats_test.go: -------------------------------------------------------------------------------- 1 | package foodgroup 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/mk6i/retro-aim-server/wire" 10 | ) 11 | 12 | func TestStatsService_ReportEvents(t *testing.T) { 13 | svc := NewStatsService() 14 | 15 | frame := wire.SNACFrame{ 16 | RequestID: 1234, 17 | } 18 | body := wire.SNAC_0x0B_0x03_StatsReportEvents{} 19 | 20 | have := svc.ReportEvents(context.Background(), frame, body) 21 | 22 | want := wire.SNACMessage{ 23 | Frame: wire.SNACFrame{ 24 | FoodGroup: wire.Stats, 25 | SubGroup: wire.StatsReportAck, 26 | RequestID: 1234, 27 | }, 28 | Body: wire.SNAC_0x0B_0x04_StatsReportAck{}, 29 | } 30 | 31 | assert.Equal(t, want, have) 32 | } 33 | -------------------------------------------------------------------------------- /state/migrations/0003_formatted_screen_name.up.sql: -------------------------------------------------------------------------------- 1 | -- call the new table "users" since it's not a reserved word that we'll need to 2 | -- deal with for postgres in the future. 3 | CREATE TABLE users 4 | ( 5 | identScreenName VARCHAR(16) PRIMARY KEY, 6 | displayScreenName TEXT, 7 | authKey TEXT, 8 | strongMD5Pass TEXT, 9 | weakMD5Pass TEXT 10 | ); 11 | 12 | INSERT INTO users 13 | SELECT LOWER(REPLACE(screenName, ' ', '')), 14 | screenName, 15 | authKey, 16 | strongMD5Pass, 17 | weakMD5Pass 18 | FROM user; 19 | 20 | DROP TABLE user; 21 | 22 | UPDATE feedbag 23 | SET name = LOWER(REPLACE(name, ' ', '')) 24 | WHERE classID IN (0, 2, 3); 25 | 26 | UPDATE feedbag 27 | SET screenName = LOWER(REPLACE(screenName, ' ', '')); -------------------------------------------------------------------------------- /state/migrations/0023_web_api_keys.up.sql: -------------------------------------------------------------------------------- 1 | -- Create table for Web API authentication keys 2 | CREATE TABLE IF NOT EXISTS web_api_keys 3 | ( 4 | dev_id VARCHAR(255) PRIMARY KEY, 5 | dev_key VARCHAR(255) UNIQUE NOT NULL, 6 | app_name VARCHAR(255) NOT NULL, 7 | created_at INTEGER NOT NULL, 8 | last_used INTEGER, 9 | is_active BOOLEAN DEFAULT 1, 10 | rate_limit INTEGER DEFAULT 60, 11 | allowed_origins TEXT, -- JSON array of allowed CORS origins 12 | capabilities TEXT -- JSON array of enabled features/endpoints 13 | ); 14 | 15 | -- Create indexes for efficient lookups 16 | CREATE INDEX idx_web_api_keys_dev_key ON web_api_keys(dev_key); 17 | CREATE INDEX idx_web_api_keys_is_active ON web_api_keys(is_active); 18 | 19 | -------------------------------------------------------------------------------- /scripts/run_dev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # This script launches Open OSCAR Server using go run with the environment vars 3 | # defined in config/settings.env under MacOS/Linux. The script can be run from 4 | # any working directory--it assumes the location of config/command files 5 | # relative to the path of this script. 6 | set -e 7 | 8 | SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) 9 | DEFAULT_ENV_FILE="$SCRIPT_DIR/../config/ssl/settings.env" 10 | 11 | if [ "$#" -gt 1 ]; then 12 | echo "Usage: $0 [path/to/settings.env]" 13 | exit 1 14 | fi 15 | 16 | if [ "$#" -eq 1 ]; then 17 | ENV_FILE="$1" 18 | else 19 | ENV_FILE="$DEFAULT_ENV_FILE" 20 | fi 21 | REPO_ROOT="$SCRIPT_DIR/.." 22 | 23 | # Run Open OSCAR Server from repo root. 24 | cd "$REPO_ROOT" 25 | go run -v ./cmd/server -config "$ENV_FILE" -------------------------------------------------------------------------------- /foodgroup/stats.go: -------------------------------------------------------------------------------- 1 | package foodgroup 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/mk6i/retro-aim-server/wire" 7 | ) 8 | 9 | func NewStatsService() StatsService { 10 | return StatsService{} 11 | } 12 | 13 | type StatsService struct { 14 | } 15 | 16 | // ReportEvents handles incoming stats events by acknowledging them without 17 | // processing. This is a no-op implementation to satisfy the client's 18 | // expectation of a response. 19 | func (s StatsService) ReportEvents(ctx context.Context, inFrame wire.SNACFrame, _ wire.SNAC_0x0B_0x03_StatsReportEvents) wire.SNACMessage { 20 | return wire.SNACMessage{ 21 | Frame: wire.SNACFrame{ 22 | FoodGroup: wire.Stats, 23 | SubGroup: wire.StatsReportAck, 24 | RequestID: inFrame.RequestID, 25 | }, 26 | Body: wire.SNAC_0x0B_0x04_StatsReportAck{}, 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /state/chat_test.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/mk6i/retro-aim-server/wire" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestChatRoom_TLVList(t *testing.T) { 12 | room := NewChatRoom("chat-room-name", NewIdentScreenName(""), PublicExchange) 13 | 14 | have := room.TLVList() 15 | want := []wire.TLV{ 16 | wire.NewTLVBE(wire.ChatRoomTLVFlags, uint16(15)), 17 | wire.NewTLVBE(wire.ChatRoomTLVCreateTime, uint32(room.createTime.Unix())), 18 | wire.NewTLVBE(wire.ChatRoomTLVMaxMsgLen, uint16(1024)), 19 | wire.NewTLVBE(wire.ChatRoomTLVMaxOccupancy, uint16(100)), 20 | wire.NewTLVBE(wire.ChatRoomTLVNavCreatePerms, uint8(2)), 21 | wire.NewTLVBE(wire.ChatRoomTLVFullyQualifiedName, room.name), 22 | wire.NewTLVBE(wire.ChatRoomTLVRoomName, room.name), 23 | wire.NewTLVBE(wire.ChatRoomTLVMaxMsgVisLen, uint16(1024)), 24 | } 25 | 26 | assert.Equal(t, want, have) 27 | } 28 | -------------------------------------------------------------------------------- /state/migrations/0010_feedbag_pd.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE feedbag 2 | ADD COLUMN pdMode int DEFAULT 0; 3 | 4 | UPDATE feedbag 5 | SET pdMode = 6 | CASE 7 | WHEN instr(hex(attributes), '00CA000101') > 0 THEN 1 8 | WHEN instr(hex(attributes), '00CA000102') > 0 THEN 2 9 | WHEN instr(hex(attributes), '00CA000103') > 0 THEN 3 10 | WHEN instr(hex(attributes), '00CA000104') > 0 THEN 4 11 | WHEN instr(hex(attributes), '00CA000105') > 0 THEN 5 12 | END 13 | WHERE classID = 4; 14 | 15 | CREATE TABLE buddyListMode 16 | ( 17 | screenName VARCHAR(16), 18 | clientSidePDMode INTEGER DEFAULT 0, 19 | useFeedbag BOOLEAN DEFAULT false, 20 | PRIMARY KEY (screenName) 21 | ); 22 | 23 | CREATE TABLE clientSideBuddyList 24 | ( 25 | me VARCHAR(16), 26 | them VARCHAR(16), 27 | isBuddy BOOLEAN DEFAULT false, 28 | isPermit BOOLEAN DEFAULT false, 29 | isDeny BOOLEAN DEFAULT false, 30 | PRIMARY KEY (me, them) 31 | ); -------------------------------------------------------------------------------- /state/migrations/0026_offline_message_fk_index.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE offlineMessage 2 | RENAME TO offlineMessage_old; 3 | 4 | CREATE TABLE offlineMessage 5 | ( 6 | sender VARCHAR(16) NOT NULL, 7 | recipient VARCHAR(16) NOT NULL, 8 | message BLOB NOT NULL, 9 | sent TIMESTAMP NOT NULL, 10 | FOREIGN KEY (sender) REFERENCES users (identScreenName) ON DELETE CASCADE ON UPDATE CASCADE, 11 | FOREIGN KEY (recipient) REFERENCES users (identScreenName) ON DELETE CASCADE ON UPDATE CASCADE 12 | ); 13 | 14 | INSERT INTO offlineMessage (sender, recipient, message, sent) 15 | SELECT sender, recipient, message, sent 16 | FROM offlineMessage_old; 17 | 18 | DROP TABLE offlineMessage_old; 19 | 20 | CREATE INDEX idx_offlineMessage_sender ON offlineMessage (sender); 21 | CREATE INDEX idx_offlineMessage_recipient ON offlineMessage (recipient); 22 | 23 | ALTER TABLE users 24 | ADD COLUMN offlineMsgCount INTEGER NOT NULL DEFAULT 0; 25 | -------------------------------------------------------------------------------- /cmd/snac_buster/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | 7 | "github.com/mk6i/retro-aim-server/wire" 8 | ) 9 | 10 | func main() { 11 | 12 | b := []byte{} 13 | 14 | flap := wire.FLAPFrame{} 15 | err := wire.UnmarshalBE(&flap, bytes.NewReader(b)) 16 | if err != nil { 17 | err = fmt.Errorf("unable to unmarshal FLAP frame: %w", err) 18 | } 19 | 20 | rd := bytes.NewBuffer(flap.Payload) 21 | snac := wire.SNACFrame{} 22 | wire.UnmarshalBE(&snac, rd) 23 | 24 | printByteSlice(rd.Bytes()) 25 | //snacBody := wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate{} 26 | //wire.UnmarshalBE(&snacBody, rd) 27 | ////fmt.Println(snacBody) 28 | // 29 | //fmt.Println() 30 | // 31 | //for _, tlv := range snacBody.TLVList { 32 | // fmt.Printf("0x%x\t", tlv.Tag) 33 | // printByteSlice(tlv.Value) 34 | // fmt.Println() 35 | //} 36 | } 37 | 38 | func printByteSlice(data []byte) { 39 | fmt.Print("[]byte{") 40 | for i, b := range data { 41 | if i > 0 { 42 | fmt.Print(", ") 43 | } 44 | fmt.Printf("0x%02X", b) 45 | } 46 | fmt.Println("}") 47 | } 48 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request ✨ 3 | about: Use this template to suggest new features or improvements for Open OSCAR Server. 4 | 5 | title: '' 6 | labels: enhancement 7 | assignees: '' 8 | 9 | --- 10 | 11 | 17 | 18 | ### Description of the Feature 19 | 20 | 21 | ### Use Case 22 | 23 | 24 | ### Potential Challenges 25 | 26 | 27 | ### Additional Information 28 | 29 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v5 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v6 21 | with: 22 | go-version: '1.25.5' 23 | 24 | - name: Go Format 25 | run: | 26 | gofmt -s -l . 27 | if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then exit 1; fi 28 | 29 | - name: Go Vet 30 | run: go vet ./... 31 | 32 | - name: Build 33 | run: go build -v ./... 34 | 35 | - name: Test 36 | run: go test -v -race -coverprofile=coverage.out -covermode=atomic ./... 37 | 38 | - name: Upload coverage reports to Codecov 39 | uses: codecov/codecov-action@v5 40 | with: 41 | token: ${{ secrets.CODECOV_TOKEN }} -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 mk6i 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /state/migrations/0017_web_preferences.up.sql: -------------------------------------------------------------------------------- 1 | -- Create table for Web API user preferences 2 | CREATE TABLE IF NOT EXISTS web_preferences 3 | ( 4 | screen_name VARCHAR(16) PRIMARY KEY, 5 | preferences TEXT, -- JSON object of preference key-value pairs 6 | created_at INTEGER NOT NULL, 7 | updated_at INTEGER NOT NULL 8 | ); 9 | 10 | -- Create index for efficient lookups 11 | CREATE INDEX idx_web_preferences_screen_name ON web_preferences(screen_name); 12 | 13 | -- Ensure buddyListMode table exists (for PD mode storage) 14 | -- This should already exist from migration 0010, but we'll add IF NOT EXISTS for safety 15 | CREATE TABLE IF NOT EXISTS buddyListMode 16 | ( 17 | screenName VARCHAR(16), 18 | clientSidePDMode INTEGER DEFAULT 0, 19 | useFeedbag BOOLEAN DEFAULT false, 20 | PRIMARY KEY (screenName) 21 | ); 22 | 23 | -- Ensure clientSideBuddyList table exists (for permit/deny lists) 24 | -- This should already exist from migration 0010, but we'll add IF NOT EXISTS for safety 25 | CREATE TABLE IF NOT EXISTS clientSideBuddyList 26 | ( 27 | me VARCHAR(16), 28 | them VARCHAR(16), 29 | isBuddy BOOLEAN DEFAULT false, 30 | isPermit BOOLEAN DEFAULT false, 31 | isDeny BOOLEAN DEFAULT false, 32 | PRIMARY KEY (me, them) 33 | ); 34 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mk6i/retro-aim-server 2 | 3 | go 1.24.2 4 | 5 | require ( 6 | github.com/breign/goAMF3 v1.0.1-0.20250916173039-e43798221950 7 | github.com/golang-migrate/migrate/v4 v4.19.1 8 | github.com/google/uuid v1.6.0 9 | github.com/joho/godotenv v1.5.1 10 | github.com/kelseyhightower/envconfig v1.4.0 11 | github.com/mitchellh/go-wordwrap v1.0.1 12 | github.com/patrickmn/go-cache v2.1.0+incompatible 13 | github.com/stretchr/testify v1.11.1 14 | golang.org/x/net v0.47.0 15 | golang.org/x/sync v0.18.0 16 | golang.org/x/time v0.14.0 17 | modernc.org/sqlite v1.40.1 18 | ) 19 | 20 | require ( 21 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 22 | github.com/dustin/go-humanize v1.0.1 // indirect 23 | github.com/mattn/go-isatty v0.0.20 // indirect 24 | github.com/ncruces/go-strftime v1.0.0 // indirect 25 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 26 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 27 | github.com/stretchr/objx v0.5.3 // indirect 28 | golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 // indirect 29 | golang.org/x/sys v0.38.0 // indirect 30 | gopkg.in/yaml.v3 v3.0.1 // indirect 31 | modernc.org/libc v1.67.1 // indirect 32 | modernc.org/mathutil v1.7.1 // indirect 33 | modernc.org/memory v1.11.0 // indirect 34 | ) 35 | -------------------------------------------------------------------------------- /state/migrations/0018_oscar_bridge_sessions.up.sql: -------------------------------------------------------------------------------- 1 | -- Create table for storing WebAPI to OSCAR bridge sessions 2 | -- This table maps WebAPI sessions to OSCAR authentication cookies 3 | -- and tracks connection details for bridged sessions 4 | 5 | CREATE TABLE IF NOT EXISTS oscar_bridge_sessions ( 6 | -- WebAPI session identifier (aimsid) 7 | web_session_id VARCHAR(64) PRIMARY KEY, 8 | 9 | -- OSCAR authentication cookie (hex encoded) 10 | oscar_cookie BLOB NOT NULL, 11 | 12 | -- BOS server connection details 13 | bos_host VARCHAR(255) NOT NULL, 14 | bos_port INTEGER NOT NULL, 15 | use_ssl BOOLEAN DEFAULT FALSE, 16 | 17 | -- Screen name associated with the session 18 | screen_name VARCHAR(97) NOT NULL, 19 | 20 | -- Session metadata 21 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 22 | last_accessed TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 23 | 24 | -- Optional: Track client info 25 | client_name VARCHAR(255), 26 | client_version VARCHAR(50) 27 | ); 28 | 29 | -- Create index for quick lookups by screen name 30 | CREATE INDEX idx_oscar_bridge_screen_name ON oscar_bridge_sessions(screen_name); 31 | 32 | -- Create index for cleanup of old sessions 33 | CREATE INDEX idx_oscar_bridge_last_accessed ON oscar_bridge_sessions(last_accessed); 34 | 35 | -- Optional: Create a trigger to auto-update last_accessed on SELECT/UPDATE 36 | -- (SQLite doesn't support this directly, would need application logic) 37 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | cert-gen: 3 | image: ras-certgen:latest 4 | volumes: 5 | - ./certs:/work/certs 6 | working_dir: /work/certs 7 | entrypoint: [ "/bin/sh", "-c" ] 8 | environment: 9 | - OSCAR_HOST=${OSCAR_HOST} 10 | command: 11 | - "openssl req -x509 -newkey rsa:1024 12 | -keyout key.pem 13 | -out cert.pem 14 | -sha256 -days 365 -nodes 15 | -subj \"/CN=${OSCAR_HOST}\" 16 | && cat cert.pem key.pem > server.pem 17 | && rm cert.pem key.pem" 18 | 19 | nss-gen: 20 | image: ras-certgen:latest 21 | volumes: 22 | - ./certs:/work/certs 23 | working_dir: /work/certs 24 | entrypoint: [ "/bin/sh", "-c" ] 25 | command: 26 | - "rm -rf nss 27 | && mkdir -p nss 28 | && certutil -N -d nss --empty-password 29 | && certutil -A -n \"RAS\" -t \"CT,,C\" -i server.pem -d nss" 30 | 31 | retro-aim-server: 32 | image: ras:latest 33 | ports: 34 | - "5190:5190" 35 | - "8080:8080" 36 | - "9898:9898" 37 | - "1088:1088" 38 | env_file: 39 | - ./config/ssl/settings.env 40 | volumes: 41 | - .:/project 42 | working_dir: /project 43 | 44 | stunnel: 45 | image: ras-stunnel:5.75-openssl-1.0.2u 46 | ports: 47 | - "443:443" 48 | - "5193:5193" 49 | volumes: 50 | - ./config/ssl/stunnel.conf:/etc/stunnel/stunnel.conf:ro 51 | - ./certs:/etc/stunnel/certs:ro 52 | command: stunnel.conf 53 | -------------------------------------------------------------------------------- /state/migrations/0021_vanity_urls.up.sql: -------------------------------------------------------------------------------- 1 | -- Migration: 0020_vanity_urls 2 | -- Description: Create tables for vanity URL management 3 | -- Date: 2024-12-28 4 | 5 | -- Create vanity URLs table for custom user URLs 6 | CREATE TABLE IF NOT EXISTS vanity_urls ( 7 | screen_name VARCHAR(16) PRIMARY KEY, 8 | vanity_url VARCHAR(255) UNIQUE NOT NULL, 9 | display_name VARCHAR(100), 10 | bio TEXT, 11 | location VARCHAR(100), 12 | website VARCHAR(255), 13 | created_at INTEGER NOT NULL, 14 | updated_at INTEGER NOT NULL, 15 | is_active BOOLEAN DEFAULT TRUE, 16 | click_count INTEGER DEFAULT 0, 17 | last_accessed INTEGER 18 | ); 19 | 20 | -- Create indexes for efficient lookups 21 | CREATE INDEX idx_vanity_urls_url ON vanity_urls(vanity_url); 22 | CREATE INDEX idx_vanity_urls_active ON vanity_urls(is_active); 23 | CREATE INDEX idx_vanity_urls_created ON vanity_urls(created_at); 24 | 25 | -- Create vanity URL redirects table for tracking and analytics 26 | CREATE TABLE IF NOT EXISTS vanity_url_redirects ( 27 | id INTEGER PRIMARY KEY AUTOINCREMENT, 28 | vanity_url VARCHAR(255) NOT NULL, 29 | accessed_at INTEGER NOT NULL, 30 | ip_address VARCHAR(45), 31 | user_agent TEXT, 32 | referer TEXT, 33 | FOREIGN KEY (vanity_url) REFERENCES vanity_urls(vanity_url) ON DELETE CASCADE 34 | ); 35 | 36 | -- Create index for redirect analytics 37 | CREATE INDEX idx_vanity_redirects_url ON vanity_url_redirects(vanity_url); 38 | CREATE INDEX idx_vanity_redirects_time ON vanity_url_redirects(accessed_at); 39 | -------------------------------------------------------------------------------- /docs/SYSTEMD.md: -------------------------------------------------------------------------------- 1 | # Configuring Open OSCAR Server With systemd 2 | 3 | This document details the configuration of Open OSCAR Server to run as an unprivileged user with `systemd` managing it as 4 | a production service. 5 | 6 | 1. **Download Open OSCAR Server** 7 | 8 | Grab the latest Linux release from the [releases page](https://github.com/mk6i/open-oscar-server/releases) 9 | 10 | 2. **Create the ras user and group** 11 | 12 | Run the following commands: 13 | 14 | ```shell 15 | sudo useradd ras 16 | sudo mkdir -p /opt/ras 17 | ``` 18 | 19 | 3. **Extract the archive** 20 | 21 | Extract the archive using the usual `tar` invocation, and move `open_oscar_server` into `/opt/ras` 22 | 23 | 4. **Set Ownership and Permissions** 24 | 25 | ```shell 26 | sudo chown -R ras:ras /opt/ras 27 | sudo chmod -R o-rx /opt/ras 28 | ``` 29 | 30 | 5. **Copy the systemd service** 31 | 32 | Place the `ras.service` file in `/etc/systemd/system` 33 | 34 | 6. **Reload systemd** 35 | 36 | ```shell 37 | sudo systemctl daemon-reload 38 | ``` 39 | 40 | 7. **Enable and start the service** 41 | 42 | ```shell 43 | sudo systemctl enable --now ras.service 44 | ``` 45 | 46 | 8. **Make sure the service is running** 47 | 48 | ```shell 49 | sudo systemctl status ras.service 50 | sudo journalctl -xeu ras.service 51 | ``` 52 | 53 | Note that the `systemd` service defines the configuration for Open OSCAR Server directly, bypassing the `settings.env` 54 | config. Customizations may be performed in `/etc/systemd/system/ras.service`. 55 | -------------------------------------------------------------------------------- /server/webapi/handlers/expressions.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "log/slog" 5 | "net/http" 6 | ) 7 | 8 | // ExpressionsHandler handles Web AIM API expressions/buddy icon endpoints. 9 | type ExpressionsHandler struct { 10 | Logger *slog.Logger 11 | } 12 | 13 | // NewExpressionsHandler creates a new ExpressionsHandler. 14 | func NewExpressionsHandler(logger *slog.Logger) *ExpressionsHandler { 15 | return &ExpressionsHandler{ 16 | Logger: logger, 17 | } 18 | } 19 | 20 | // Get handles GET /expressions/get requests for buddy icons and expressions. 21 | func (h *ExpressionsHandler) Get(w http.ResponseWriter, r *http.Request) { 22 | // Parse parameters 23 | format := r.URL.Query().Get("f") 24 | targetUser := r.URL.Query().Get("t") 25 | expressionType := r.URL.Query().Get("type") 26 | 27 | h.Logger.Debug("expressions/get request", 28 | "format", format, 29 | "target", targetUser, 30 | "type", expressionType, 31 | ) 32 | 33 | // For redirect format, return a 404 (no buddy icon) 34 | // In a real implementation, this would redirect to the actual icon URL 35 | if format == "redirect" { 36 | w.WriteHeader(http.StatusNotFound) 37 | return 38 | } 39 | 40 | // For other formats, return an empty response indicating no expressions 41 | response := map[string]interface{}{ 42 | "response": map[string]interface{}{ 43 | "statusCode": 200, 44 | "statusText": "OK", 45 | "data": map[string]interface{}{ 46 | "expressions": []interface{}{}, 47 | }, 48 | }, 49 | } 50 | 51 | // Send response in requested format 52 | SendResponse(w, r, response, h.Logger) 53 | } 54 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 🐞 3 | about: Use this template to report bugs in Open OSCAR Server. 4 | 5 | title: '' 6 | labels: bug 7 | assignees: '' 8 | 9 | --- 10 | 11 | 15 | 16 | 22 | 23 | ### Subject of the Issue 24 | 25 | 26 | ### Deployment Environment 27 | * **Open OSCAR Server Version**: 28 | * **Installation Method**: 29 | * **Client(s) Used**: 30 | * **Other Relevant Details**: 31 | 32 | ### Steps to Reproduce 33 | 34 | 35 | ### Expected Behavior 36 | 37 | 38 | ### Actual Behavior 39 | 40 | 41 | ### Troubleshooting Data 42 | 43 | -------------------------------------------------------------------------------- /foodgroup/user_lookup.go: -------------------------------------------------------------------------------- 1 | package foodgroup 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/mk6i/retro-aim-server/state" 8 | "github.com/mk6i/retro-aim-server/wire" 9 | ) 10 | 11 | // NewUserLookupService returns a new instance of UserLookupService. 12 | func NewUserLookupService(profileManager ProfileManager) UserLookupService { 13 | return UserLookupService{ 14 | profileManager: profileManager, 15 | } 16 | } 17 | 18 | // UserLookupService implements the UserLookup food group. 19 | type UserLookupService struct { 20 | profileManager ProfileManager 21 | } 22 | 23 | // FindByEmail searches for a user by email address. 24 | func (s UserLookupService) FindByEmail(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0A_0x02_UserLookupFindByEmail) (wire.SNACMessage, error) { 25 | user, err := s.profileManager.FindByAIMEmail(ctx, string(inBody.Email)) 26 | 27 | switch { 28 | case errors.Is(err, state.ErrNoUser): 29 | return wire.SNACMessage{ 30 | Frame: wire.SNACFrame{ 31 | FoodGroup: wire.UserLookup, 32 | SubGroup: wire.UserLookupErr, 33 | RequestID: inFrame.RequestID, 34 | }, 35 | Body: wire.UserLookupErrNoUserFound, 36 | }, nil 37 | case err != nil: 38 | return wire.SNACMessage{}, err 39 | } 40 | 41 | return wire.SNACMessage{ 42 | Frame: wire.SNACFrame{ 43 | FoodGroup: wire.UserLookup, 44 | SubGroup: wire.UserLookupFindReply, 45 | RequestID: inFrame.RequestID, 46 | }, 47 | Body: wire.SNAC_0x0A_0x03_UserLookupFindReply{ 48 | TLVRestBlock: wire.TLVRestBlock{ 49 | TLVList: wire.TLVList{ 50 | wire.NewTLVBE(wire.UserLookupTLVEmailAddress, user.DisplayScreenName), 51 | }, 52 | }, 53 | }, 54 | }, nil 55 | } 56 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | 7 | builds: 8 | - id: linux 9 | binary: open_oscar_server 10 | goos: 11 | - linux 12 | goarch: 13 | - amd64 14 | - arm 15 | goarm: 16 | - "7" 17 | main: ./cmd/server 18 | env: 19 | - CGO_ENABLED=0 20 | - id: macos 21 | binary: open_oscar_server 22 | goos: 23 | - darwin 24 | goarch: 25 | - amd64 26 | - arm64 27 | main: ./cmd/server 28 | env: 29 | - CGO_ENABLED=0 30 | - id: windows 31 | binary: open_oscar_server 32 | goos: 33 | - windows 34 | goarch: 35 | - amd64 36 | main: ./cmd/server 37 | env: 38 | - CGO_ENABLED=0 39 | 40 | archives: 41 | - id: linux 42 | ids: 43 | - linux 44 | formats: 45 | - tar.gz 46 | wrap_in_directory: true 47 | files: 48 | - LICENSE 49 | - src: config/settings.env 50 | strip_parent: true 51 | - src: config/ras.service 52 | strip_parent: true 53 | name_template: >- 54 | {{ .Binary }}.{{ .Version }}.{{ .Os }}. 55 | {{- if eq .Arch "amd64" }}x86_64{{ else }}arm64_arm7_raspberry_pi{{ end }} 56 | - id: macos 57 | ids: 58 | - macos 59 | formats: 60 | - zip 61 | wrap_in_directory: true 62 | files: 63 | - LICENSE 64 | - src: config/settings.env 65 | strip_parent: true 66 | name_template: >- 67 | {{ .Binary }}.{{ .Version }}.macos. 68 | {{- if eq .Arch "amd64" }}intel_x86_64{{ else }}apple_silicon{{ end }} 69 | - id: windows 70 | ids: 71 | - windows 72 | formats: 73 | - zip 74 | wrap_in_directory: true 75 | files: 76 | - LICENSE 77 | - src: config/settings.env 78 | strip_parent: true 79 | name_template: >- 80 | {{ .Binary }}.{{ .Version }}.{{ .Os }}. 81 | {{- if eq .Arch "amd64" }}x86_64 82 | {{- else }}{{ .Arch }}{{ end }} 83 | 84 | release: 85 | draft: true -------------------------------------------------------------------------------- /server/webapi/handler.go: -------------------------------------------------------------------------------- 1 | package webapi 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "net/http" 8 | 9 | "github.com/mk6i/retro-aim-server/state" 10 | "github.com/mk6i/retro-aim-server/wire" 11 | ) 12 | 13 | type Handler struct { 14 | AdminService AdminService 15 | AuthService AuthService 16 | BuddyListRegistry BuddyListRegistry 17 | BuddyService BuddyService 18 | ChatNavService ChatNavService 19 | ChatService ChatService 20 | CookieBaker CookieBaker 21 | DirSearchService DirSearchService 22 | ICBMService ICBMService 23 | LocateService LocateService 24 | Logger *slog.Logger 25 | OServiceService OServiceService 26 | PermitDenyService PermitDenyService 27 | TOCConfigStore TOCConfigStore 28 | SNACRateLimits wire.SNACRateLimits 29 | // New fields for WebAPI handlers 30 | SessionRetriever SessionRetriever 31 | FeedbagRetriever FeedbagRetriever 32 | FeedbagManager FeedbagManager 33 | // Phase 2 additions 34 | MessageRelayer MessageRelayer 35 | OfflineMessageManager OfflineMessageManager 36 | BuddyBroadcaster BuddyBroadcaster 37 | ProfileManager ProfileManager 38 | RelationshipFetcher interface { 39 | Relationship(ctx context.Context, me state.IdentScreenName, them state.IdentScreenName) (state.Relationship, error) 40 | } 41 | // Authentication support 42 | UserManager UserManager 43 | TokenStore TokenStore 44 | // Phase 3 additions 45 | PreferenceManager PreferenceManager 46 | PermitDenyManager PermitDenyManager 47 | // Phase 4 additions for OSCAR Bridge 48 | OSCARBridgeStore OSCARBridgeStore 49 | OSCARConfig OSCARConfig 50 | // Phase 5 additions for buddy list and messaging 51 | BuddyListManager interface{} 52 | // Phase 5 additions for chat rooms 53 | ChatManager *state.WebAPIChatManager 54 | } 55 | 56 | func (h Handler) GetHelloWorldHandler(w http.ResponseWriter, r *http.Request) { 57 | h.Logger.Info("got a request to the root endpoint", "method", r.Method, "path", r.URL.Path) 58 | _, _ = fmt.Fprintf(w, "WebAPI Server Running\n") 59 | } 60 | -------------------------------------------------------------------------------- /docs/RELEASE.md: -------------------------------------------------------------------------------- 1 | # Open OSCAR Server Release Process 2 | 3 | This document explains how the Open OSCAR Server release process works. 4 | 5 | ## Overview 6 | 7 | Open OSCAR Server is built and released to Github using [GoReleaser](https://goreleaser.com/). The release process, which 8 | runs from a local computer (and not a CI/CD process) creates pre-built binaries for several platforms (Windows, macOS, 9 | Linux). 10 | 11 | GoReleaser runs in a Docker container, which provides a hermetic environment that prevents build contamination from the 12 | host environment. 13 | 14 | ### Code Signing Policy 15 | 16 | This project offers signed Windows binaries and does not currently offer signed macOS binaries. This means that macOS 17 | distrusts Open OSCAR Server by default and quarantines the application when you open it. 18 | > If you don't want to bypass this security mechanism, you can [build the project yourself](./building) instead. 19 | 20 | ## Release Procedure 21 | 22 | The following is the procedure that builds Open OSCAR Server and uploads the build artifacts to a Github release. 23 | 24 | 1. **Export Github Personal Access Token (PAT)** 25 | 26 | Export a Github Personal Access Token that has `write:packages` permissions for the Open OSCAR Server repo. 27 | 28 | ```sh 29 | export GITHUB_TOKEN=... 30 | ``` 31 | 32 | 2. **Tag The Build** 33 | 34 | Tag the build using [semantic versioning](https://semver.org/). 35 | ```shell 36 | git tag v0.1.0 37 | git push --tags 38 | ``` 39 | 40 | 3. **Dry-Run Release** 41 | 42 | Execute a dry-run build to make sure all the moving parts work together. Fix any problems that crop up before 43 | continuing. 44 | 45 | ```shell 46 | make release-dry-run 47 | ``` 48 | 49 | 4. **Release It!** 50 | 51 | Now run the release process. Once its complete, a private draft [release](https://github.com/mk6i/open-oscar-server/releases) 52 | should appear with attached build artifacts. 53 | 54 | ```shell 55 | make release 56 | ``` 57 | 58 | 5. **Sign It!** 59 | 60 | Download the Windows release, sign it, and re-upload the `.zip` to the draft release created in the previous step. 61 | 62 | 6. **Publish It** 63 | 64 | Publish the draft release. -------------------------------------------------------------------------------- /docs/BUILD.md: -------------------------------------------------------------------------------- 1 | # Development: How to Run/Compile Open OSCAR Server 2 | 3 | This guide explains how to set up a development environment for Open OSCAR Server and build/run the application. It 4 | assumes that you have little to no experience with golang. 5 | 6 | ## Dependencies 7 | 8 | Before you can run Open OSCAR Server, set up the following software dependencies. 9 | 10 | ### Golang 11 | 12 | Since Open OSCAR Server is written in go, install the latest version of [golang](https://go.dev/). 13 | 14 | If you're new to go, try [Visual Studio Code](https://code.visualstudio.com) wth the [go plugin](https://code.visualstudio.com/docs/languages/go) 15 | as your first IDE. 16 | 17 | ### Mockery (optional) 18 | 19 | [Mockery](https://github.com/vektra/mockery) is used to generate test mocks. Install this dependency and regenerate the 20 | mocks if you change any interfaces. 21 | 22 | ```shell 23 | go install github.com/vektra/mockery/v2@latest 24 | ``` 25 | 26 | Run the following command in a terminal from the root of the repository in order to regenerate test mocks, 27 | 28 | ```shell 29 | mockery 30 | ``` 31 | 32 | ## Run the Server 33 | 34 | To run the server using `go run`, run the following script from the root of the repository. The default settings can be 35 | modified in `config/settings.env`. 36 | 37 | ```shell 38 | scripts/run_dev.sh 39 | ``` 40 | 41 | ## Build the Server 42 | 43 | To build the server binary: 44 | 45 | ```shell 46 | go build -o open_oscar_server ./cmd/server 47 | ``` 48 | 49 | To run the binary with the settings file: 50 | 51 | ```shell 52 | ./open_oscar_server -config config/settings.env 53 | ``` 54 | 55 | ## Testing 56 | 57 | Open OSCAR Server includes a test suite that must pass before merging new code. To run the unit tests, run the following 58 | command from the root of the repository in a terminal: 59 | 60 | ```shell 61 | go test -race ./... 62 | ``` 63 | 64 | ## Config File Generation 65 | 66 | The config file `config/settings.env` is generated programmatically from the [Config](../config/config.go) struct using 67 | `go generate`. If you want to add or remove application configuration options, first edit the Config struct and then 68 | generate the configuration files by running `make config` from the project root. Do not edit the config files by hand. 69 | -------------------------------------------------------------------------------- /wire/user.go: -------------------------------------------------------------------------------- 1 | package wire 2 | 3 | import ( 4 | "crypto/md5" 5 | "io" 6 | ) 7 | 8 | // WeakMD5PasswordHash hashes password and authKey for AIM v3.5-v4.7. 9 | // 10 | //goland:noinspection ALL 11 | func WeakMD5PasswordHash(pass, authKey string) []byte { 12 | hash := md5.New() 13 | io.WriteString(hash, authKey) 14 | io.WriteString(hash, pass) 15 | io.WriteString(hash, "AOL Instant Messenger (SM)") 16 | return hash.Sum(nil) 17 | } 18 | 19 | // StrongMD5PasswordHash hashes password and authKey for AIM v4.8+. 20 | // 21 | //goland:noinspection ALL 22 | func StrongMD5PasswordHash(pass, authKey string) []byte { 23 | top := md5.New() 24 | io.WriteString(top, pass) 25 | bottom := md5.New() 26 | io.WriteString(bottom, authKey) 27 | bottom.Write(top.Sum(nil)) 28 | io.WriteString(bottom, "AOL Instant Messenger (SM)") 29 | return bottom.Sum(nil) 30 | } 31 | 32 | // RoastOSCARPassword roasts an OSCAR client password. 33 | func RoastOSCARPassword(roastedPass []byte) []byte { 34 | var roastTable = []byte{ 35 | 0xF3, 0x26, 0x81, 0xC4, 0x39, 0x86, 0xDB, 0x92, 36 | 0x71, 0xA3, 0xB9, 0xE6, 0x53, 0x7A, 0x95, 0x7C, 37 | } 38 | return roastPass(roastedPass, roastTable) 39 | } 40 | 41 | // RoastKerberosPassword roasts a Kerberos client password. 42 | func RoastKerberosPassword(roastedPass []byte) []byte { 43 | var roastTable = []byte{ 44 | 0x76, 0x91, 0xc5, 0xe7, 0xd0, 0xd9, 0x95, 0xdd, 45 | 0x9e, 0x2F, 0xea, 0xd8, 0x6B, 0x21, 0xc2, 0xbc, 46 | } 47 | return roastPass(roastedPass, roastTable) 48 | } 49 | 50 | // RoastOSCARJavaPassword roasts a Java OSCAR client password. 51 | func RoastOSCARJavaPassword(roastedPass []byte) []byte { 52 | var roastTable = []byte{ 53 | 0xF3, 0xB3, 0x6C, 0x99, 0x95, 0x3F, 0xAC, 0xB6, 54 | 0xC5, 0xFA, 0x6B, 0x63, 0x69, 0x6C, 0xC3, 0x9A, 55 | } 56 | return roastPass(roastedPass, roastTable) 57 | } 58 | 59 | // RoastTOCPassword roasts a TOC client password. 60 | func RoastTOCPassword(roastedPass []byte) []byte { 61 | return roastPass(roastedPass, []byte("Tic/Toc")) 62 | } 63 | 64 | // roastPass toggles obfuscation/deobfuscates of roastedPass. 65 | func roastPass(roastedPass []byte, roastTable []byte) []byte { 66 | clearPass := make([]byte, len(roastedPass)) 67 | for i := range roastedPass { 68 | clearPass[i] = roastedPass[i] ^ roastTable[i%len(roastTable)] 69 | } 70 | return clearPass 71 | } 72 | -------------------------------------------------------------------------------- /docs/DOCKER.md: -------------------------------------------------------------------------------- 1 | # Open OSCAR Server Docker Setup 2 | 3 | This guide explains how to set up an SSL-enabled instance of Open OSCAR Server using Docker. 4 | 5 | ## Prerequisites 6 | 7 | - Git 8 | - [Docker Desktop](https://docs.docker.com/get-started/get-docker/) 9 | - Unix-like terminal with Makefile installed (use WSL2 for Windows) 10 | 11 | ## Getting Started 12 | 13 | ### 1. Clone the Repository 14 | 15 | ```bash 16 | git clone https://github.com/mk6i/open-oscar-server.git 17 | cd open-oscar-server 18 | ``` 19 | 20 | ### 2. Build Docker Images 21 | 22 | This builds Docker images for: 23 | 24 | - Certificate generation 25 | - SSL termination 26 | - The Open OSCAR Server runtime 27 | 28 | ```bash 29 | make docker-images 30 | ``` 31 | 32 | ### 3. Configure SSL Certificate 33 | 34 | #### Option A: Generate a Self-Signed Certificate 35 | 36 | If you don't have an SSL certificate, you can generate a self-signed certificate. The following creates a certificate 37 | under `certs/server.pem`. 38 | 39 | ```bash 40 | make docker-cert OSCAR_HOST=ras.dev 41 | ``` 42 | 43 | Replace `ras.dev` with the hostname clients will use to connect. 44 | 45 | #### Option B: Use an Existing Certificate 46 | 47 | If you already have an SSL certificate, place the PEM-encoded file at: 48 | 49 | ``` 50 | certs/server.pem 51 | ``` 52 | 53 | ### 4. Generate NSS Certificate Database 54 | 55 | This creates the [NSS certificate database](https://developer.mozilla.org/en-US/docs/Mozilla/Projects/NSS) in 56 | `certs/nss/`, which must be installed on each AIM 6.2+ client. 57 | 58 | ```bash 59 | make docker-nss 60 | ``` 61 | 62 | ### 5. Start the Server 63 | 64 | ```bash 65 | make docker-run OSCAR_HOST=ras.dev 66 | ``` 67 | 68 | Replace `ras.dev` with the hostname clients will use to connect. 69 | 70 | ### 6. Client Configuration 71 | 72 | #### Certificate Database 73 | 74 | Follow the [AIM 6.x client setup instructions](AIM_6_7.md#aim-6265312-setup) to install the `certs/nss/` database on each 75 | client. 76 | 77 | #### Resolving Hostname 78 | 79 | If `OSCAR_HOST` (e.g., `ras.dev`) is not a real domain with DNS configured, you'll need to add it to each client's hosts 80 | file so clients can resolve it. 81 | 82 | - Linux/macOS: `/etc/hosts` 83 | - Windows: `C:\Windows\System32\drivers\etc\hosts` 84 | 85 | Add a line like this, replacing the IP with your server's IP address: 86 | 87 | ``` 88 | 127.0.0.1 ras.dev 89 | ``` 90 | -------------------------------------------------------------------------------- /config/settings.env: -------------------------------------------------------------------------------- 1 | # Network listeners for core OSCAR services. For multi-homed servers, allows 2 | # users to connect from multiple networks. For example, you can allow both LAN 3 | # and Internet clients to connect to the same server using different connection 4 | # settings. 5 | # 6 | # Format: 7 | # - Comma-separated list of [NAME]://[HOSTNAME]:[PORT] 8 | # - Listener names and ports must be unique 9 | # - Listener names are user-defined 10 | # - Each listener needs a listener in OSCAR_ADVERTISED_LISTENERS_PLAIN 11 | # 12 | # Examples: 13 | # // Listen on all interfaces 14 | # LAN://0.0.0.0:5190 15 | # // Separate Internet and LAN config 16 | # WAN://142.250.176.206:5190,LAN://192.168.1.10:5191 17 | export OSCAR_LISTENERS=LOCAL://0.0.0.0:5190 18 | 19 | # Hostnames published by the server that clients connect to for accessing 20 | # various OSCAR services. These hostnames are NOT the bind addresses. For 21 | # multi-homed use servers, allows clients to connect using separate hostnames 22 | # per network. 23 | # 24 | # Format: 25 | # - Comma-separated list of [NAME]://[HOSTNAME]:[PORT] 26 | # - Each listener config must correspond to a config in OSCAR_LISTENERS 27 | # - Clients MUST be able to connect to these hostnames 28 | # 29 | # Examples: 30 | # // Local LAN config, server behind NAT 31 | # LAN://192.168.1.10:5190 32 | # // Separate Internet and LAN config 33 | # WAN://aim.example.com:5190,LAN://192.168.1.10:5191 34 | export OSCAR_ADVERTISED_LISTENERS_PLAIN=LOCAL://127.0.0.1:5190 35 | 36 | # Network listeners for TOC protocol service. 37 | # 38 | # Format: Comma-separated list of hostname:port pairs. 39 | # 40 | # Examples: 41 | # // All interfaces 42 | # 0.0.0.0:9898 43 | # // Multiple listeners 44 | # 0.0.0.0:9898,192.168.1.10:9899 45 | export TOC_LISTENERS=0.0.0.0:9898 46 | 47 | # Network listener for management API binds to. Only 1 listener can be 48 | # specified. (Default 127.0.0.1 restricts to same machine only). 49 | export API_LISTENER=127.0.0.1:8080 50 | 51 | # The path to the SQLite database file. The file and DB schema are auto-created 52 | # if they doesn't exist. 53 | export DB_PATH=oscar.sqlite 54 | 55 | # Disable password check and auto-create new users at login time. Useful for 56 | # quickly creating new accounts during development without having to register 57 | # new users via the management API. 58 | export DISABLE_AUTH=true 59 | 60 | # Set logging granularity. Possible values: 'trace', 'debug', 'info', 'warn', 61 | # 'error'. 62 | export LOG_LEVEL=info 63 | 64 | -------------------------------------------------------------------------------- /state/migrations/0020_buddy_feeds.up.sql: -------------------------------------------------------------------------------- 1 | -- Migration: 0019_buddy_feeds 2 | -- Description: Create tables for buddy feed functionality 3 | -- Date: 2024-12-28 4 | 5 | -- Create buddy feeds table for storing user feed configurations 6 | CREATE TABLE IF NOT EXISTS buddy_feeds ( 7 | id INTEGER PRIMARY KEY AUTOINCREMENT, 8 | screen_name VARCHAR(16) NOT NULL, 9 | feed_type VARCHAR(50) NOT NULL, -- 'rss', 'atom', 'status', 'blog', 'social' 10 | title TEXT, 11 | description TEXT, 12 | link TEXT, 13 | published_at INTEGER NOT NULL, 14 | created_at INTEGER NOT NULL, 15 | updated_at INTEGER NOT NULL, 16 | is_active BOOLEAN DEFAULT TRUE 17 | ); 18 | 19 | -- Create indexes for efficient querying 20 | CREATE INDEX idx_buddy_feeds_screen_name ON buddy_feeds(screen_name); 21 | CREATE INDEX idx_buddy_feeds_published ON buddy_feeds(published_at); 22 | CREATE INDEX idx_buddy_feeds_type ON buddy_feeds(feed_type); 23 | CREATE INDEX idx_buddy_feeds_active ON buddy_feeds(is_active); 24 | 25 | -- Create buddy feed items table for individual feed entries 26 | CREATE TABLE IF NOT EXISTS buddy_feed_items ( 27 | id INTEGER PRIMARY KEY AUTOINCREMENT, 28 | feed_id INTEGER NOT NULL, 29 | title TEXT NOT NULL, 30 | description TEXT, 31 | link TEXT, 32 | guid TEXT, 33 | author VARCHAR(16), -- Screen name of the author 34 | categories TEXT, -- JSON array of categories 35 | published_at INTEGER NOT NULL, 36 | created_at INTEGER NOT NULL, 37 | FOREIGN KEY (feed_id) REFERENCES buddy_feeds(id) ON DELETE CASCADE 38 | ); 39 | 40 | -- Create indexes for feed items 41 | CREATE INDEX idx_feed_items_feed_id ON buddy_feed_items(feed_id); 42 | CREATE INDEX idx_feed_items_published ON buddy_feed_items(published_at); 43 | CREATE INDEX idx_feed_items_guid ON buddy_feed_items(guid); 44 | 45 | -- Create buddy feed subscriptions table for tracking who follows which feeds 46 | CREATE TABLE IF NOT EXISTS buddy_feed_subscriptions ( 47 | id INTEGER PRIMARY KEY AUTOINCREMENT, 48 | subscriber_screen_name VARCHAR(16) NOT NULL, 49 | feed_id INTEGER NOT NULL, 50 | subscribed_at INTEGER NOT NULL, 51 | last_checked_at INTEGER, 52 | FOREIGN KEY (feed_id) REFERENCES buddy_feeds(id) ON DELETE CASCADE, 53 | UNIQUE(subscriber_screen_name, feed_id) 54 | ); 55 | 56 | -- Create indexes for subscriptions 57 | CREATE INDEX idx_feed_subs_subscriber ON buddy_feed_subscriptions(subscriber_screen_name); 58 | CREATE INDEX idx_feed_subs_feed_id ON buddy_feed_subscriptions(feed_id); 59 | -------------------------------------------------------------------------------- /cmd/server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | "time" 11 | 12 | "github.com/joho/godotenv" 13 | "golang.org/x/sync/errgroup" 14 | 15 | "github.com/mk6i/retro-aim-server/server/webapi" 16 | ) 17 | 18 | var ( 19 | // default build fields populated by GoReleaser 20 | version = "dev" 21 | commit = "none" 22 | date = "unknown" 23 | ) 24 | 25 | func init() { 26 | cfgFile := flag.String("config", "settings.env", "Path to config file") 27 | showHelp := flag.Bool("help", false, "Display help") 28 | showVersion := flag.Bool("version", false, "Display build information") 29 | 30 | flag.Parse() 31 | 32 | switch { 33 | case *showVersion: 34 | fmt.Printf("%-10s %s\n", "version:", version) 35 | fmt.Printf("%-10s %s\n", "commit:", commit) 36 | fmt.Printf("%-10s %s\n", "date:", date) 37 | os.Exit(0) 38 | case *showHelp: 39 | flag.PrintDefaults() 40 | os.Exit(0) 41 | } 42 | 43 | // optionally populate environment variables with config file 44 | if err := godotenv.Load(*cfgFile); err != nil { 45 | fmt.Printf("Config file (%s) not found, defaulting to env vars for app config...\n", *cfgFile) 46 | } else { 47 | fmt.Printf("Successfully loaded config file (%s)\n", *cfgFile) 48 | } 49 | } 50 | 51 | func main() { 52 | ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) 53 | defer stop() 54 | 55 | deps, err := MakeCommonDeps() 56 | if err != nil { 57 | fmt.Printf("startup failed: %s\n", err) 58 | os.Exit(1) 59 | } 60 | 61 | g, ctx := errgroup.WithContext(ctx) 62 | 63 | oscar := OSCAR(deps) 64 | g.Go(oscar.ListenAndServe) 65 | 66 | kerb := KerberosAPI(deps) 67 | g.Go(kerb.ListenAndServe) 68 | 69 | api := MgmtAPI(deps) 70 | g.Go(api.ListenAndServe) 71 | 72 | toc := TOC(deps) 73 | g.Go(toc.ListenAndServe) 74 | 75 | var webAPI *webapi.Server 76 | if os.Getenv("ENABLE_WEBAPI") == "1" { 77 | webAPI = WebAPI(deps) 78 | g.Go(webAPI.ListenAndServe) 79 | } 80 | 81 | select { 82 | case <-ctx.Done(): 83 | shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 84 | defer cancel() 85 | _ = oscar.Shutdown(shutdownCtx) 86 | _ = kerb.Shutdown(shutdownCtx) 87 | _ = api.Shutdown(shutdownCtx) 88 | _ = toc.Shutdown(shutdownCtx) 89 | if os.Getenv("ENABLE_WEBAPI") == "1" { 90 | _ = webAPI.Shutdown(shutdownCtx) 91 | } 92 | } 93 | 94 | if err = g.Wait(); err != nil { 95 | deps.logger.Error("server initialization failed", "err", err.Error()) 96 | os.Exit(1) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Dockerfile.stunnel: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Build stage – compile OpenSSL 1.0.2u and stunnel 5.75 3 | ############################################################################### 4 | FROM debian:12.11-slim AS build 5 | 6 | ARG OPENSSL_VERSION=1.0.2u 7 | ARG OPENSSL_TAG=OpenSSL_1_0_2u 8 | ARG STUNNEL_VERSION=5.75 9 | 10 | ARG OPENSSL_URL=https://github.com/openssl/openssl/releases/download/${OPENSSL_TAG}/openssl-${OPENSSL_VERSION}.tar.gz 11 | ARG STUNNEL_URL=https://www.stunnel.org/downloads/stunnel-${STUNNEL_VERSION}.tar.gz 12 | 13 | # Build prerequisites 14 | RUN apt-get update && \ 15 | apt-get install -y --no-install-recommends \ 16 | build-essential \ 17 | ca-certificates \ 18 | wget \ 19 | perl \ 20 | zlib1g-dev \ 21 | pkg-config && \ 22 | rm -rf /var/lib/apt/lists/* 23 | 24 | WORKDIR /usr/src 25 | 26 | # ---------- OpenSSL ---------------------------------------------------------- 27 | RUN wget -qO openssl.tar.gz "${OPENSSL_URL}" && \ 28 | tar xzf openssl.tar.gz && \ 29 | cd openssl-${OPENSSL_VERSION} && \ 30 | ./config --prefix=/usr/local/openssl --openssldir=/usr/local/openssl shared zlib && \ 31 | make -j"$(nproc)" && \ 32 | make install_sw 33 | 34 | # ---------- stunnel ---------------------------------------------------------- 35 | RUN wget -qO stunnel.tar.gz "${STUNNEL_URL}" && \ 36 | tar xzf stunnel.tar.gz && \ 37 | cd stunnel-${STUNNEL_VERSION} && \ 38 | ./configure \ 39 | --with-ssl=/usr/local/openssl \ 40 | --prefix=/usr/local \ 41 | --sysconfdir=/etc \ 42 | --disable-libwrap && \ 43 | make -j"$(nproc)" && \ 44 | make install 45 | 46 | ############################################################################### 47 | # Runtime stage – only what we need to run stunnel 48 | ############################################################################### 49 | FROM debian:bookworm-slim AS runtime 50 | 51 | COPY --from=build /usr/local/openssl /usr/local/openssl 52 | COPY --from=build /usr/local/bin/stunnel /usr/local/bin/ 53 | COPY --from=build /usr/local/lib /usr/local/lib 54 | 55 | # Make sure the custom OpenSSL is preferred at runtime 56 | ENV LD_LIBRARY_PATH="/usr/local/openssl/lib" 57 | 58 | # Directory to hold the user‑supplied stunnel.conf 59 | RUN mkdir -p /etc/stunnel 60 | 61 | WORKDIR /etc/stunnel 62 | EXPOSE 443 1088 63 | 64 | ENTRYPOINT ["stunnel"] 65 | # You can pass the config file name as CMD or at `docker run` time, e.g.: 66 | # CMD ["stunnel.conf"] -------------------------------------------------------------------------------- /docs/LINUX.md: -------------------------------------------------------------------------------- 1 | # Open OSCAR Server Quickstart for Linux (x86_64) 2 | 3 | This guide explains how to download, configure and run Open OSCAR Server on Linux (x86_64). 4 | 5 | 1. **Download Open OSCAR Server** 6 | 7 | Grab the latest Linux release from the [releases page](https://github.com/mk6i/open-oscar-server/releases) and extract 8 | the archive. The extracted folder contains the application and a configuration file `settings.env`. 9 | 10 | 2. **Configure Server Address** 11 | 12 | Set the default listener in `OSCAR_ADVERTISED_LISTENERS_PLAIN` in `settings.env` to a hostname and port that the AIM 13 | clients can connect to. If you are running the AIM client and server on the same machine, you don't need to change 14 | the default value. 15 | 16 | The format is `[NAME]://[HOSTNAME]:[PORT]` where: 17 | - `LOCAL` is the listener name (can be any name you choose, as long as it matches the `OSCAR_LISTENERS` config) 18 | - `127.0.0.1` is the hostname clients connect to 19 | - `5190` is the port number clients connect to 20 | 21 | In order to connect AIM clients on your LAN (including VMs with bridged networking), you can find the appropriate IP 22 | address by running `ifconfig` from the terminal and use that IP instead of `127.0.0.1`. 23 | 24 | 3. **Start the Application** 25 | 26 | Run the following command to launch Open OSCAR Server: 27 | 28 | ```shell 29 | ./open_oscar_server 30 | ``` 31 | 32 | Open OSCAR Server will run in the terminal, ready to accept AIM client connections. 33 | 34 | 4. **Configure AIM Clients** 35 | 36 | To do a quick sanity check, start an AIM client, sign in to the server, and send yourself an instant message. 37 | Configure the AIM client to connect to the host and port from `OSCAR_ADVERTISED_LISTENERS_PLAIN` in `settings.env`. If 38 | using the default server setting, set host to `127.0.0.1` and port `5190`. 39 | 40 | See the [Client Configuration Guide](./CLIENT.md) for more detail on setting up the AIM client. 41 | 42 | By default, you can enter *any* screen name and password at the AIM sign-in screen to auto-create an account. 43 | 44 | > Account auto-creation is meant to be a convenience feature for local development. In a production deployment, you 45 | should set `DISABLE_AUTH=false` in `settings.env` to enforce account authentication. User accounts can be created via 46 | the [Management API](../README.md#-management-api). 47 | 48 | 5. **Additional Setup** 49 | 50 | For optional configuration steps that enhance your Open OSCAR Server experience, refer to 51 | the [Additional Setup Guide](./ADDITIONAL_SETUP.md). -------------------------------------------------------------------------------- /state/migrations/0019_api_analytics.up.sql: -------------------------------------------------------------------------------- 1 | -- Migration: 0018_api_analytics 2 | -- Description: Create tables for Web API usage analytics and tracking 3 | -- Date: 2024-12-28 4 | 5 | -- Create API usage logs table for detailed request tracking 6 | CREATE TABLE IF NOT EXISTS api_usage_logs ( 7 | id INTEGER PRIMARY KEY AUTOINCREMENT, 8 | dev_id VARCHAR(255) NOT NULL, 9 | endpoint VARCHAR(255) NOT NULL, 10 | method VARCHAR(10) NOT NULL, 11 | timestamp INTEGER NOT NULL, 12 | response_time_ms INTEGER, 13 | status_code INTEGER, 14 | ip_address VARCHAR(45), 15 | user_agent TEXT, 16 | screen_name VARCHAR(16), -- User making the request (if authenticated) 17 | error_message TEXT, -- Store error details if request failed 18 | request_size INTEGER, -- Size of request in bytes 19 | response_size INTEGER -- Size of response in bytes 20 | ); 21 | 22 | -- Create indexes for efficient querying 23 | CREATE INDEX idx_usage_dev_id ON api_usage_logs(dev_id); 24 | CREATE INDEX idx_usage_timestamp ON api_usage_logs(timestamp); 25 | CREATE INDEX idx_usage_endpoint ON api_usage_logs(endpoint); 26 | CREATE INDEX idx_usage_status ON api_usage_logs(status_code); 27 | CREATE INDEX idx_usage_screen_name ON api_usage_logs(screen_name); 28 | 29 | -- Create aggregated statistics table for performance 30 | CREATE TABLE IF NOT EXISTS api_usage_stats ( 31 | id INTEGER PRIMARY KEY AUTOINCREMENT, 32 | dev_id VARCHAR(255) NOT NULL, 33 | endpoint VARCHAR(255) NOT NULL, 34 | period_type VARCHAR(10) NOT NULL, -- 'hour', 'day', 'month' 35 | period_start INTEGER NOT NULL, 36 | request_count INTEGER DEFAULT 0, 37 | error_count INTEGER DEFAULT 0, 38 | total_response_time_ms INTEGER DEFAULT 0, 39 | avg_response_time_ms INTEGER DEFAULT 0, 40 | total_request_bytes INTEGER DEFAULT 0, 41 | total_response_bytes INTEGER DEFAULT 0, 42 | unique_users INTEGER DEFAULT 0, 43 | UNIQUE(dev_id, endpoint, period_type, period_start) 44 | ); 45 | 46 | -- Create indexes for aggregated stats 47 | CREATE INDEX idx_stats_dev_id ON api_usage_stats(dev_id); 48 | CREATE INDEX idx_stats_period ON api_usage_stats(period_type, period_start); 49 | CREATE INDEX idx_stats_endpoint ON api_usage_stats(endpoint); 50 | 51 | -- Create table for tracking API key quotas and limits 52 | CREATE TABLE IF NOT EXISTS api_quotas ( 53 | dev_id VARCHAR(255) PRIMARY KEY, 54 | daily_limit INTEGER DEFAULT 10000, 55 | monthly_limit INTEGER DEFAULT 300000, 56 | daily_used INTEGER DEFAULT 0, 57 | monthly_used INTEGER DEFAULT 0, 58 | last_reset_daily INTEGER NOT NULL, 59 | last_reset_monthly INTEGER NOT NULL, 60 | overage_allowed BOOLEAN DEFAULT FALSE 61 | ); 62 | -------------------------------------------------------------------------------- /docs/CLIENT.md: -------------------------------------------------------------------------------- 1 | # Windows AIM 5.x Client Setup 2 | 3 | This guide explains how to install and configure Windows AIM 5.x clients for Open OSCAR Server. 4 | 5 | AIM 5.x is recommended if you want to experience the last version of AIM with the "classic" early-2000s feel. 6 | 7 | ## Installation 8 | 9 | ### Linux / FreeBSD 10 | 11 | Windows AIM versions 5.0-5.1.3036 run perfectly well on [Wine](https://www.winehq.org/). Here's how to set up AIM 12 | 5.1.3036. 13 | 14 | 1. [Install Wine](https://wiki.winehq.org/Download) 15 | 2. Download the AIM installer from [archive.org](https://archive.org/details/aim513036) 16 | 3. Run the installer from the terminal: 17 | ```shell 18 | wine aim513036.exe 19 | ``` 20 | 21 | ### macOS (Intel & Apple Silicon) 22 | 23 | Windows AIM can run on modern macOS without a VM, including the Apple Silicon platform! 24 | 25 | Get started with the [AIM for macOS project](https://github.com/mk6i/aim-for-macos). 26 | 27 | ### Windows 10/11 28 | 29 | 1. Download AIM 5.9.6089 (available on [NINA wiki](https://web.archive.org/web/20250910233232/https://wiki.nina.chat/wiki/Clients/AOL_Instant_Messenger#Windows)). 30 | 2. Install AIM and exit out of the application post-installation. 31 | 3. Open **Task Manager** and kill the **AOL Instant Messenger (32 bit)** process to make sure the application is 32 | actually terminated. 33 | 4. Open **File Explorer** and navigate to `C:\Program Files (x86)\AIM`. 34 | 5. Delete `aimapi.dll`. 35 | 6. Set [Windows XP compatibility mode](https://support.microsoft.com/en-us/windows/make-older-apps-or-programs-compatible-with-windows-783d6dd7-b439-bdb0-0490-54eea0f45938) 36 | on `aim.exe`. 37 | 38 | 7. Launch AIM. 39 | 40 | ## Configuration 41 | 42 | Once installed, configure AIM to connect to Open OSCAR Server. 43 | 44 | 1. At the sign-on screen, click `Setup`. 45 |

46 | screenshot of AIM sign-on screen 47 |

48 | 2. Under the `Sign On/Off` category, click `Connection`. 49 |

50 | screenshot of AIM preferences window 51 |

52 | 3. Configure the server host and port fields according to the `OSCAR_ADVERTISED_LISTENERS_PLAIN` configuration found in 53 | `config/settings.env`. For example, if `OSCAR_ADVERTISED_LISTENERS_PLAIN=LOCAL://127.0.0.1:5190`, set `Host` to 54 | `127.0.0.1` and `Port` to `5190`. 55 |

56 | screenshot of AIM host dialog 57 |

58 | 4. Click OK and sign in to AIM! 59 | -------------------------------------------------------------------------------- /server/oscar/mock_chat_session_manager_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package oscar 6 | 7 | import ( 8 | "github.com/mk6i/retro-aim-server/state" 9 | mock "github.com/stretchr/testify/mock" 10 | ) 11 | 12 | // newMockChatSessionManager creates a new instance of mockChatSessionManager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 13 | // The first argument is typically a *testing.T value. 14 | func newMockChatSessionManager(t interface { 15 | mock.TestingT 16 | Cleanup(func()) 17 | }) *mockChatSessionManager { 18 | mock := &mockChatSessionManager{} 19 | mock.Mock.Test(t) 20 | 21 | t.Cleanup(func() { mock.AssertExpectations(t) }) 22 | 23 | return mock 24 | } 25 | 26 | // mockChatSessionManager is an autogenerated mock type for the ChatSessionManager type 27 | type mockChatSessionManager struct { 28 | mock.Mock 29 | } 30 | 31 | type mockChatSessionManager_Expecter struct { 32 | mock *mock.Mock 33 | } 34 | 35 | func (_m *mockChatSessionManager) EXPECT() *mockChatSessionManager_Expecter { 36 | return &mockChatSessionManager_Expecter{mock: &_m.Mock} 37 | } 38 | 39 | // RemoveUserFromAllChats provides a mock function for the type mockChatSessionManager 40 | func (_mock *mockChatSessionManager) RemoveUserFromAllChats(user state.IdentScreenName) { 41 | _mock.Called(user) 42 | return 43 | } 44 | 45 | // mockChatSessionManager_RemoveUserFromAllChats_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveUserFromAllChats' 46 | type mockChatSessionManager_RemoveUserFromAllChats_Call struct { 47 | *mock.Call 48 | } 49 | 50 | // RemoveUserFromAllChats is a helper method to define mock.On call 51 | // - user state.IdentScreenName 52 | func (_e *mockChatSessionManager_Expecter) RemoveUserFromAllChats(user interface{}) *mockChatSessionManager_RemoveUserFromAllChats_Call { 53 | return &mockChatSessionManager_RemoveUserFromAllChats_Call{Call: _e.mock.On("RemoveUserFromAllChats", user)} 54 | } 55 | 56 | func (_c *mockChatSessionManager_RemoveUserFromAllChats_Call) Run(run func(user state.IdentScreenName)) *mockChatSessionManager_RemoveUserFromAllChats_Call { 57 | _c.Call.Run(func(args mock.Arguments) { 58 | var arg0 state.IdentScreenName 59 | if args[0] != nil { 60 | arg0 = args[0].(state.IdentScreenName) 61 | } 62 | run( 63 | arg0, 64 | ) 65 | }) 66 | return _c 67 | } 68 | 69 | func (_c *mockChatSessionManager_RemoveUserFromAllChats_Call) Return() *mockChatSessionManager_RemoveUserFromAllChats_Call { 70 | _c.Call.Return() 71 | return _c 72 | } 73 | 74 | func (_c *mockChatSessionManager_RemoveUserFromAllChats_Call) RunAndReturn(run func(user state.IdentScreenName)) *mockChatSessionManager_RemoveUserFromAllChats_Call { 75 | _c.Run(run) 76 | return _c 77 | } 78 | -------------------------------------------------------------------------------- /server/oscar/mock_online_notifier_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package oscar 6 | 7 | import ( 8 | "github.com/mk6i/retro-aim-server/wire" 9 | mock "github.com/stretchr/testify/mock" 10 | ) 11 | 12 | // newMockOnlineNotifier creates a new instance of mockOnlineNotifier. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 13 | // The first argument is typically a *testing.T value. 14 | func newMockOnlineNotifier(t interface { 15 | mock.TestingT 16 | Cleanup(func()) 17 | }) *mockOnlineNotifier { 18 | mock := &mockOnlineNotifier{} 19 | mock.Mock.Test(t) 20 | 21 | t.Cleanup(func() { mock.AssertExpectations(t) }) 22 | 23 | return mock 24 | } 25 | 26 | // mockOnlineNotifier is an autogenerated mock type for the OnlineNotifier type 27 | type mockOnlineNotifier struct { 28 | mock.Mock 29 | } 30 | 31 | type mockOnlineNotifier_Expecter struct { 32 | mock *mock.Mock 33 | } 34 | 35 | func (_m *mockOnlineNotifier) EXPECT() *mockOnlineNotifier_Expecter { 36 | return &mockOnlineNotifier_Expecter{mock: &_m.Mock} 37 | } 38 | 39 | // HostOnline provides a mock function for the type mockOnlineNotifier 40 | func (_mock *mockOnlineNotifier) HostOnline(service uint16) wire.SNACMessage { 41 | ret := _mock.Called(service) 42 | 43 | if len(ret) == 0 { 44 | panic("no return value specified for HostOnline") 45 | } 46 | 47 | var r0 wire.SNACMessage 48 | if returnFunc, ok := ret.Get(0).(func(uint16) wire.SNACMessage); ok { 49 | r0 = returnFunc(service) 50 | } else { 51 | r0 = ret.Get(0).(wire.SNACMessage) 52 | } 53 | return r0 54 | } 55 | 56 | // mockOnlineNotifier_HostOnline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HostOnline' 57 | type mockOnlineNotifier_HostOnline_Call struct { 58 | *mock.Call 59 | } 60 | 61 | // HostOnline is a helper method to define mock.On call 62 | // - service uint16 63 | func (_e *mockOnlineNotifier_Expecter) HostOnline(service interface{}) *mockOnlineNotifier_HostOnline_Call { 64 | return &mockOnlineNotifier_HostOnline_Call{Call: _e.mock.On("HostOnline", service)} 65 | } 66 | 67 | func (_c *mockOnlineNotifier_HostOnline_Call) Run(run func(service uint16)) *mockOnlineNotifier_HostOnline_Call { 68 | _c.Call.Run(func(args mock.Arguments) { 69 | var arg0 uint16 70 | if args[0] != nil { 71 | arg0 = args[0].(uint16) 72 | } 73 | run( 74 | arg0, 75 | ) 76 | }) 77 | return _c 78 | } 79 | 80 | func (_c *mockOnlineNotifier_HostOnline_Call) Return(sNACMessage wire.SNACMessage) *mockOnlineNotifier_HostOnline_Call { 81 | _c.Call.Return(sNACMessage) 82 | return _c 83 | } 84 | 85 | func (_c *mockOnlineNotifier_HostOnline_Call) RunAndReturn(run func(service uint16) wire.SNACMessage) *mockOnlineNotifier_HostOnline_Call { 86 | _c.Call.Return(run) 87 | return _c 88 | } 89 | -------------------------------------------------------------------------------- /docs/RENDEZVOUS.md: -------------------------------------------------------------------------------- 1 | # Configuring File Transfer Over The Internet 2 | 3 | ## Context 4 | 5 | In the OSCAR protocol, **Rendezvous** is a mechanism that lets two AIM clients exchange the necessary information to 6 | establish a direct, peer-to-peer connection—typically used for file transfers. 7 | 8 | Back in the early 2000s, most computers were assigned a **public IP address** directly from their ISP. As a result, when 9 | one AIM client sent its IP address to another, that address was routable on the public Internet. Today, almost everyone 10 | is behind a router performing Network Address Translation (NAT). Consequently, the sender’s machine usually only has a 11 | **private IP address** (e.g., `192.168.x.x`), which cannot be reached directly over the Internet. 12 | 13 | To work around this limitation, **Open OSCAR Server** can substitute your client’s private IP address with the server’s 14 | view of your **public IP address**. However, you still need to configure **port forwarding** on your router to ensure 15 | that incoming Rendezvous connections (for file transfers) are routed to the correct machine. 16 | 17 | ## Send File Setup 18 | 19 | This guide explains how to configure your Windows AIM client to send files over the Internet using the **Send File** 20 | feature. If you only need to **receive** a file, no additional setup is required. 21 | 22 | ### Caveats 23 | 24 | - **Same LAN Scenario** 25 | If both Retro AIM server and your AIM client are on the same local network (LAN), you do **not** need these steps. 26 | 27 | - **Mixed LAN and Internet** 28 | If the sending client and the server are on the same LAN while the receiver is on the Internet, this guide may not 29 | work as intended. 30 | 31 | - **Security Notice** 32 | Rendezvous makes your IP address visible to the recipient. Opening a port on your home network allows inbound Internet 33 | traffic to reach your computer on that port. Use careful judgment and consider the security implications before 34 | proceeding. 35 | 36 | ### 1. Configure AIM Port 37 | 38 | 1. Open the **AIM Preferences** window. 39 | 2. Select **File Transfer** from the sidebar or menu. 40 | 3. In the **Port number to use** field, enter a high port number such as `4000`. 41 | 42 | ### 2. Set Up Router Port Forwarding 43 | 44 | 1. Log in to your router’s admin interface. 45 | 2. Create a new **port forwarding** rule: 46 | - Forward **TCP** port `4000` (or the port you chose) 47 | - Send that traffic to the **local IP address** of the computer running AIM 48 | 3. Save or apply the changes. 49 | 50 | ### 3. Send a File 51 | 52 | Once everything is set up: 53 | 54 | 1. Start an IM with a friend. 55 | 2. Go to **File** > **Send File...** (or use the appropriate menu option in your AIM client). 56 | 3. Choose the file you want to send. 57 | 4. Your friend should now receive a prompt to accept or decline the file. 58 | 59 | If the receiver is on the Internet and your port forwarding is correct, the direct file transfer should succeed. 60 | -------------------------------------------------------------------------------- /server/oscar/mock_response_writer_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package oscar 6 | 7 | import ( 8 | "github.com/mk6i/retro-aim-server/wire" 9 | mock "github.com/stretchr/testify/mock" 10 | ) 11 | 12 | // newMockResponseWriter creates a new instance of mockResponseWriter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 13 | // The first argument is typically a *testing.T value. 14 | func newMockResponseWriter(t interface { 15 | mock.TestingT 16 | Cleanup(func()) 17 | }) *mockResponseWriter { 18 | mock := &mockResponseWriter{} 19 | mock.Mock.Test(t) 20 | 21 | t.Cleanup(func() { mock.AssertExpectations(t) }) 22 | 23 | return mock 24 | } 25 | 26 | // mockResponseWriter is an autogenerated mock type for the ResponseWriter type 27 | type mockResponseWriter struct { 28 | mock.Mock 29 | } 30 | 31 | type mockResponseWriter_Expecter struct { 32 | mock *mock.Mock 33 | } 34 | 35 | func (_m *mockResponseWriter) EXPECT() *mockResponseWriter_Expecter { 36 | return &mockResponseWriter_Expecter{mock: &_m.Mock} 37 | } 38 | 39 | // SendSNAC provides a mock function for the type mockResponseWriter 40 | func (_mock *mockResponseWriter) SendSNAC(frame wire.SNACFrame, body any) error { 41 | ret := _mock.Called(frame, body) 42 | 43 | if len(ret) == 0 { 44 | panic("no return value specified for SendSNAC") 45 | } 46 | 47 | var r0 error 48 | if returnFunc, ok := ret.Get(0).(func(wire.SNACFrame, any) error); ok { 49 | r0 = returnFunc(frame, body) 50 | } else { 51 | r0 = ret.Error(0) 52 | } 53 | return r0 54 | } 55 | 56 | // mockResponseWriter_SendSNAC_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendSNAC' 57 | type mockResponseWriter_SendSNAC_Call struct { 58 | *mock.Call 59 | } 60 | 61 | // SendSNAC is a helper method to define mock.On call 62 | // - frame wire.SNACFrame 63 | // - body any 64 | func (_e *mockResponseWriter_Expecter) SendSNAC(frame interface{}, body interface{}) *mockResponseWriter_SendSNAC_Call { 65 | return &mockResponseWriter_SendSNAC_Call{Call: _e.mock.On("SendSNAC", frame, body)} 66 | } 67 | 68 | func (_c *mockResponseWriter_SendSNAC_Call) Run(run func(frame wire.SNACFrame, body any)) *mockResponseWriter_SendSNAC_Call { 69 | _c.Call.Run(func(args mock.Arguments) { 70 | var arg0 wire.SNACFrame 71 | if args[0] != nil { 72 | arg0 = args[0].(wire.SNACFrame) 73 | } 74 | var arg1 any 75 | if args[1] != nil { 76 | arg1 = args[1].(any) 77 | } 78 | run( 79 | arg0, 80 | arg1, 81 | ) 82 | }) 83 | return _c 84 | } 85 | 86 | func (_c *mockResponseWriter_SendSNAC_Call) Return(err error) *mockResponseWriter_SendSNAC_Call { 87 | _c.Call.Return(err) 88 | return _c 89 | } 90 | 91 | func (_c *mockResponseWriter_SendSNAC_Call) RunAndReturn(run func(frame wire.SNACFrame, body any) error) *mockResponseWriter_SendSNAC_Call { 92 | _c.Call.Return(run) 93 | return _c 94 | } 95 | -------------------------------------------------------------------------------- /cmd/config_generator/main.go: -------------------------------------------------------------------------------- 1 | // This program generates env config scripts from config.Config struct tags for 2 | // unix and windows platforms. 3 | // Usage: go run ./cmd/config_generator [platform] [filename] [value_tag] 4 | // Example: go run ./cmd/config_generator unix settings.basic.env basic 5 | package main 6 | 7 | import ( 8 | "fmt" 9 | "io" 10 | "os" 11 | "reflect" 12 | "strings" 13 | 14 | "github.com/mk6i/retro-aim-server/config" 15 | 16 | "github.com/mitchellh/go-wordwrap" 17 | ) 18 | 19 | var platformKeywords = map[string]struct { 20 | comment string 21 | assignment string 22 | }{ 23 | "windows": { 24 | comment: "rem ", 25 | assignment: "set ", 26 | }, 27 | "unix": { 28 | comment: "# ", 29 | assignment: "export ", 30 | }, 31 | } 32 | 33 | func main() { 34 | args := os.Args[1:] 35 | if len(args) < 3 { 36 | fmt.Fprintln(os.Stderr, "usage: go run cmd/config_generator [platform] [filename] [value_tag]") 37 | os.Exit(1) 38 | } 39 | 40 | platform := args[0] 41 | filename := args[1] 42 | valueTag := args[2] // e.g., "basic", "ssl" 43 | 44 | keywords, ok := platformKeywords[platform] 45 | if !ok { 46 | fmt.Fprintf(os.Stderr, "unable to find platform `%s`\n", platform) 47 | os.Exit(1) 48 | } 49 | fmt.Println("writing to", filename) 50 | f, err := os.Create(filename) 51 | if err != nil { 52 | fmt.Fprintf(os.Stderr, "error creating file: %s\n", err.Error()) 53 | os.Exit(1) 54 | } 55 | defer f.Close() 56 | 57 | configType := reflect.TypeOf(config.Config{}) 58 | for i := 0; i < configType.NumField(); i++ { 59 | field := configType.Field(i) 60 | 61 | // Check if field is optional and has empty value 62 | required := field.Tag.Get("required") 63 | val := field.Tag.Get(valueTag) // Use the specified value tag 64 | 65 | // Skip optional fields with empty values 66 | if required == "false" && val == "" { 67 | continue 68 | } 69 | 70 | comment := field.Tag.Get("description") 71 | if err := writeComment(f, comment, 80, keywords.comment); err != nil { 72 | fmt.Fprintf(os.Stderr, "error writing to file: %s\n", err.Error()) 73 | os.Exit(1) 74 | } 75 | 76 | varName := field.Tag.Get("envconfig") 77 | if err := writeAssignment(f, keywords.assignment, varName, val); err != nil { 78 | fmt.Fprintf(os.Stderr, "error writing to file: %s\n", err.Error()) 79 | os.Exit(1) 80 | } 81 | } 82 | } 83 | 84 | func writeComment(w io.Writer, comment string, width uint, keyword string) error { 85 | // adjust wrapping threshold to accommodate comment keyword length 86 | width = width - uint(len(keyword)) 87 | comment = wordwrap.WrapString(comment, width) 88 | // prepend lines with comment keyword 89 | comment = strings.ReplaceAll(comment, "\n", fmt.Sprintf("\n%s", keyword)) 90 | _, err := fmt.Fprintf(w, "%s%s\n", keyword, comment) 91 | return err 92 | } 93 | 94 | func writeAssignment(w io.Writer, keyword string, varName string, val string) error { 95 | _, err := fmt.Fprintf(w, "%s%s=%s\n\n", keyword, varName, val) 96 | return err 97 | } 98 | -------------------------------------------------------------------------------- /config/ssl/settings.env: -------------------------------------------------------------------------------- 1 | # Network listeners for core OSCAR services. For multi-homed servers, allows 2 | # users to connect from multiple networks. For example, you can allow both LAN 3 | # and Internet clients to connect to the same server using different connection 4 | # settings. 5 | # 6 | # Format: 7 | # - Comma-separated list of [NAME]://[HOSTNAME]:[PORT] 8 | # - Listener names and ports must be unique 9 | # - Listener names are user-defined 10 | # - Each listener needs a listener in OSCAR_ADVERTISED_LISTENERS_PLAIN 11 | # 12 | # Examples: 13 | # // Listen on all interfaces 14 | # LAN://0.0.0.0:5190 15 | # // Separate Internet and LAN config 16 | # WAN://142.250.176.206:5190,LAN://192.168.1.10:5191 17 | export OSCAR_LISTENERS=LOCAL://0.0.0.0:5190 18 | 19 | # Hostnames published by the server that clients connect to for accessing 20 | # various OSCAR services. These hostnames are NOT the bind addresses. For 21 | # multi-homed use servers, allows clients to connect using separate hostnames 22 | # per network. 23 | # 24 | # Format: 25 | # - Comma-separated list of [NAME]://[HOSTNAME]:[PORT] 26 | # - Each listener config must correspond to a config in OSCAR_LISTENERS 27 | # - Clients MUST be able to connect to these hostnames 28 | # 29 | # Examples: 30 | # // Local LAN config, server behind NAT 31 | # LAN://192.168.1.10:5190 32 | # // Separate Internet and LAN config 33 | # WAN://aim.example.com:5190,LAN://192.168.1.10:5191 34 | export OSCAR_ADVERTISED_LISTENERS_PLAIN=LOCAL://127.0.0.1:5190 35 | 36 | # Same as OSCAR_ADVERTISED_LISTENERS_PLAIN, except the hostname is for the 37 | # server that terminates SSL. 38 | export OSCAR_ADVERTISED_LISTENERS_SSL=LOCAL://ras.dev:5193 39 | 40 | # Network listeners for Kerberos authentication. See OSCAR_LISTENERS doc for 41 | # more details. 42 | # 43 | # Examples: 44 | # // Listen on all interfaces 45 | # LAN://0.0.0.0:1088 46 | # // Separate Internet and LAN config 47 | # WAN://142.250.176.206:1088,LAN://192.168.1.10:1087 48 | export KERBEROS_LISTENERS=LOCAL://0.0.0.0:1088 49 | 50 | # Network listeners for TOC protocol service. 51 | # 52 | # Format: Comma-separated list of hostname:port pairs. 53 | # 54 | # Examples: 55 | # // All interfaces 56 | # 0.0.0.0:9898 57 | # // Multiple listeners 58 | # 0.0.0.0:9898,192.168.1.10:9899 59 | export TOC_LISTENERS=0.0.0.0:9898 60 | 61 | # Network listener for management API binds to. Only 1 listener can be 62 | # specified. (Default 127.0.0.1 restricts to same machine only). 63 | export API_LISTENER=127.0.0.1:8080 64 | 65 | # The path to the SQLite database file. The file and DB schema are auto-created 66 | # if they doesn't exist. 67 | export DB_PATH=oscar.sqlite 68 | 69 | # Disable password check and auto-create new users at login time. Useful for 70 | # quickly creating new accounts during development without having to register 71 | # new users via the management API. 72 | export DISABLE_AUTH=true 73 | 74 | # Set logging granularity. Possible values: 'trace', 'debug', 'info', 'warn', 75 | # 'error'. 76 | export LOG_LEVEL=info 77 | 78 | -------------------------------------------------------------------------------- /server/http/mock_chat_room_deleter_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v2.53.3. DO NOT EDIT. 2 | 3 | package http 4 | 5 | import ( 6 | context "context" 7 | 8 | mock "github.com/stretchr/testify/mock" 9 | ) 10 | 11 | // mockChatRoomDeleter is an autogenerated mock type for the ChatRoomDeleter type 12 | type mockChatRoomDeleter struct { 13 | mock.Mock 14 | } 15 | 16 | type mockChatRoomDeleter_Expecter struct { 17 | mock *mock.Mock 18 | } 19 | 20 | func (_m *mockChatRoomDeleter) EXPECT() *mockChatRoomDeleter_Expecter { 21 | return &mockChatRoomDeleter_Expecter{mock: &_m.Mock} 22 | } 23 | 24 | // DeleteChatRooms provides a mock function with given fields: ctx, exchange, names 25 | func (_m *mockChatRoomDeleter) DeleteChatRooms(ctx context.Context, exchange uint16, names []string) error { 26 | ret := _m.Called(ctx, exchange, names) 27 | 28 | if len(ret) == 0 { 29 | panic("no return value specified for DeleteChatRooms") 30 | } 31 | 32 | var r0 error 33 | if rf, ok := ret.Get(0).(func(context.Context, uint16, []string) error); ok { 34 | r0 = rf(ctx, exchange, names) 35 | } else { 36 | r0 = ret.Error(0) 37 | } 38 | 39 | return r0 40 | } 41 | 42 | // mockChatRoomDeleter_DeleteChatRooms_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteChatRooms' 43 | type mockChatRoomDeleter_DeleteChatRooms_Call struct { 44 | *mock.Call 45 | } 46 | 47 | // DeleteChatRooms is a helper method to define mock.On call 48 | // - ctx context.Context 49 | // - exchange uint16 50 | // - names []string 51 | func (_e *mockChatRoomDeleter_Expecter) DeleteChatRooms(ctx interface{}, exchange interface{}, names interface{}) *mockChatRoomDeleter_DeleteChatRooms_Call { 52 | return &mockChatRoomDeleter_DeleteChatRooms_Call{Call: _e.mock.On("DeleteChatRooms", ctx, exchange, names)} 53 | } 54 | 55 | func (_c *mockChatRoomDeleter_DeleteChatRooms_Call) Run(run func(ctx context.Context, exchange uint16, names []string)) *mockChatRoomDeleter_DeleteChatRooms_Call { 56 | _c.Call.Run(func(args mock.Arguments) { 57 | run(args[0].(context.Context), args[1].(uint16), args[2].([]string)) 58 | }) 59 | return _c 60 | } 61 | 62 | func (_c *mockChatRoomDeleter_DeleteChatRooms_Call) Return(_a0 error) *mockChatRoomDeleter_DeleteChatRooms_Call { 63 | _c.Call.Return(_a0) 64 | return _c 65 | } 66 | 67 | func (_c *mockChatRoomDeleter_DeleteChatRooms_Call) RunAndReturn(run func(context.Context, uint16, []string) error) *mockChatRoomDeleter_DeleteChatRooms_Call { 68 | _c.Call.Return(run) 69 | return _c 70 | } 71 | 72 | // newMockChatRoomDeleter creates a new instance of mockChatRoomDeleter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 73 | // The first argument is typically a *testing.T value. 74 | func newMockChatRoomDeleter(t interface { 75 | mock.TestingT 76 | Cleanup(func()) 77 | }) *mockChatRoomDeleter { 78 | mock := &mockChatRoomDeleter{} 79 | mock.Mock.Test(t) 80 | 81 | t.Cleanup(func() { mock.AssertExpectations(t) }) 82 | 83 | return mock 84 | } 85 | -------------------------------------------------------------------------------- /server/http/mock_chat_session_retriever_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package http 6 | 7 | import ( 8 | "github.com/mk6i/retro-aim-server/state" 9 | mock "github.com/stretchr/testify/mock" 10 | ) 11 | 12 | // newMockChatSessionRetriever creates a new instance of mockChatSessionRetriever. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 13 | // The first argument is typically a *testing.T value. 14 | func newMockChatSessionRetriever(t interface { 15 | mock.TestingT 16 | Cleanup(func()) 17 | }) *mockChatSessionRetriever { 18 | mock := &mockChatSessionRetriever{} 19 | mock.Mock.Test(t) 20 | 21 | t.Cleanup(func() { mock.AssertExpectations(t) }) 22 | 23 | return mock 24 | } 25 | 26 | // mockChatSessionRetriever is an autogenerated mock type for the ChatSessionRetriever type 27 | type mockChatSessionRetriever struct { 28 | mock.Mock 29 | } 30 | 31 | type mockChatSessionRetriever_Expecter struct { 32 | mock *mock.Mock 33 | } 34 | 35 | func (_m *mockChatSessionRetriever) EXPECT() *mockChatSessionRetriever_Expecter { 36 | return &mockChatSessionRetriever_Expecter{mock: &_m.Mock} 37 | } 38 | 39 | // AllSessions provides a mock function for the type mockChatSessionRetriever 40 | func (_mock *mockChatSessionRetriever) AllSessions(cookie string) []*state.Session { 41 | ret := _mock.Called(cookie) 42 | 43 | if len(ret) == 0 { 44 | panic("no return value specified for AllSessions") 45 | } 46 | 47 | var r0 []*state.Session 48 | if returnFunc, ok := ret.Get(0).(func(string) []*state.Session); ok { 49 | r0 = returnFunc(cookie) 50 | } else { 51 | if ret.Get(0) != nil { 52 | r0 = ret.Get(0).([]*state.Session) 53 | } 54 | } 55 | return r0 56 | } 57 | 58 | // mockChatSessionRetriever_AllSessions_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AllSessions' 59 | type mockChatSessionRetriever_AllSessions_Call struct { 60 | *mock.Call 61 | } 62 | 63 | // AllSessions is a helper method to define mock.On call 64 | // - cookie string 65 | func (_e *mockChatSessionRetriever_Expecter) AllSessions(cookie interface{}) *mockChatSessionRetriever_AllSessions_Call { 66 | return &mockChatSessionRetriever_AllSessions_Call{Call: _e.mock.On("AllSessions", cookie)} 67 | } 68 | 69 | func (_c *mockChatSessionRetriever_AllSessions_Call) Run(run func(cookie string)) *mockChatSessionRetriever_AllSessions_Call { 70 | _c.Call.Run(func(args mock.Arguments) { 71 | var arg0 string 72 | if args[0] != nil { 73 | arg0 = args[0].(string) 74 | } 75 | run( 76 | arg0, 77 | ) 78 | }) 79 | return _c 80 | } 81 | 82 | func (_c *mockChatSessionRetriever_AllSessions_Call) Return(sessions []*state.Session) *mockChatSessionRetriever_AllSessions_Call { 83 | _c.Call.Return(sessions) 84 | return _c 85 | } 86 | 87 | func (_c *mockChatSessionRetriever_AllSessions_Call) RunAndReturn(run func(cookie string) []*state.Session) *mockChatSessionRetriever_AllSessions_Call { 88 | _c.Call.Return(run) 89 | return _c 90 | } 91 | -------------------------------------------------------------------------------- /foodgroup/mock_session_retriever_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package foodgroup 6 | 7 | import ( 8 | "github.com/mk6i/retro-aim-server/state" 9 | mock "github.com/stretchr/testify/mock" 10 | ) 11 | 12 | // newMockSessionRetriever creates a new instance of mockSessionRetriever. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 13 | // The first argument is typically a *testing.T value. 14 | func newMockSessionRetriever(t interface { 15 | mock.TestingT 16 | Cleanup(func()) 17 | }) *mockSessionRetriever { 18 | mock := &mockSessionRetriever{} 19 | mock.Mock.Test(t) 20 | 21 | t.Cleanup(func() { mock.AssertExpectations(t) }) 22 | 23 | return mock 24 | } 25 | 26 | // mockSessionRetriever is an autogenerated mock type for the SessionRetriever type 27 | type mockSessionRetriever struct { 28 | mock.Mock 29 | } 30 | 31 | type mockSessionRetriever_Expecter struct { 32 | mock *mock.Mock 33 | } 34 | 35 | func (_m *mockSessionRetriever) EXPECT() *mockSessionRetriever_Expecter { 36 | return &mockSessionRetriever_Expecter{mock: &_m.Mock} 37 | } 38 | 39 | // RetrieveSession provides a mock function for the type mockSessionRetriever 40 | func (_mock *mockSessionRetriever) RetrieveSession(screenName state.IdentScreenName) *state.Session { 41 | ret := _mock.Called(screenName) 42 | 43 | if len(ret) == 0 { 44 | panic("no return value specified for RetrieveSession") 45 | } 46 | 47 | var r0 *state.Session 48 | if returnFunc, ok := ret.Get(0).(func(state.IdentScreenName) *state.Session); ok { 49 | r0 = returnFunc(screenName) 50 | } else { 51 | if ret.Get(0) != nil { 52 | r0 = ret.Get(0).(*state.Session) 53 | } 54 | } 55 | return r0 56 | } 57 | 58 | // mockSessionRetriever_RetrieveSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RetrieveSession' 59 | type mockSessionRetriever_RetrieveSession_Call struct { 60 | *mock.Call 61 | } 62 | 63 | // RetrieveSession is a helper method to define mock.On call 64 | // - screenName state.IdentScreenName 65 | func (_e *mockSessionRetriever_Expecter) RetrieveSession(screenName interface{}) *mockSessionRetriever_RetrieveSession_Call { 66 | return &mockSessionRetriever_RetrieveSession_Call{Call: _e.mock.On("RetrieveSession", screenName)} 67 | } 68 | 69 | func (_c *mockSessionRetriever_RetrieveSession_Call) Run(run func(screenName state.IdentScreenName)) *mockSessionRetriever_RetrieveSession_Call { 70 | _c.Call.Run(func(args mock.Arguments) { 71 | var arg0 state.IdentScreenName 72 | if args[0] != nil { 73 | arg0 = args[0].(state.IdentScreenName) 74 | } 75 | run( 76 | arg0, 77 | ) 78 | }) 79 | return _c 80 | } 81 | 82 | func (_c *mockSessionRetriever_RetrieveSession_Call) Return(session *state.Session) *mockSessionRetriever_RetrieveSession_Call { 83 | _c.Call.Return(session) 84 | return _c 85 | } 86 | 87 | func (_c *mockSessionRetriever_RetrieveSession_Call) RunAndReturn(run func(screenName state.IdentScreenName) *state.Session) *mockSessionRetriever_RetrieveSession_Call { 88 | _c.Call.Return(run) 89 | return _c 90 | } 91 | -------------------------------------------------------------------------------- /server/http/mock_chat_room_creator_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package http 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/mk6i/retro-aim-server/state" 11 | mock "github.com/stretchr/testify/mock" 12 | ) 13 | 14 | // newMockChatRoomCreator creates a new instance of mockChatRoomCreator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 15 | // The first argument is typically a *testing.T value. 16 | func newMockChatRoomCreator(t interface { 17 | mock.TestingT 18 | Cleanup(func()) 19 | }) *mockChatRoomCreator { 20 | mock := &mockChatRoomCreator{} 21 | mock.Mock.Test(t) 22 | 23 | t.Cleanup(func() { mock.AssertExpectations(t) }) 24 | 25 | return mock 26 | } 27 | 28 | // mockChatRoomCreator is an autogenerated mock type for the ChatRoomCreator type 29 | type mockChatRoomCreator struct { 30 | mock.Mock 31 | } 32 | 33 | type mockChatRoomCreator_Expecter struct { 34 | mock *mock.Mock 35 | } 36 | 37 | func (_m *mockChatRoomCreator) EXPECT() *mockChatRoomCreator_Expecter { 38 | return &mockChatRoomCreator_Expecter{mock: &_m.Mock} 39 | } 40 | 41 | // CreateChatRoom provides a mock function for the type mockChatRoomCreator 42 | func (_mock *mockChatRoomCreator) CreateChatRoom(ctx context.Context, chatRoom *state.ChatRoom) error { 43 | ret := _mock.Called(ctx, chatRoom) 44 | 45 | if len(ret) == 0 { 46 | panic("no return value specified for CreateChatRoom") 47 | } 48 | 49 | var r0 error 50 | if returnFunc, ok := ret.Get(0).(func(context.Context, *state.ChatRoom) error); ok { 51 | r0 = returnFunc(ctx, chatRoom) 52 | } else { 53 | r0 = ret.Error(0) 54 | } 55 | return r0 56 | } 57 | 58 | // mockChatRoomCreator_CreateChatRoom_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateChatRoom' 59 | type mockChatRoomCreator_CreateChatRoom_Call struct { 60 | *mock.Call 61 | } 62 | 63 | // CreateChatRoom is a helper method to define mock.On call 64 | // - ctx context.Context 65 | // - chatRoom *state.ChatRoom 66 | func (_e *mockChatRoomCreator_Expecter) CreateChatRoom(ctx interface{}, chatRoom interface{}) *mockChatRoomCreator_CreateChatRoom_Call { 67 | return &mockChatRoomCreator_CreateChatRoom_Call{Call: _e.mock.On("CreateChatRoom", ctx, chatRoom)} 68 | } 69 | 70 | func (_c *mockChatRoomCreator_CreateChatRoom_Call) Run(run func(ctx context.Context, chatRoom *state.ChatRoom)) *mockChatRoomCreator_CreateChatRoom_Call { 71 | _c.Call.Run(func(args mock.Arguments) { 72 | var arg0 context.Context 73 | if args[0] != nil { 74 | arg0 = args[0].(context.Context) 75 | } 76 | var arg1 *state.ChatRoom 77 | if args[1] != nil { 78 | arg1 = args[1].(*state.ChatRoom) 79 | } 80 | run( 81 | arg0, 82 | arg1, 83 | ) 84 | }) 85 | return _c 86 | } 87 | 88 | func (_c *mockChatRoomCreator_CreateChatRoom_Call) Return(err error) *mockChatRoomCreator_CreateChatRoom_Call { 89 | _c.Call.Return(err) 90 | return _c 91 | } 92 | 93 | func (_c *mockChatRoomCreator_CreateChatRoom_Call) RunAndReturn(run func(ctx context.Context, chatRoom *state.ChatRoom) error) *mockChatRoomCreator_CreateChatRoom_Call { 94 | _c.Call.Return(run) 95 | return _c 96 | } 97 | -------------------------------------------------------------------------------- /server/http/mock_message_relayer_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package http 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/mk6i/retro-aim-server/state" 11 | "github.com/mk6i/retro-aim-server/wire" 12 | mock "github.com/stretchr/testify/mock" 13 | ) 14 | 15 | // newMockMessageRelayer creates a new instance of mockMessageRelayer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 16 | // The first argument is typically a *testing.T value. 17 | func newMockMessageRelayer(t interface { 18 | mock.TestingT 19 | Cleanup(func()) 20 | }) *mockMessageRelayer { 21 | mock := &mockMessageRelayer{} 22 | mock.Mock.Test(t) 23 | 24 | t.Cleanup(func() { mock.AssertExpectations(t) }) 25 | 26 | return mock 27 | } 28 | 29 | // mockMessageRelayer is an autogenerated mock type for the MessageRelayer type 30 | type mockMessageRelayer struct { 31 | mock.Mock 32 | } 33 | 34 | type mockMessageRelayer_Expecter struct { 35 | mock *mock.Mock 36 | } 37 | 38 | func (_m *mockMessageRelayer) EXPECT() *mockMessageRelayer_Expecter { 39 | return &mockMessageRelayer_Expecter{mock: &_m.Mock} 40 | } 41 | 42 | // RelayToScreenName provides a mock function for the type mockMessageRelayer 43 | func (_mock *mockMessageRelayer) RelayToScreenName(ctx context.Context, screenName state.IdentScreenName, msg wire.SNACMessage) { 44 | _mock.Called(ctx, screenName, msg) 45 | return 46 | } 47 | 48 | // mockMessageRelayer_RelayToScreenName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RelayToScreenName' 49 | type mockMessageRelayer_RelayToScreenName_Call struct { 50 | *mock.Call 51 | } 52 | 53 | // RelayToScreenName is a helper method to define mock.On call 54 | // - ctx context.Context 55 | // - screenName state.IdentScreenName 56 | // - msg wire.SNACMessage 57 | func (_e *mockMessageRelayer_Expecter) RelayToScreenName(ctx interface{}, screenName interface{}, msg interface{}) *mockMessageRelayer_RelayToScreenName_Call { 58 | return &mockMessageRelayer_RelayToScreenName_Call{Call: _e.mock.On("RelayToScreenName", ctx, screenName, msg)} 59 | } 60 | 61 | func (_c *mockMessageRelayer_RelayToScreenName_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName, msg wire.SNACMessage)) *mockMessageRelayer_RelayToScreenName_Call { 62 | _c.Call.Run(func(args mock.Arguments) { 63 | var arg0 context.Context 64 | if args[0] != nil { 65 | arg0 = args[0].(context.Context) 66 | } 67 | var arg1 state.IdentScreenName 68 | if args[1] != nil { 69 | arg1 = args[1].(state.IdentScreenName) 70 | } 71 | var arg2 wire.SNACMessage 72 | if args[2] != nil { 73 | arg2 = args[2].(wire.SNACMessage) 74 | } 75 | run( 76 | arg0, 77 | arg1, 78 | arg2, 79 | ) 80 | }) 81 | return _c 82 | } 83 | 84 | func (_c *mockMessageRelayer_RelayToScreenName_Call) Return() *mockMessageRelayer_RelayToScreenName_Call { 85 | _c.Call.Return() 86 | return _c 87 | } 88 | 89 | func (_c *mockMessageRelayer_RelayToScreenName_Call) RunAndReturn(run func(ctx context.Context, screenName state.IdentScreenName, msg wire.SNACMessage)) *mockMessageRelayer_RelayToScreenName_Call { 90 | _c.Run(run) 91 | return _c 92 | } 93 | -------------------------------------------------------------------------------- /state/migrations/0022_web_chat_rooms.up.sql: -------------------------------------------------------------------------------- 1 | -- Migration 0021: Web API Chat Rooms Support 2 | -- This migration adds tables for Web API chat room functionality 3 | 4 | -- Chat rooms table 5 | CREATE TABLE IF NOT EXISTS web_chat_rooms ( 6 | room_id VARCHAR(255) PRIMARY KEY, 7 | room_name VARCHAR(255) NOT NULL, 8 | description TEXT, 9 | room_type VARCHAR(50) DEFAULT 'userCreated', 10 | category_id VARCHAR(50), 11 | creator_screen_name VARCHAR(16) NOT NULL, 12 | created_at INTEGER NOT NULL, 13 | closed_at INTEGER, 14 | max_participants INTEGER DEFAULT 100 15 | ); 16 | 17 | -- Create indexes for web_chat_rooms table 18 | CREATE INDEX IF NOT EXISTS idx_web_chat_rooms_name ON web_chat_rooms(room_name); 19 | CREATE INDEX IF NOT EXISTS idx_web_chat_rooms_creator ON web_chat_rooms(creator_screen_name); 20 | CREATE INDEX IF NOT EXISTS idx_web_chat_rooms_created ON web_chat_rooms(created_at); 21 | CREATE INDEX IF NOT EXISTS idx_web_chat_rooms_closed ON web_chat_rooms(closed_at); 22 | 23 | -- Chat sessions table (maps users to chat rooms) 24 | CREATE TABLE IF NOT EXISTS web_chat_sessions ( 25 | chat_sid VARCHAR(255) PRIMARY KEY, 26 | aimsid VARCHAR(255) NOT NULL, 27 | room_id VARCHAR(255) NOT NULL, 28 | screen_name VARCHAR(16) NOT NULL, 29 | instance_id INTEGER NOT NULL, 30 | joined_at INTEGER NOT NULL, 31 | left_at INTEGER, 32 | FOREIGN KEY (room_id) REFERENCES web_chat_rooms(room_id) ON DELETE CASCADE 33 | ); 34 | 35 | -- Create indexes for web_chat_sessions table 36 | CREATE INDEX IF NOT EXISTS idx_web_chat_sessions_aimsid ON web_chat_sessions(aimsid); 37 | CREATE INDEX IF NOT EXISTS idx_web_chat_sessions_room ON web_chat_sessions(room_id); 38 | CREATE INDEX IF NOT EXISTS idx_web_chat_sessions_user ON web_chat_sessions(screen_name); 39 | CREATE INDEX IF NOT EXISTS idx_web_chat_sessions_joined ON web_chat_sessions(joined_at); 40 | 41 | -- Chat messages table 42 | CREATE TABLE IF NOT EXISTS web_chat_messages ( 43 | id INTEGER PRIMARY KEY AUTOINCREMENT, 44 | room_id VARCHAR(255) NOT NULL, 45 | screen_name VARCHAR(16) NOT NULL, 46 | message TEXT NOT NULL, 47 | whisper_target VARCHAR(16), 48 | timestamp INTEGER NOT NULL, 49 | FOREIGN KEY (room_id) REFERENCES web_chat_rooms(room_id) ON DELETE CASCADE 50 | ); 51 | 52 | -- Create indexes for web_chat_messages table 53 | CREATE INDEX IF NOT EXISTS idx_web_chat_messages_room ON web_chat_messages(room_id); 54 | CREATE INDEX IF NOT EXISTS idx_web_chat_messages_timestamp ON web_chat_messages(timestamp); 55 | CREATE INDEX IF NOT EXISTS idx_web_chat_messages_user ON web_chat_messages(screen_name); 56 | 57 | -- Chat participants table (current participants in each room) 58 | CREATE TABLE IF NOT EXISTS web_chat_participants ( 59 | room_id VARCHAR(255) NOT NULL, 60 | screen_name VARCHAR(16) NOT NULL, 61 | chat_sid VARCHAR(255) NOT NULL, 62 | joined_at INTEGER NOT NULL, 63 | typing_status VARCHAR(20) DEFAULT 'none', 64 | typing_updated_at INTEGER, 65 | PRIMARY KEY (room_id, screen_name), 66 | FOREIGN KEY (room_id) REFERENCES web_chat_rooms(room_id) ON DELETE CASCADE 67 | ); 68 | 69 | -- Create indexes for web_chat_participants table 70 | CREATE INDEX IF NOT EXISTS idx_web_chat_participants_room ON web_chat_participants(room_id); 71 | CREATE INDEX IF NOT EXISTS idx_web_chat_participants_user ON web_chat_participants(screen_name); 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | # Build & release helpers 3 | ################################################################################ 4 | 5 | DOCKER_IMAGE_TAG_GO_RELEASER := goreleaser/goreleaser:v2.13.1 6 | DOCKER_RUN_GO_RELEASER := @docker run \ 7 | --env CGO_ENABLED=0 \ 8 | --env GITHUB_TOKEN=$(GITHUB_TOKEN) \ 9 | --rm \ 10 | --volume `pwd`:/go/src/retro-aim-server \ 11 | --workdir /go/src/retro-aim-server \ 12 | $(DOCKER_IMAGE_TAG_GO_RELEASER) 13 | OSCAR_HOST ?= ras.dev 14 | 15 | .PHONY: config-basic config-ssl config 16 | config-basic: ## Generate basic config file template 17 | go run ./cmd/config_generator unix config/settings.env basic 18 | 19 | config-ssl: ## Generate SSL config file template 20 | go run ./cmd/config_generator unix config/ssl/settings.env ssl 21 | 22 | config: config-basic config-ssl ## Generate all config file templates from Config struct 23 | 24 | .PHONY: release 25 | release: ## Run a clean, full GoReleaser run (publish + validate) 26 | $(DOCKER_RUN_GO_RELEASER) --clean 27 | 28 | .PHONY: release-dry-run 29 | release-dry-run: ## GoReleaser dry-run (skips validate & publish) 30 | $(DOCKER_RUN_GO_RELEASER) --clean --skip=validate --skip=publish 31 | 32 | .PHONY: docker-image-ras 33 | docker-image-ras: ## Build Open OSCAR Server image 34 | docker build -t ras:latest -f Dockerfile . 35 | 36 | .PHONY: docker-image-stunnel 37 | docker-image-stunnel: ## Build stunnel image pinned to v5.75 / OpenSSL 1.0.2u 38 | docker build -t ras-stunnel:5.75-openssl-1.0.2u -f Dockerfile.stunnel . 39 | 40 | .PHONY: docker-image-certgen 41 | docker-image-certgen: ## Build minimal helper image with openssl & nss tools 42 | docker build -t ras-certgen:latest -f Dockerfile.certgen . 43 | 44 | .PHONY: docker-images 45 | docker-images: docker-image-ras docker-image-stunnel docker-image-certgen 46 | 47 | .PHONY: docker-run 48 | docker-run: 49 | OSCAR_HOST=$(OSCAR_HOST) docker compose up retro-aim-server stunnel 50 | 51 | .PHONY: docker-run-bg 52 | docker-run-bg: ## Run Open OSCAR Server in background with docker-compose 53 | OSCAR_HOST=$(OSCAR_HOST) docker compose up -d retro-aim-server stunnel 54 | 55 | .PHONY: docker-run-stop 56 | docker-run-stop: ## Stop Open OSCAR Server docker-compose services 57 | OSCAR_HOST=$(OSCAR_HOST) docker compose down 58 | 59 | ################################################################################ 60 | # SSL Helpers 61 | ################################################################################ 62 | 63 | .PHONY: docker-cert 64 | docker-cert: clean-certs ## Create SSL certificates for server 65 | mkdir -p certs/ 66 | OSCAR_HOST=$(OSCAR_HOST) docker compose run --no-TTY --rm cert-gen 67 | 68 | .PHONY: docker-nss 69 | docker-nss: ## Create NSS certificate database for AIM 6.x clients 70 | OSCAR_HOST=$(OSCAR_HOST) docker compose run --no-TTY --rm nss-gen 71 | 72 | .PHONY: clean-certs 73 | clean-certs: ## Remove all generated certificates & NSS DB 74 | rm -rf certs/* 75 | 76 | ################################################################################ 77 | # Web API Tools 78 | ################################################################################ 79 | 80 | .PHONY: webapi-keygen 81 | webapi-keygen: ## Build the Web API key generator tool 82 | go build -o webapi_keygen ./cmd/webapi_keygen 83 | 84 | .PHONY: webapi-keygen-install 85 | webapi-keygen-install: ## Install the Web API key generator tool system-wide 86 | go install ./cmd/webapi_keygen 87 | -------------------------------------------------------------------------------- /docs/WINDOWS.md: -------------------------------------------------------------------------------- 1 | # Open OSCAR Server Quickstart for Windows 10/11 2 | 3 | This guide explains how to download, configure and run Open OSCAR Server on Windows 10/11. 4 | 5 | 1. **Download Open OSCAR Server** 6 | 7 | Download the latest Windows release from the [Releases page](https://github.com/mk6i/open-oscar-server/releases) and 8 | extract the `.zip` archive, which contains the application and a configuration file `settings.env`. 9 | 10 | 2. **Configure Server Address** 11 | 12 | Open `settings.env` (right-click, `edit in notepad`) and set the default listener in `OSCAR_ADVERTISED_LISTENERS_PLAIN` to 13 | a hostname and port that the AIM clients can connect 14 | to. If you are running the AIM client and server on the same machine, you don't need to change the default value. 15 | 16 | The format is `[NAME]://[HOSTNAME]:[PORT]` where: 17 | - `LOCAL` is the listener name (can be any name you choose, as long as it matches the `OSCAR_LISTENERS` config) 18 | - `127.0.0.1` is the hostname clients connect to 19 | - `5190` is the port number clients connect to 20 | 21 | In order to connect AIM clients on your LAN (including VMs with bridged networking), you can find the appropriate IP 22 | address by running `ipconfig` from the Command Prompt and use that IP instead of `127.0.0.1`. 23 | 24 | 3. **Start the Application** 25 | 26 | Launch `open_oscar_server.exe` to start Open OSCAR Server. 27 | 28 | Because Open OSCAR Server has not built up enough reputation with Microsoft, Windows will flag the application as a 29 | security risk the first time you run it. You'll be presented with a `Microsoft Defender SmartScreen` warning prompt 30 | that gives you the option to run the blocked application. 31 | 32 | To proceed, click `More Options`, then `Run anyway`. 33 | 34 |

35 | screenshot of microsoft defender smartscreen prompt 36 | screenshot of microsoft defender smartscreen prompt 37 |

38 | 39 | Click `Allow` if you get a Windows Defender Firewall alert. 40 | 41 |

42 | screenshot of microsoft defender firewall alert 43 |

44 | 45 | Open OSCAR Server will open in a terminal, ready to accept AIM client connections. 46 | 47 | 4. **Test** 48 | 49 | To do a quick sanity check, start an AIM client, sign in to the server, and send yourself an instant message. 50 | Configure the AIM client to connect to the host and port from `OSCAR_ADVERTISED_LISTENERS_PLAIN` in `settings.env`. If 51 | using the default server setting, set host to `127.0.0.1` and port `5190`. 52 | 53 | See the [Client Configuration Guide](./CLIENT.md) for more detail on setting up the AIM client. 54 | 55 | By default, you can enter *any* screen name and password at the AIM sign-in screen to auto-create an account. 56 | 57 | > Account auto-creation is meant to be a convenience feature for local development. In a production deployment, you 58 | should set `DISABLE_AUTH=false` in `settings.env` to enforce account authentication. User accounts can be created via 59 | the [Management API](../README.md#-management-api). 60 | 61 | 5. **Additional Setup** 62 | 63 | For optional configuration steps that enhance your Open OSCAR Server experience, refer to 64 | the [Additional Setup Guide](./ADDITIONAL_SETUP.md). -------------------------------------------------------------------------------- /wire/snacs_test.go: -------------------------------------------------------------------------------- 1 | package wire 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestBARTInfo_HasClearIconHash(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | bartInfo BARTInfo 14 | want bool 15 | }{ 16 | { 17 | bartInfo: BARTInfo{ 18 | Hash: GetClearIconHash(), 19 | }, 20 | want: true, 21 | }, 22 | { 23 | bartInfo: BARTInfo{ 24 | Hash: []byte{'s', 'o', 'm', 'e', 'd', 'a', 't', 'a'}, 25 | }, 26 | want: false, 27 | }, 28 | } 29 | for _, tt := range tests { 30 | t.Run(tt.name, func(t *testing.T) { 31 | assert.Equal(t, tt.want, tt.bartInfo.HasClearIconHash()) 32 | }) 33 | } 34 | } 35 | 36 | func TestSNAC_0x01_0x14_OServiceSetPrivacyFlags_IdleFlag(t *testing.T) { 37 | type fields struct { 38 | PrivacyFlags uint32 39 | } 40 | tests := []struct { 41 | name string 42 | fields fields 43 | want bool 44 | }{ 45 | { 46 | name: "flag is set", 47 | fields: fields{ 48 | PrivacyFlags: OServicePrivacyFlagIdle | OServicePrivacyFlagMember, 49 | }, 50 | want: true, 51 | }, 52 | { 53 | name: "flag is not set", 54 | fields: fields{ 55 | PrivacyFlags: OServicePrivacyFlagMember, 56 | }, 57 | want: false, 58 | }, 59 | } 60 | for _, tt := range tests { 61 | t.Run(tt.name, func(t *testing.T) { 62 | s := SNAC_0x01_0x14_OServiceSetPrivacyFlags{ 63 | PrivacyFlags: tt.fields.PrivacyFlags, 64 | } 65 | assert.Equal(t, tt.want, s.IdleFlag()) 66 | }) 67 | } 68 | } 69 | 70 | func TestSNAC_0x01_0x14_OServiceSetPrivacyFlags_MemberFlag(t *testing.T) { 71 | type fields struct { 72 | PrivacyFlags uint32 73 | } 74 | tests := []struct { 75 | name string 76 | fields fields 77 | want bool 78 | }{ 79 | { 80 | name: "flag is set", 81 | fields: fields{ 82 | PrivacyFlags: OServicePrivacyFlagIdle | OServicePrivacyFlagMember, 83 | }, 84 | want: true, 85 | }, 86 | { 87 | name: "flag is not set", 88 | fields: fields{ 89 | PrivacyFlags: OServicePrivacyFlagIdle, 90 | }, 91 | want: false, 92 | }, 93 | } 94 | for _, tt := range tests { 95 | t.Run(tt.name, func(t *testing.T) { 96 | s := SNAC_0x01_0x14_OServiceSetPrivacyFlags{ 97 | PrivacyFlags: tt.fields.PrivacyFlags, 98 | } 99 | assert.Equal(t, tt.want, s.MemberFlag()) 100 | }) 101 | } 102 | } 103 | 104 | func TestUnmarshalChatMessageText(t *testing.T) { 105 | tests := []struct { 106 | name string 107 | b []byte 108 | want string 109 | wantErr string 110 | }{ 111 | { 112 | name: "happy path", 113 | b: func() []byte { 114 | tlv := TLVRestBlock{ 115 | TLVList: TLVList{ 116 | NewTLVBE(ChatTLVMessageInfoText, "

hello world!

"), 117 | }, 118 | } 119 | b := &bytes.Buffer{} 120 | err := MarshalBE(tlv, b) 121 | assert.NoError(t, err) 122 | return b.Bytes() 123 | }(), 124 | want: "

hello world!

", 125 | }, 126 | { 127 | name: "missing ChatTLVMessageInfoText", 128 | b: func() []byte { 129 | tlv := TLVRestBlock{TLVList: TLVList{}} 130 | b := &bytes.Buffer{} 131 | err := MarshalBE(tlv, b) 132 | assert.NoError(t, err) 133 | return b.Bytes() 134 | }(), 135 | wantErr: "has no chat msg text TLV", 136 | }, 137 | } 138 | for _, tt := range tests { 139 | t.Run(tt.name, func(t *testing.T) { 140 | got, err := UnmarshalChatMessageText(tt.b) 141 | if tt.wantErr != "" { 142 | assert.ErrorContains(t, err, tt.wantErr) 143 | } else { 144 | assert.NoError(t, err) 145 | assert.Equal(t, tt.want, got) 146 | } 147 | }) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /server/oscar/mock_stats_service_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package oscar 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/mk6i/retro-aim-server/wire" 11 | mock "github.com/stretchr/testify/mock" 12 | ) 13 | 14 | // newMockStatsService creates a new instance of mockStatsService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 15 | // The first argument is typically a *testing.T value. 16 | func newMockStatsService(t interface { 17 | mock.TestingT 18 | Cleanup(func()) 19 | }) *mockStatsService { 20 | mock := &mockStatsService{} 21 | mock.Mock.Test(t) 22 | 23 | t.Cleanup(func() { mock.AssertExpectations(t) }) 24 | 25 | return mock 26 | } 27 | 28 | // mockStatsService is an autogenerated mock type for the StatsService type 29 | type mockStatsService struct { 30 | mock.Mock 31 | } 32 | 33 | type mockStatsService_Expecter struct { 34 | mock *mock.Mock 35 | } 36 | 37 | func (_m *mockStatsService) EXPECT() *mockStatsService_Expecter { 38 | return &mockStatsService_Expecter{mock: &_m.Mock} 39 | } 40 | 41 | // ReportEvents provides a mock function for the type mockStatsService 42 | func (_mock *mockStatsService) ReportEvents(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0B_0x03_StatsReportEvents) wire.SNACMessage { 43 | ret := _mock.Called(ctx, inFrame, inBody) 44 | 45 | if len(ret) == 0 { 46 | panic("no return value specified for ReportEvents") 47 | } 48 | 49 | var r0 wire.SNACMessage 50 | if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0B_0x03_StatsReportEvents) wire.SNACMessage); ok { 51 | r0 = returnFunc(ctx, inFrame, inBody) 52 | } else { 53 | r0 = ret.Get(0).(wire.SNACMessage) 54 | } 55 | return r0 56 | } 57 | 58 | // mockStatsService_ReportEvents_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReportEvents' 59 | type mockStatsService_ReportEvents_Call struct { 60 | *mock.Call 61 | } 62 | 63 | // ReportEvents is a helper method to define mock.On call 64 | // - ctx context.Context 65 | // - inFrame wire.SNACFrame 66 | // - inBody wire.SNAC_0x0B_0x03_StatsReportEvents 67 | func (_e *mockStatsService_Expecter) ReportEvents(ctx interface{}, inFrame interface{}, inBody interface{}) *mockStatsService_ReportEvents_Call { 68 | return &mockStatsService_ReportEvents_Call{Call: _e.mock.On("ReportEvents", ctx, inFrame, inBody)} 69 | } 70 | 71 | func (_c *mockStatsService_ReportEvents_Call) Run(run func(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0B_0x03_StatsReportEvents)) *mockStatsService_ReportEvents_Call { 72 | _c.Call.Run(func(args mock.Arguments) { 73 | var arg0 context.Context 74 | if args[0] != nil { 75 | arg0 = args[0].(context.Context) 76 | } 77 | var arg1 wire.SNACFrame 78 | if args[1] != nil { 79 | arg1 = args[1].(wire.SNACFrame) 80 | } 81 | var arg2 wire.SNAC_0x0B_0x03_StatsReportEvents 82 | if args[2] != nil { 83 | arg2 = args[2].(wire.SNAC_0x0B_0x03_StatsReportEvents) 84 | } 85 | run( 86 | arg0, 87 | arg1, 88 | arg2, 89 | ) 90 | }) 91 | return _c 92 | } 93 | 94 | func (_c *mockStatsService_ReportEvents_Call) Return(sNACMessage wire.SNACMessage) *mockStatsService_ReportEvents_Call { 95 | _c.Call.Return(sNACMessage) 96 | return _c 97 | } 98 | 99 | func (_c *mockStatsService_ReportEvents_Call) RunAndReturn(run func(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0B_0x03_StatsReportEvents) wire.SNACMessage) *mockStatsService_ReportEvents_Call { 100 | _c.Call.Return(run) 101 | return _c 102 | } 103 | -------------------------------------------------------------------------------- /server/oscar/mock_rate_limit_updater_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package oscar 6 | 7 | import ( 8 | "context" 9 | "time" 10 | 11 | "github.com/mk6i/retro-aim-server/state" 12 | "github.com/mk6i/retro-aim-server/wire" 13 | mock "github.com/stretchr/testify/mock" 14 | ) 15 | 16 | // newMockRateLimitUpdater creates a new instance of mockRateLimitUpdater. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 17 | // The first argument is typically a *testing.T value. 18 | func newMockRateLimitUpdater(t interface { 19 | mock.TestingT 20 | Cleanup(func()) 21 | }) *mockRateLimitUpdater { 22 | mock := &mockRateLimitUpdater{} 23 | mock.Mock.Test(t) 24 | 25 | t.Cleanup(func() { mock.AssertExpectations(t) }) 26 | 27 | return mock 28 | } 29 | 30 | // mockRateLimitUpdater is an autogenerated mock type for the RateLimitUpdater type 31 | type mockRateLimitUpdater struct { 32 | mock.Mock 33 | } 34 | 35 | type mockRateLimitUpdater_Expecter struct { 36 | mock *mock.Mock 37 | } 38 | 39 | func (_m *mockRateLimitUpdater) EXPECT() *mockRateLimitUpdater_Expecter { 40 | return &mockRateLimitUpdater_Expecter{mock: &_m.Mock} 41 | } 42 | 43 | // RateLimitUpdates provides a mock function for the type mockRateLimitUpdater 44 | func (_mock *mockRateLimitUpdater) RateLimitUpdates(ctx context.Context, sess *state.Session, now time.Time) []wire.SNACMessage { 45 | ret := _mock.Called(ctx, sess, now) 46 | 47 | if len(ret) == 0 { 48 | panic("no return value specified for RateLimitUpdates") 49 | } 50 | 51 | var r0 []wire.SNACMessage 52 | if returnFunc, ok := ret.Get(0).(func(context.Context, *state.Session, time.Time) []wire.SNACMessage); ok { 53 | r0 = returnFunc(ctx, sess, now) 54 | } else { 55 | if ret.Get(0) != nil { 56 | r0 = ret.Get(0).([]wire.SNACMessage) 57 | } 58 | } 59 | return r0 60 | } 61 | 62 | // mockRateLimitUpdater_RateLimitUpdates_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RateLimitUpdates' 63 | type mockRateLimitUpdater_RateLimitUpdates_Call struct { 64 | *mock.Call 65 | } 66 | 67 | // RateLimitUpdates is a helper method to define mock.On call 68 | // - ctx context.Context 69 | // - sess *state.Session 70 | // - now time.Time 71 | func (_e *mockRateLimitUpdater_Expecter) RateLimitUpdates(ctx interface{}, sess interface{}, now interface{}) *mockRateLimitUpdater_RateLimitUpdates_Call { 72 | return &mockRateLimitUpdater_RateLimitUpdates_Call{Call: _e.mock.On("RateLimitUpdates", ctx, sess, now)} 73 | } 74 | 75 | func (_c *mockRateLimitUpdater_RateLimitUpdates_Call) Run(run func(ctx context.Context, sess *state.Session, now time.Time)) *mockRateLimitUpdater_RateLimitUpdates_Call { 76 | _c.Call.Run(func(args mock.Arguments) { 77 | var arg0 context.Context 78 | if args[0] != nil { 79 | arg0 = args[0].(context.Context) 80 | } 81 | var arg1 *state.Session 82 | if args[1] != nil { 83 | arg1 = args[1].(*state.Session) 84 | } 85 | var arg2 time.Time 86 | if args[2] != nil { 87 | arg2 = args[2].(time.Time) 88 | } 89 | run( 90 | arg0, 91 | arg1, 92 | arg2, 93 | ) 94 | }) 95 | return _c 96 | } 97 | 98 | func (_c *mockRateLimitUpdater_RateLimitUpdates_Call) Return(sNACMessages []wire.SNACMessage) *mockRateLimitUpdater_RateLimitUpdates_Call { 99 | _c.Call.Return(sNACMessages) 100 | return _c 101 | } 102 | 103 | func (_c *mockRateLimitUpdater_RateLimitUpdates_Call) RunAndReturn(run func(ctx context.Context, sess *state.Session, now time.Time) []wire.SNACMessage) *mockRateLimitUpdater_RateLimitUpdates_Call { 104 | _c.Call.Return(run) 105 | return _c 106 | } 107 | -------------------------------------------------------------------------------- /server/http/mock_chat_room_retriever_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package http 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/mk6i/retro-aim-server/state" 11 | mock "github.com/stretchr/testify/mock" 12 | ) 13 | 14 | // newMockChatRoomRetriever creates a new instance of mockChatRoomRetriever. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 15 | // The first argument is typically a *testing.T value. 16 | func newMockChatRoomRetriever(t interface { 17 | mock.TestingT 18 | Cleanup(func()) 19 | }) *mockChatRoomRetriever { 20 | mock := &mockChatRoomRetriever{} 21 | mock.Mock.Test(t) 22 | 23 | t.Cleanup(func() { mock.AssertExpectations(t) }) 24 | 25 | return mock 26 | } 27 | 28 | // mockChatRoomRetriever is an autogenerated mock type for the ChatRoomRetriever type 29 | type mockChatRoomRetriever struct { 30 | mock.Mock 31 | } 32 | 33 | type mockChatRoomRetriever_Expecter struct { 34 | mock *mock.Mock 35 | } 36 | 37 | func (_m *mockChatRoomRetriever) EXPECT() *mockChatRoomRetriever_Expecter { 38 | return &mockChatRoomRetriever_Expecter{mock: &_m.Mock} 39 | } 40 | 41 | // AllChatRooms provides a mock function for the type mockChatRoomRetriever 42 | func (_mock *mockChatRoomRetriever) AllChatRooms(ctx context.Context, exchange uint16) ([]state.ChatRoom, error) { 43 | ret := _mock.Called(ctx, exchange) 44 | 45 | if len(ret) == 0 { 46 | panic("no return value specified for AllChatRooms") 47 | } 48 | 49 | var r0 []state.ChatRoom 50 | var r1 error 51 | if returnFunc, ok := ret.Get(0).(func(context.Context, uint16) ([]state.ChatRoom, error)); ok { 52 | return returnFunc(ctx, exchange) 53 | } 54 | if returnFunc, ok := ret.Get(0).(func(context.Context, uint16) []state.ChatRoom); ok { 55 | r0 = returnFunc(ctx, exchange) 56 | } else { 57 | if ret.Get(0) != nil { 58 | r0 = ret.Get(0).([]state.ChatRoom) 59 | } 60 | } 61 | if returnFunc, ok := ret.Get(1).(func(context.Context, uint16) error); ok { 62 | r1 = returnFunc(ctx, exchange) 63 | } else { 64 | r1 = ret.Error(1) 65 | } 66 | return r0, r1 67 | } 68 | 69 | // mockChatRoomRetriever_AllChatRooms_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AllChatRooms' 70 | type mockChatRoomRetriever_AllChatRooms_Call struct { 71 | *mock.Call 72 | } 73 | 74 | // AllChatRooms is a helper method to define mock.On call 75 | // - ctx context.Context 76 | // - exchange uint16 77 | func (_e *mockChatRoomRetriever_Expecter) AllChatRooms(ctx interface{}, exchange interface{}) *mockChatRoomRetriever_AllChatRooms_Call { 78 | return &mockChatRoomRetriever_AllChatRooms_Call{Call: _e.mock.On("AllChatRooms", ctx, exchange)} 79 | } 80 | 81 | func (_c *mockChatRoomRetriever_AllChatRooms_Call) Run(run func(ctx context.Context, exchange uint16)) *mockChatRoomRetriever_AllChatRooms_Call { 82 | _c.Call.Run(func(args mock.Arguments) { 83 | var arg0 context.Context 84 | if args[0] != nil { 85 | arg0 = args[0].(context.Context) 86 | } 87 | var arg1 uint16 88 | if args[1] != nil { 89 | arg1 = args[1].(uint16) 90 | } 91 | run( 92 | arg0, 93 | arg1, 94 | ) 95 | }) 96 | return _c 97 | } 98 | 99 | func (_c *mockChatRoomRetriever_AllChatRooms_Call) Return(chatRooms []state.ChatRoom, err error) *mockChatRoomRetriever_AllChatRooms_Call { 100 | _c.Call.Return(chatRooms, err) 101 | return _c 102 | } 103 | 104 | func (_c *mockChatRoomRetriever_AllChatRooms_Call) RunAndReturn(run func(ctx context.Context, exchange uint16) ([]state.ChatRoom, error)) *mockChatRoomRetriever_AllChatRooms_Call { 105 | _c.Call.Return(run) 106 | return _c 107 | } 108 | -------------------------------------------------------------------------------- /server/http/mock_profile_retriever_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package http 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/mk6i/retro-aim-server/state" 11 | mock "github.com/stretchr/testify/mock" 12 | ) 13 | 14 | // newMockProfileRetriever creates a new instance of mockProfileRetriever. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 15 | // The first argument is typically a *testing.T value. 16 | func newMockProfileRetriever(t interface { 17 | mock.TestingT 18 | Cleanup(func()) 19 | }) *mockProfileRetriever { 20 | mock := &mockProfileRetriever{} 21 | mock.Mock.Test(t) 22 | 23 | t.Cleanup(func() { mock.AssertExpectations(t) }) 24 | 25 | return mock 26 | } 27 | 28 | // mockProfileRetriever is an autogenerated mock type for the ProfileRetriever type 29 | type mockProfileRetriever struct { 30 | mock.Mock 31 | } 32 | 33 | type mockProfileRetriever_Expecter struct { 34 | mock *mock.Mock 35 | } 36 | 37 | func (_m *mockProfileRetriever) EXPECT() *mockProfileRetriever_Expecter { 38 | return &mockProfileRetriever_Expecter{mock: &_m.Mock} 39 | } 40 | 41 | // Profile provides a mock function for the type mockProfileRetriever 42 | func (_mock *mockProfileRetriever) Profile(ctx context.Context, screenName state.IdentScreenName) (state.UserProfile, error) { 43 | ret := _mock.Called(ctx, screenName) 44 | 45 | if len(ret) == 0 { 46 | panic("no return value specified for Profile") 47 | } 48 | 49 | var r0 state.UserProfile 50 | var r1 error 51 | if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) (state.UserProfile, error)); ok { 52 | return returnFunc(ctx, screenName) 53 | } 54 | if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) state.UserProfile); ok { 55 | r0 = returnFunc(ctx, screenName) 56 | } else { 57 | r0 = ret.Get(0).(state.UserProfile) 58 | } 59 | if returnFunc, ok := ret.Get(1).(func(context.Context, state.IdentScreenName) error); ok { 60 | r1 = returnFunc(ctx, screenName) 61 | } else { 62 | r1 = ret.Error(1) 63 | } 64 | return r0, r1 65 | } 66 | 67 | // mockProfileRetriever_Profile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Profile' 68 | type mockProfileRetriever_Profile_Call struct { 69 | *mock.Call 70 | } 71 | 72 | // Profile is a helper method to define mock.On call 73 | // - ctx context.Context 74 | // - screenName state.IdentScreenName 75 | func (_e *mockProfileRetriever_Expecter) Profile(ctx interface{}, screenName interface{}) *mockProfileRetriever_Profile_Call { 76 | return &mockProfileRetriever_Profile_Call{Call: _e.mock.On("Profile", ctx, screenName)} 77 | } 78 | 79 | func (_c *mockProfileRetriever_Profile_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName)) *mockProfileRetriever_Profile_Call { 80 | _c.Call.Run(func(args mock.Arguments) { 81 | var arg0 context.Context 82 | if args[0] != nil { 83 | arg0 = args[0].(context.Context) 84 | } 85 | var arg1 state.IdentScreenName 86 | if args[1] != nil { 87 | arg1 = args[1].(state.IdentScreenName) 88 | } 89 | run( 90 | arg0, 91 | arg1, 92 | ) 93 | }) 94 | return _c 95 | } 96 | 97 | func (_c *mockProfileRetriever_Profile_Call) Return(userProfile state.UserProfile, err error) *mockProfileRetriever_Profile_Call { 98 | _c.Call.Return(userProfile, err) 99 | return _c 100 | } 101 | 102 | func (_c *mockProfileRetriever_Profile_Call) RunAndReturn(run func(ctx context.Context, screenName state.IdentScreenName) (state.UserProfile, error)) *mockProfileRetriever_Profile_Call { 103 | _c.Call.Return(run) 104 | return _c 105 | } 106 | -------------------------------------------------------------------------------- /state/cookie.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import ( 4 | "bytes" 5 | "crypto/hmac" 6 | "crypto/rand" 7 | "crypto/sha256" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "time" 12 | 13 | "github.com/mk6i/retro-aim-server/wire" 14 | ) 15 | 16 | // authCookieLen is the fixed auth cookie length. 17 | const authCookieLen = 256 18 | 19 | // ServerCookie represents a token containing client metadata passed to the BOS 20 | // service upon connection. 21 | type ServerCookie struct { 22 | Service uint16 23 | ScreenName DisplayScreenName `oscar:"len_prefix=uint8"` 24 | ClientID string `oscar:"len_prefix=uint8"` 25 | ChatCookie string `oscar:"len_prefix=uint8"` 26 | MultiConnFlag uint8 27 | // KerberosAuth indicates whether the client used Kerberos for authentication. 28 | KerberosAuth uint8 29 | } 30 | 31 | func NewHMACCookieBaker() (HMACCookieBaker, error) { 32 | cb := HMACCookieBaker{} 33 | cb.key = make([]byte, 32) 34 | if _, err := io.ReadFull(rand.Reader, cb.key); err != nil { 35 | return cb, fmt.Errorf("cannot generate random HMAC key: %w", err) 36 | } 37 | return cb, nil 38 | } 39 | 40 | type HMACCookieBaker struct { 41 | key []byte 42 | } 43 | 44 | func (c HMACCookieBaker) Issue(data []byte) ([]byte, error) { 45 | payload := hmacTokenPayload{ 46 | Expiry: uint32(time.Now().Add(1 * time.Minute).Unix()), 47 | Data: data, 48 | } 49 | buf := &bytes.Buffer{} 50 | if err := wire.MarshalBE(payload, buf); err != nil { 51 | return nil, fmt.Errorf("unable to marshal auth authCookie: %w", err) 52 | } 53 | 54 | hmacTok := hmacToken{ 55 | Data: buf.Bytes(), 56 | } 57 | hmacTok.hash(c.key) 58 | 59 | buf.Reset() 60 | 61 | if err := wire.MarshalBE(hmacTok, buf); err != nil { 62 | return nil, fmt.Errorf("unable to marshal auth authCookie: %w", err) 63 | } 64 | 65 | // Some clients (such as perl NET::OSCAR) expect the auth cookie to be 66 | // exactly 256 bytes, even though the cookie is stored in a 67 | // variable-length TLV. Pad the auth cookie to make sure it's exactly 68 | // 256 bytes. 69 | if buf.Len() > authCookieLen { 70 | return nil, fmt.Errorf("sess is too long, expect 256 bytes, got %d", buf.Len()) 71 | } 72 | buf.Write(make([]byte, authCookieLen-buf.Len())) 73 | 74 | return buf.Bytes(), nil 75 | } 76 | 77 | func (c HMACCookieBaker) Crack(data []byte) ([]byte, error) { 78 | hmacTok := hmacToken{} 79 | if err := wire.UnmarshalBE(&hmacTok, bytes.NewBuffer(data)); err != nil { 80 | return nil, fmt.Errorf("unable to unmarshal HMAC cooie: %w", err) 81 | } 82 | 83 | if !hmacTok.validate(c.key) { 84 | return nil, errors.New("invalid HMAC cookie") 85 | } 86 | 87 | payload := hmacTokenPayload{} 88 | if err := wire.UnmarshalBE(&payload, bytes.NewBuffer(hmacTok.Data)); err != nil { 89 | return nil, fmt.Errorf("unable to unmarshal HMAC cookie payload: %w", err) 90 | } 91 | 92 | expiry := time.Unix(int64(payload.Expiry), 0) 93 | if expiry.Before(time.Now()) { 94 | return nil, errors.New("HMAC cookie expired") 95 | } 96 | 97 | return payload.Data, nil 98 | } 99 | 100 | type hmacTokenPayload struct { 101 | Expiry uint32 102 | Data []byte `oscar:"len_prefix=uint16"` 103 | } 104 | 105 | type hmacToken struct { 106 | Data []byte `oscar:"len_prefix=uint16"` 107 | Sig []byte `oscar:"len_prefix=uint16"` 108 | } 109 | 110 | func (h *hmacToken) hash(key []byte) { 111 | hs := hmac.New(sha256.New, key) 112 | if _, err := hs.Write(h.Data); err != nil { 113 | // according to Hash interface, Write() should never return an error 114 | panic("unable to compute hmac token") 115 | } 116 | h.Sig = hs.Sum(nil) 117 | } 118 | 119 | func (h *hmacToken) validate(key []byte) bool { 120 | cp := *h 121 | cp.hash(key) 122 | return hmac.Equal(h.Sig, cp.Sig) 123 | } 124 | -------------------------------------------------------------------------------- /server/http/mock_feedbag_retriever_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package http 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/mk6i/retro-aim-server/state" 11 | "github.com/mk6i/retro-aim-server/wire" 12 | mock "github.com/stretchr/testify/mock" 13 | ) 14 | 15 | // newMockFeedBagRetriever creates a new instance of mockFeedBagRetriever. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 16 | // The first argument is typically a *testing.T value. 17 | func newMockFeedBagRetriever(t interface { 18 | mock.TestingT 19 | Cleanup(func()) 20 | }) *mockFeedBagRetriever { 21 | mock := &mockFeedBagRetriever{} 22 | mock.Mock.Test(t) 23 | 24 | t.Cleanup(func() { mock.AssertExpectations(t) }) 25 | 26 | return mock 27 | } 28 | 29 | // mockFeedBagRetriever is an autogenerated mock type for the FeedBagRetriever type 30 | type mockFeedBagRetriever struct { 31 | mock.Mock 32 | } 33 | 34 | type mockFeedBagRetriever_Expecter struct { 35 | mock *mock.Mock 36 | } 37 | 38 | func (_m *mockFeedBagRetriever) EXPECT() *mockFeedBagRetriever_Expecter { 39 | return &mockFeedBagRetriever_Expecter{mock: &_m.Mock} 40 | } 41 | 42 | // BuddyIconMetadata provides a mock function for the type mockFeedBagRetriever 43 | func (_mock *mockFeedBagRetriever) BuddyIconMetadata(ctx context.Context, screenName state.IdentScreenName) (*wire.BARTID, error) { 44 | ret := _mock.Called(ctx, screenName) 45 | 46 | if len(ret) == 0 { 47 | panic("no return value specified for BuddyIconMetadata") 48 | } 49 | 50 | var r0 *wire.BARTID 51 | var r1 error 52 | if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) (*wire.BARTID, error)); ok { 53 | return returnFunc(ctx, screenName) 54 | } 55 | if returnFunc, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) *wire.BARTID); ok { 56 | r0 = returnFunc(ctx, screenName) 57 | } else { 58 | if ret.Get(0) != nil { 59 | r0 = ret.Get(0).(*wire.BARTID) 60 | } 61 | } 62 | if returnFunc, ok := ret.Get(1).(func(context.Context, state.IdentScreenName) error); ok { 63 | r1 = returnFunc(ctx, screenName) 64 | } else { 65 | r1 = ret.Error(1) 66 | } 67 | return r0, r1 68 | } 69 | 70 | // mockFeedBagRetriever_BuddyIconMetadata_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BuddyIconMetadata' 71 | type mockFeedBagRetriever_BuddyIconMetadata_Call struct { 72 | *mock.Call 73 | } 74 | 75 | // BuddyIconMetadata is a helper method to define mock.On call 76 | // - ctx context.Context 77 | // - screenName state.IdentScreenName 78 | func (_e *mockFeedBagRetriever_Expecter) BuddyIconMetadata(ctx interface{}, screenName interface{}) *mockFeedBagRetriever_BuddyIconMetadata_Call { 79 | return &mockFeedBagRetriever_BuddyIconMetadata_Call{Call: _e.mock.On("BuddyIconMetadata", ctx, screenName)} 80 | } 81 | 82 | func (_c *mockFeedBagRetriever_BuddyIconMetadata_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName)) *mockFeedBagRetriever_BuddyIconMetadata_Call { 83 | _c.Call.Run(func(args mock.Arguments) { 84 | var arg0 context.Context 85 | if args[0] != nil { 86 | arg0 = args[0].(context.Context) 87 | } 88 | var arg1 state.IdentScreenName 89 | if args[1] != nil { 90 | arg1 = args[1].(state.IdentScreenName) 91 | } 92 | run( 93 | arg0, 94 | arg1, 95 | ) 96 | }) 97 | return _c 98 | } 99 | 100 | func (_c *mockFeedBagRetriever_BuddyIconMetadata_Call) Return(bARTID *wire.BARTID, err error) *mockFeedBagRetriever_BuddyIconMetadata_Call { 101 | _c.Call.Return(bARTID, err) 102 | return _c 103 | } 104 | 105 | func (_c *mockFeedBagRetriever_BuddyIconMetadata_Call) RunAndReturn(run func(ctx context.Context, screenName state.IdentScreenName) (*wire.BARTID, error)) *mockFeedBagRetriever_BuddyIconMetadata_Call { 106 | _c.Call.Return(run) 107 | return _c 108 | } 109 | -------------------------------------------------------------------------------- /foodgroup/user_lookup_test.go: -------------------------------------------------------------------------------- 1 | package foodgroup 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "testing" 7 | 8 | "github.com/mk6i/retro-aim-server/state" 9 | "github.com/mk6i/retro-aim-server/wire" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestUserLookupService_FindByEmail(t *testing.T) { 15 | cases := []struct { 16 | // name is the unit test name 17 | name string 18 | // inputSNAC is the SNAC sent by the sender client 19 | inputSNAC wire.SNACMessage 20 | // expectSNACFrame is the SNAC frame sent from the server to the recipient 21 | // client 22 | expectOutput wire.SNACMessage 23 | // mockParams is the list of params sent to mocks that satisfy this 24 | // method's dependencies 25 | mockParams mockParams 26 | // expectErr is the expected error returned by the handler 27 | expectErr error 28 | }{ 29 | { 30 | name: "search by email address - results found", 31 | inputSNAC: wire.SNACMessage{ 32 | Frame: wire.SNACFrame{ 33 | RequestID: 1234, 34 | }, 35 | Body: wire.SNAC_0x0A_0x02_UserLookupFindByEmail{ 36 | Email: []byte("user@aol.com"), 37 | }, 38 | }, 39 | expectOutput: wire.SNACMessage{ 40 | Frame: wire.SNACFrame{ 41 | FoodGroup: wire.UserLookup, 42 | SubGroup: wire.UserLookupFindReply, 43 | RequestID: 1234, 44 | }, 45 | Body: wire.SNAC_0x0A_0x03_UserLookupFindReply{ 46 | TLVRestBlock: wire.TLVRestBlock{ 47 | TLVList: wire.TLVList{ 48 | wire.NewTLVBE(wire.UserLookupTLVEmailAddress, "ChattingChuck"), 49 | }, 50 | }, 51 | }, 52 | }, 53 | mockParams: mockParams{ 54 | profileManagerParams: profileManagerParams{ 55 | findByAIMEmailParams: findByAIMEmailParams{ 56 | { 57 | email: "user@aol.com", 58 | result: state.User{ 59 | DisplayScreenName: "ChattingChuck", 60 | }, 61 | }, 62 | }, 63 | }, 64 | }, 65 | }, 66 | { 67 | name: "search by email address - no results found", 68 | inputSNAC: wire.SNACMessage{ 69 | Frame: wire.SNACFrame{ 70 | RequestID: 1234, 71 | }, 72 | Body: wire.SNAC_0x0A_0x02_UserLookupFindByEmail{ 73 | Email: []byte("user@aol.com"), 74 | }, 75 | }, 76 | expectOutput: wire.SNACMessage{ 77 | Frame: wire.SNACFrame{ 78 | FoodGroup: wire.UserLookup, 79 | SubGroup: wire.UserLookupErr, 80 | RequestID: 1234, 81 | }, 82 | Body: wire.UserLookupErrNoUserFound, 83 | }, 84 | mockParams: mockParams{ 85 | profileManagerParams: profileManagerParams{ 86 | findByAIMEmailParams: findByAIMEmailParams{ 87 | { 88 | email: "user@aol.com", 89 | err: state.ErrNoUser, 90 | }, 91 | }, 92 | }, 93 | }, 94 | }, 95 | { 96 | name: "search by email address - search error", 97 | inputSNAC: wire.SNACMessage{ 98 | Frame: wire.SNACFrame{ 99 | RequestID: 1234, 100 | }, 101 | Body: wire.SNAC_0x0A_0x02_UserLookupFindByEmail{ 102 | Email: []byte("user@aol.com"), 103 | }, 104 | }, 105 | expectOutput: wire.SNACMessage{}, 106 | expectErr: io.EOF, 107 | mockParams: mockParams{ 108 | profileManagerParams: profileManagerParams{ 109 | findByAIMEmailParams: findByAIMEmailParams{ 110 | { 111 | email: "user@aol.com", 112 | err: io.EOF, 113 | }, 114 | }, 115 | }, 116 | }, 117 | }, 118 | } 119 | 120 | for _, tc := range cases { 121 | t.Run(tc.name, func(t *testing.T) { 122 | profileManager := newMockProfileManager(t) 123 | 124 | for _, params := range tc.mockParams.findByAIMEmailParams { 125 | profileManager.EXPECT(). 126 | FindByAIMEmail(matchContext(), params.email). 127 | Return(params.result, params.err) 128 | } 129 | 130 | svc := NewUserLookupService(profileManager) 131 | actual, err := svc.FindByEmail(context.Background(), tc.inputSNAC.Frame, tc.inputSNAC.Body.(wire.SNAC_0x0A_0x02_UserLookupFindByEmail)) 132 | assert.ErrorIs(t, err, tc.expectErr) 133 | assert.Equal(t, tc.expectOutput, actual) 134 | }) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /state/webapi_auth.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "fmt" 8 | "time" 9 | ) 10 | 11 | // WebAPITokenStore manages authentication tokens for Web API sessions. 12 | type WebAPITokenStore struct { 13 | store *SQLiteUserStore 14 | } 15 | 16 | // NewWebAPITokenStore creates a new token store. 17 | func (s *SQLiteUserStore) NewWebAPITokenStore() *WebAPITokenStore { 18 | return &WebAPITokenStore{store: s} 19 | } 20 | 21 | // StoreToken saves an authentication token for a user. 22 | func (s *WebAPITokenStore) StoreToken(ctx context.Context, token string, screenName IdentScreenName, expiresAt time.Time) error { 23 | query := ` 24 | INSERT INTO webapi_tokens (token, screen_name, expires_at, created_at) 25 | VALUES (?, ?, ?, ?) 26 | ON CONFLICT(token) DO UPDATE SET 27 | screen_name = excluded.screen_name, 28 | expires_at = excluded.expires_at 29 | ` 30 | _, err := s.store.db.ExecContext(ctx, query, token, screenName.String(), expiresAt, time.Now()) 31 | if err != nil { 32 | return fmt.Errorf("failed to store token: %w", err) 33 | } 34 | return nil 35 | } 36 | 37 | // ValidateToken checks if a token is valid and returns the associated screen name. 38 | func (s *WebAPITokenStore) ValidateToken(ctx context.Context, token string) (IdentScreenName, error) { 39 | var screenNameStr string 40 | var expiresAt time.Time 41 | 42 | query := ` 43 | SELECT screen_name, expires_at 44 | FROM webapi_tokens 45 | WHERE token = ? 46 | ` 47 | err := s.store.db.QueryRowContext(ctx, query, token).Scan(&screenNameStr, &expiresAt) 48 | if err != nil { 49 | if errors.Is(err, sql.ErrNoRows) { 50 | return NewIdentScreenName(""), errors.New("invalid token") 51 | } 52 | return NewIdentScreenName(""), fmt.Errorf("failed to validate token: %w", err) 53 | } 54 | 55 | // Check if token has expired 56 | if time.Now().After(expiresAt) { 57 | // Clean up expired token 58 | s.DeleteToken(ctx, token) 59 | return NewIdentScreenName(""), errors.New("token expired") 60 | } 61 | 62 | return NewIdentScreenName(screenNameStr), nil 63 | } 64 | 65 | // DeleteToken removes a token. 66 | func (s *WebAPITokenStore) DeleteToken(ctx context.Context, token string) error { 67 | query := `DELETE FROM webapi_tokens WHERE token = ?` 68 | _, err := s.store.db.ExecContext(ctx, query, token) 69 | if err != nil { 70 | return fmt.Errorf("failed to delete token: %w", err) 71 | } 72 | return nil 73 | } 74 | 75 | // CleanupExpiredTokens removes all expired tokens from the database. 76 | func (s *WebAPITokenStore) CleanupExpiredTokens(ctx context.Context) error { 77 | query := `DELETE FROM webapi_tokens WHERE expires_at < ?` 78 | _, err := s.store.db.ExecContext(ctx, query, time.Now()) 79 | if err != nil { 80 | return fmt.Errorf("failed to cleanup expired tokens: %w", err) 81 | } 82 | return nil 83 | } 84 | 85 | // AuthenticateUser verifies username and password. 86 | // This implementation uses the existing user store for authentication. 87 | func (u *SQLiteUserStore) AuthenticateUser(ctx context.Context, username, password string) (*User, error) { 88 | // Convert username to IdentScreenName for lookup 89 | identSN := NewIdentScreenName(username) 90 | 91 | // Try to find the user 92 | user, err := u.User(ctx, identSN) 93 | if err != nil { 94 | return nil, fmt.Errorf("user not found: %w", err) 95 | } 96 | 97 | // In development mode with DISABLE_AUTH=true, accept any password 98 | // In production, this would verify the password hash 99 | // For now, we'll accept any non-empty password if the user exists 100 | if password == "" { 101 | return nil, errors.New("password required") 102 | } 103 | 104 | // TODO: In production, verify password hash here 105 | // For development with DISABLE_AUTH, we just check if user exists 106 | 107 | return user, nil 108 | } 109 | 110 | // FindUserByScreenName finds a user by their screen name. 111 | // This is just an alias for the User method to satisfy the UserManager interface. 112 | func (u *SQLiteUserStore) FindUserByScreenName(ctx context.Context, screenName IdentScreenName) (*User, error) { 113 | return u.User(ctx, screenName) 114 | } 115 | -------------------------------------------------------------------------------- /server/toc/mock_dir_search_service_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package toc 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/mk6i/retro-aim-server/wire" 11 | mock "github.com/stretchr/testify/mock" 12 | ) 13 | 14 | // newMockDirSearchService creates a new instance of mockDirSearchService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 15 | // The first argument is typically a *testing.T value. 16 | func newMockDirSearchService(t interface { 17 | mock.TestingT 18 | Cleanup(func()) 19 | }) *mockDirSearchService { 20 | mock := &mockDirSearchService{} 21 | mock.Mock.Test(t) 22 | 23 | t.Cleanup(func() { mock.AssertExpectations(t) }) 24 | 25 | return mock 26 | } 27 | 28 | // mockDirSearchService is an autogenerated mock type for the DirSearchService type 29 | type mockDirSearchService struct { 30 | mock.Mock 31 | } 32 | 33 | type mockDirSearchService_Expecter struct { 34 | mock *mock.Mock 35 | } 36 | 37 | func (_m *mockDirSearchService) EXPECT() *mockDirSearchService_Expecter { 38 | return &mockDirSearchService_Expecter{mock: &_m.Mock} 39 | } 40 | 41 | // InfoQuery provides a mock function for the type mockDirSearchService 42 | func (_mock *mockDirSearchService) InfoQuery(context1 context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0F_0x02_InfoQuery) (wire.SNACMessage, error) { 43 | ret := _mock.Called(context1, inFrame, inBody) 44 | 45 | if len(ret) == 0 { 46 | panic("no return value specified for InfoQuery") 47 | } 48 | 49 | var r0 wire.SNACMessage 50 | var r1 error 51 | if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0F_0x02_InfoQuery) (wire.SNACMessage, error)); ok { 52 | return returnFunc(context1, inFrame, inBody) 53 | } 54 | if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0F_0x02_InfoQuery) wire.SNACMessage); ok { 55 | r0 = returnFunc(context1, inFrame, inBody) 56 | } else { 57 | r0 = ret.Get(0).(wire.SNACMessage) 58 | } 59 | if returnFunc, ok := ret.Get(1).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0F_0x02_InfoQuery) error); ok { 60 | r1 = returnFunc(context1, inFrame, inBody) 61 | } else { 62 | r1 = ret.Error(1) 63 | } 64 | return r0, r1 65 | } 66 | 67 | // mockDirSearchService_InfoQuery_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InfoQuery' 68 | type mockDirSearchService_InfoQuery_Call struct { 69 | *mock.Call 70 | } 71 | 72 | // InfoQuery is a helper method to define mock.On call 73 | // - context1 context.Context 74 | // - inFrame wire.SNACFrame 75 | // - inBody wire.SNAC_0x0F_0x02_InfoQuery 76 | func (_e *mockDirSearchService_Expecter) InfoQuery(context1 interface{}, inFrame interface{}, inBody interface{}) *mockDirSearchService_InfoQuery_Call { 77 | return &mockDirSearchService_InfoQuery_Call{Call: _e.mock.On("InfoQuery", context1, inFrame, inBody)} 78 | } 79 | 80 | func (_c *mockDirSearchService_InfoQuery_Call) Run(run func(context1 context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0F_0x02_InfoQuery)) *mockDirSearchService_InfoQuery_Call { 81 | _c.Call.Run(func(args mock.Arguments) { 82 | var arg0 context.Context 83 | if args[0] != nil { 84 | arg0 = args[0].(context.Context) 85 | } 86 | var arg1 wire.SNACFrame 87 | if args[1] != nil { 88 | arg1 = args[1].(wire.SNACFrame) 89 | } 90 | var arg2 wire.SNAC_0x0F_0x02_InfoQuery 91 | if args[2] != nil { 92 | arg2 = args[2].(wire.SNAC_0x0F_0x02_InfoQuery) 93 | } 94 | run( 95 | arg0, 96 | arg1, 97 | arg2, 98 | ) 99 | }) 100 | return _c 101 | } 102 | 103 | func (_c *mockDirSearchService_InfoQuery_Call) Return(sNACMessage wire.SNACMessage, err error) *mockDirSearchService_InfoQuery_Call { 104 | _c.Call.Return(sNACMessage, err) 105 | return _c 106 | } 107 | 108 | func (_c *mockDirSearchService_InfoQuery_Call) RunAndReturn(run func(context1 context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0F_0x02_InfoQuery) (wire.SNACMessage, error)) *mockDirSearchService_InfoQuery_Call { 109 | _c.Call.Return(run) 110 | return _c 111 | } 112 | -------------------------------------------------------------------------------- /docs/MACOS.md: -------------------------------------------------------------------------------- 1 | # Open OSCAR Server Quickstart for macOS (Intel and Apple Silicon) 2 | 3 | This guide explains how to download, configure and run Open OSCAR Server on macOS (Intel and Apple Silicon). 4 | 5 | 1. **Download Open OSCAR Server** 6 | 7 | Grab the latest macOS release from the [Releases page](https://github.com/mk6i/open-oscar-server/releases) for your 8 | platform (Intel or Apple Silicon). 9 | 10 | Because the Open OSCAR Server binary has not been blessed by Apple, browsers such as Chrome may think it's a 11 | "suspicious" file and block the download, in which case you need to explicitly opt in to downloading the untrusted 12 | file. 13 | 14 |

15 | screenshot of a chrome prompt showing a blocked download 16 |

17 | 18 | > While the binaries are 100% safe, you can avoid the security concern by [building the application yourself](./BUILD.md). 19 | We do not provide signed binaries because of the undue cost and complexity. 20 | 21 | Once downloaded, extract the `.zip` archive, which contains the application and a configuration file `settings.env`. 22 | 23 | 2. **Open Terminal** 24 | 25 | Open a terminal and navigate to the extracted directory. This terminal will be used for the remaining steps. 26 | 27 | ```shell 28 | cd ~/Downloads/open_oscar_server.0.21.0.macos.intel_x86_64/ 29 | ``` 30 | 31 | 3. **Remove Quarantine** 32 | 33 | macOS will quarantine the Open OSCAR Server binary because it has not been blessed by Apple. To remove the quarantine 34 | flag from the binary, run following command in the same terminal, 35 | 36 | ```shell 37 | sudo xattr -d com.apple.quarantine ./open_oscar_server 38 | ``` 39 | 40 | > While the binaries are 100% safe, you can avoid the security concern by [building the application yourself](./BUILD.md). 41 | We do not provide signed binaries because of the undue cost and complexity. 42 | 43 | 4. **Configure Server Address** 44 | 45 | Set the default listener in `OSCAR_ADVERTISED_LISTENERS_PLAIN` in `settings.env` to a hostname and port that the AIM 46 | clients can connect to. If you are running the AIM client and server on the same machine, you don't need to change 47 | the default value. 48 | 49 | The format is `[NAME]://[HOSTNAME]:[PORT]` where: 50 | - `LOCAL` is the listener name (can be any name you choose, as long as it matches the `OSCAR_LISTENERS` config) 51 | - `127.0.0.1` is the hostname clients connect to 52 | - `5190` is the port number clients connect to 53 | 54 | In order to connect AIM clients on your LAN (including VMs with bridged networking), you can find the appropriate IP 55 | address by running the following command in the terminal and use that IP instead of `127.0.0.1`: 56 | 57 | ```shell 58 | osascript -e "IPv4 address of (system info)" 59 | ``` 60 | 61 | 5. **Start the Application** 62 | 63 | Run the following command to launch Open OSCAR Server: 64 | 65 | ```shell 66 | ./open_oscar_server 67 | ``` 68 | 69 | Open OSCAR Server will run in the terminal, ready to accept AIM client connections. 70 | 71 | 6. **Test** 72 | 73 | To do a quick sanity check, start an AIM client, sign in to the server, and send yourself an instant message. 74 | Configure the AIM client to connect to the host and port from `OSCAR_ADVERTISED_LISTENERS_PLAIN` in `settings.env`. If using the default server setting, set host to `127.0.0.1` and port `5190`. 75 | 76 | See the [Client Configuration Guide](./CLIENT.md) for more detail on setting up the AIM client. 77 | 78 | By default, you can enter *any* screen name and password at the AIM sign-in screen to auto-create an account. 79 | 80 | > Account auto-creation is meant to be a convenience feature for local development. In a production deployment, you 81 | should set `DISABLE_AUTH=false` in `settings.env` to enforce account authentication. User accounts can be created via 82 | the [Management API](../README.md#-management-api). 83 | 84 | 7. **Additional Setup** 85 | 86 | For optional configuration steps that enhance your Open OSCAR Server experience, refer to 87 | the [Additional Setup Guide](./ADDITIONAL_SETUP.md). -------------------------------------------------------------------------------- /server/oscar/mock_user_lookup_service_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package oscar 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/mk6i/retro-aim-server/wire" 11 | mock "github.com/stretchr/testify/mock" 12 | ) 13 | 14 | // newMockUserLookupService creates a new instance of mockUserLookupService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 15 | // The first argument is typically a *testing.T value. 16 | func newMockUserLookupService(t interface { 17 | mock.TestingT 18 | Cleanup(func()) 19 | }) *mockUserLookupService { 20 | mock := &mockUserLookupService{} 21 | mock.Mock.Test(t) 22 | 23 | t.Cleanup(func() { mock.AssertExpectations(t) }) 24 | 25 | return mock 26 | } 27 | 28 | // mockUserLookupService is an autogenerated mock type for the UserLookupService type 29 | type mockUserLookupService struct { 30 | mock.Mock 31 | } 32 | 33 | type mockUserLookupService_Expecter struct { 34 | mock *mock.Mock 35 | } 36 | 37 | func (_m *mockUserLookupService) EXPECT() *mockUserLookupService_Expecter { 38 | return &mockUserLookupService_Expecter{mock: &_m.Mock} 39 | } 40 | 41 | // FindByEmail provides a mock function for the type mockUserLookupService 42 | func (_mock *mockUserLookupService) FindByEmail(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0A_0x02_UserLookupFindByEmail) (wire.SNACMessage, error) { 43 | ret := _mock.Called(ctx, inFrame, inBody) 44 | 45 | if len(ret) == 0 { 46 | panic("no return value specified for FindByEmail") 47 | } 48 | 49 | var r0 wire.SNACMessage 50 | var r1 error 51 | if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0A_0x02_UserLookupFindByEmail) (wire.SNACMessage, error)); ok { 52 | return returnFunc(ctx, inFrame, inBody) 53 | } 54 | if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0A_0x02_UserLookupFindByEmail) wire.SNACMessage); ok { 55 | r0 = returnFunc(ctx, inFrame, inBody) 56 | } else { 57 | r0 = ret.Get(0).(wire.SNACMessage) 58 | } 59 | if returnFunc, ok := ret.Get(1).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0A_0x02_UserLookupFindByEmail) error); ok { 60 | r1 = returnFunc(ctx, inFrame, inBody) 61 | } else { 62 | r1 = ret.Error(1) 63 | } 64 | return r0, r1 65 | } 66 | 67 | // mockUserLookupService_FindByEmail_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByEmail' 68 | type mockUserLookupService_FindByEmail_Call struct { 69 | *mock.Call 70 | } 71 | 72 | // FindByEmail is a helper method to define mock.On call 73 | // - ctx context.Context 74 | // - inFrame wire.SNACFrame 75 | // - inBody wire.SNAC_0x0A_0x02_UserLookupFindByEmail 76 | func (_e *mockUserLookupService_Expecter) FindByEmail(ctx interface{}, inFrame interface{}, inBody interface{}) *mockUserLookupService_FindByEmail_Call { 77 | return &mockUserLookupService_FindByEmail_Call{Call: _e.mock.On("FindByEmail", ctx, inFrame, inBody)} 78 | } 79 | 80 | func (_c *mockUserLookupService_FindByEmail_Call) Run(run func(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0A_0x02_UserLookupFindByEmail)) *mockUserLookupService_FindByEmail_Call { 81 | _c.Call.Run(func(args mock.Arguments) { 82 | var arg0 context.Context 83 | if args[0] != nil { 84 | arg0 = args[0].(context.Context) 85 | } 86 | var arg1 wire.SNACFrame 87 | if args[1] != nil { 88 | arg1 = args[1].(wire.SNACFrame) 89 | } 90 | var arg2 wire.SNAC_0x0A_0x02_UserLookupFindByEmail 91 | if args[2] != nil { 92 | arg2 = args[2].(wire.SNAC_0x0A_0x02_UserLookupFindByEmail) 93 | } 94 | run( 95 | arg0, 96 | arg1, 97 | arg2, 98 | ) 99 | }) 100 | return _c 101 | } 102 | 103 | func (_c *mockUserLookupService_FindByEmail_Call) Return(sNACMessage wire.SNACMessage, err error) *mockUserLookupService_FindByEmail_Call { 104 | _c.Call.Return(sNACMessage, err) 105 | return _c 106 | } 107 | 108 | func (_c *mockUserLookupService_FindByEmail_Call) RunAndReturn(run func(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0A_0x02_UserLookupFindByEmail) (wire.SNACMessage, error)) *mockUserLookupService_FindByEmail_Call { 109 | _c.Call.Return(run) 110 | return _c 111 | } 112 | -------------------------------------------------------------------------------- /server/webapi/oscar_config.go: -------------------------------------------------------------------------------- 1 | package webapi 2 | 3 | import ( 4 | "net" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/mk6i/retro-aim-server/config" 9 | ) 10 | 11 | // OSCARConfigAdapter adapts the main server configuration to provide 12 | // OSCAR-specific configuration for the Web API bridge. 13 | type OSCARConfigAdapter struct { 14 | cfg config.Config 15 | listeners []config.Listener 16 | } 17 | 18 | // NewOSCARConfigAdapter creates a new OSCAR configuration adapter. 19 | func NewOSCARConfigAdapter(cfg config.Config) *OSCARConfigAdapter { 20 | listeners, _ := cfg.ParseListenersCfg() 21 | return &OSCARConfigAdapter{ 22 | cfg: cfg, 23 | listeners: listeners, 24 | } 25 | } 26 | 27 | // GetBOSAddress returns the plain (non-SSL) BOS server address for client connections. 28 | // This parses the configured BOS advertised host to extract the hostname and port. 29 | func (a *OSCARConfigAdapter) GetBOSAddress() (host string, port int) { 30 | // Default to first listener configuration 31 | if len(a.listeners) == 0 { 32 | return "localhost", 5190 // Default OSCAR port 33 | } 34 | 35 | listener := a.listeners[0] 36 | 37 | // Parse the advertised host for plain connections 38 | if listener.BOSAdvertisedHostPlain != "" { 39 | host, portStr := splitHostPort(listener.BOSAdvertisedHostPlain) 40 | if portStr != "" { 41 | if p, err := strconv.Atoi(portStr); err == nil { 42 | port = p 43 | } 44 | } 45 | if port == 0 { 46 | port = 5190 // Default OSCAR port 47 | } 48 | return host, port 49 | } 50 | 51 | // Fall back to parsing the listen address 52 | if listener.BOSListenAddress != "" { 53 | host, portStr, err := net.SplitHostPort(listener.BOSListenAddress) 54 | if err == nil { 55 | if host == "" { 56 | host = "localhost" 57 | } 58 | if p, err := strconv.Atoi(portStr); err == nil { 59 | port = p 60 | } 61 | } 62 | if port == 0 { 63 | port = 5190 64 | } 65 | return host, port 66 | } 67 | 68 | return "localhost", 5190 69 | } 70 | 71 | // GetSSLBOSAddress returns the SSL-enabled BOS server address for client connections. 72 | func (a *OSCARConfigAdapter) GetSSLBOSAddress() (host string, port int) { 73 | // Default to first listener configuration with SSL 74 | for _, listener := range a.listeners { 75 | if listener.HasSSL && listener.BOSAdvertisedHostSSL != "" { 76 | host, portStr := splitHostPort(listener.BOSAdvertisedHostSSL) 77 | if portStr != "" { 78 | if p, err := strconv.Atoi(portStr); err == nil { 79 | port = p 80 | } 81 | } 82 | if port == 0 { 83 | port = 5190 // Default OSCAR SSL port (could be different) 84 | } 85 | return host, port 86 | } 87 | } 88 | 89 | // Fall back to plain address if no SSL configured 90 | return a.GetBOSAddress() 91 | } 92 | 93 | // IsSSLAvailable checks if any listener has SSL configured. 94 | func (a *OSCARConfigAdapter) IsSSLAvailable() bool { 95 | for _, listener := range a.listeners { 96 | if listener.HasSSL { 97 | return true 98 | } 99 | } 100 | return false 101 | } 102 | 103 | // IsAuthDisabled returns whether authentication is disabled. 104 | func (a *OSCARConfigAdapter) IsAuthDisabled() bool { 105 | return a.cfg.DisableAuth 106 | } 107 | 108 | // splitHostPort splits a host:port string, handling IPv6 addresses correctly. 109 | // Unlike net.SplitHostPort, this doesn't return an error for missing ports. 110 | func splitHostPort(hostport string) (host string, port string) { 111 | // Handle IPv6 addresses 112 | if strings.HasPrefix(hostport, "[") { 113 | endIdx := strings.LastIndex(hostport, "]") 114 | if endIdx != -1 { 115 | host = hostport[1:endIdx] 116 | if endIdx+1 < len(hostport) && hostport[endIdx+1] == ':' { 117 | port = hostport[endIdx+2:] 118 | } 119 | return 120 | } 121 | } 122 | 123 | // Handle IPv4 and hostnames 124 | lastColon := strings.LastIndex(hostport, ":") 125 | if lastColon != -1 { 126 | // Check if this might be an IPv6 address without brackets 127 | if strings.Count(hostport, ":") > 1 { 128 | // Multiple colons, likely IPv6 without port 129 | host = hostport 130 | return 131 | } 132 | host = hostport[:lastColon] 133 | port = hostport[lastColon+1:] 134 | return 135 | } 136 | 137 | // No port specified 138 | host = hostport 139 | return 140 | } 141 | -------------------------------------------------------------------------------- /state/chat.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/url" 7 | "time" 8 | 9 | "github.com/mk6i/retro-aim-server/wire" 10 | ) 11 | 12 | const ( 13 | // PrivateExchange is the ID of the exchange that hosts non-public created 14 | // by users. 15 | PrivateExchange uint16 = 4 16 | // PublicExchange is the ID of the exchange that hosts public chat rooms 17 | // created by the server operator exclusively. 18 | PublicExchange uint16 = 5 19 | ) 20 | 21 | // ErrChatRoomNotFound indicates that a chat room lookup failed. 22 | var ( 23 | ErrChatRoomNotFound = errors.New("chat room not found") 24 | ErrDupChatRoom = errors.New("chat room already exists") 25 | ) 26 | 27 | // NewChatRoom creates a new ChatRoom instance. 28 | func NewChatRoom(name string, creator IdentScreenName, exchange uint16) ChatRoom { 29 | return ChatRoom{ 30 | name: name, 31 | creator: creator, 32 | exchange: exchange, 33 | } 34 | } 35 | 36 | // ChatRoom represents of a chat room. 37 | type ChatRoom struct { 38 | createTime time.Time 39 | creator IdentScreenName 40 | exchange uint16 41 | name string 42 | } 43 | 44 | // Creator returns the screen name of the user who created the chat room. 45 | func (c ChatRoom) Creator() IdentScreenName { 46 | return c.creator 47 | } 48 | 49 | // Exchange returns which exchange the chat room belongs to. 50 | func (c ChatRoom) Exchange() uint16 { 51 | return c.exchange 52 | } 53 | 54 | // Name returns the chat room name. 55 | func (c ChatRoom) Name() string { 56 | return c.name 57 | } 58 | 59 | // InstanceNumber returns which instance chatroom exists in. Overflow chat 60 | // rooms do not exist yet, so all chats happen in the same instance. 61 | func (c ChatRoom) InstanceNumber() uint16 { 62 | return 0 63 | } 64 | 65 | // CreateTime returns when the chat room was inserted in the database. 66 | func (c ChatRoom) CreateTime() time.Time { 67 | return c.createTime 68 | } 69 | 70 | // DetailLevel returns the detail level of the chat room (whatever that means). 71 | func (c ChatRoom) DetailLevel() uint8 { 72 | return 0x02 // Pidgin 2.13.0 expects value 0x02 73 | } 74 | 75 | // Cookie returns the chat room unique identifier. 76 | func (c ChatRoom) Cookie() string { 77 | // According to Pidgin, the chat cookie is a 3-part identifier. The third 78 | // segment is the chat name, which is shown explicitly in the Pidgin code. 79 | // We can assume that the first two parts were the exchange and instance 80 | // number. As of now, Pidgin is the only client that cares about the cookie 81 | // format, and it only cares about the chat name segment. 82 | return fmt.Sprintf("%d-%d-%s", c.exchange, c.InstanceNumber(), c.name) 83 | } 84 | 85 | // URL creates a URL that can be used to join a chat room. 86 | func (c ChatRoom) URL() *url.URL { 87 | // macOS client v4.0.9 requires the `roomname` param to precede `exchange` 88 | // param. Create the path using string concatenation rather than url.Values 89 | // because url.Values sorts the params alphabetically. 90 | opaque := fmt.Sprintf("gochat?roomname=%s&exchange=%d", url.QueryEscape(c.name), c.exchange) 91 | return &url.URL{ 92 | Scheme: "aim", 93 | Opaque: opaque, 94 | } 95 | } 96 | 97 | // TLVList returns a TLV list of chat room metadata. 98 | func (c ChatRoom) TLVList() []wire.TLV { 99 | return []wire.TLV{ 100 | // From protocols/oscar/family_chatnav.c in lib purple, these are the 101 | // room creation flags: 102 | // - 1 Evilable 103 | // - 2 Nav Only 104 | // - 4 Instancing Allowed 105 | // - 8 Occupant Peek Allowed 106 | // It's unclear what effect they actually have. 107 | wire.NewTLVBE(wire.ChatRoomTLVFlags, uint16(15)), 108 | wire.NewTLVBE(wire.ChatRoomTLVCreateTime, uint32(c.createTime.Unix())), 109 | wire.NewTLVBE(wire.ChatRoomTLVMaxMsgLen, uint16(1024)), 110 | wire.NewTLVBE(wire.ChatRoomTLVMaxOccupancy, uint16(100)), 111 | // From protocols/oscar/family_chatnav.c in lib purple, these are the 112 | // room creation permission values: 113 | // - 0 creation not allowed 114 | // - 1 room creation allowed 115 | // - 2 exchange creation allowed 116 | // It's unclear what effect they actually have. 117 | wire.NewTLVBE(wire.ChatRoomTLVNavCreatePerms, uint8(2)), 118 | wire.NewTLVBE(wire.ChatRoomTLVFullyQualifiedName, c.name), 119 | wire.NewTLVBE(wire.ChatRoomTLVRoomName, c.name), 120 | wire.NewTLVBE(wire.ChatRoomTLVMaxMsgVisLen, uint16(1024)), 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /server/oscar/middleware/logger.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | "strings" 9 | 10 | "github.com/mk6i/retro-aim-server/config" 11 | "github.com/mk6i/retro-aim-server/wire" 12 | ) 13 | 14 | const ( 15 | LevelTrace = slog.Level(-8) 16 | ) 17 | 18 | var levelNames = map[slog.Leveler]string{ 19 | LevelTrace: "TRACE", 20 | } 21 | 22 | func NewLogger(cfg config.Config) *slog.Logger { 23 | var level slog.Level 24 | switch strings.ToLower(cfg.LogLevel) { 25 | case "trace": 26 | level = LevelTrace 27 | case "debug": 28 | level = slog.LevelDebug 29 | case "warn": 30 | level = slog.LevelWarn 31 | case "error": 32 | level = slog.LevelError 33 | case "info": 34 | fallthrough 35 | default: 36 | level = slog.LevelInfo 37 | } 38 | 39 | opts := &slog.HandlerOptions{ 40 | Level: level, 41 | ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { 42 | if a.Key == slog.LevelKey { 43 | level := a.Value.Any().(slog.Level) 44 | levelLabel, exists := levelNames[level] 45 | if !exists { 46 | levelLabel = level.String() 47 | } 48 | a.Value = slog.StringValue(levelLabel) 49 | } 50 | 51 | return a 52 | }, 53 | } 54 | return slog.New(handler{slog.NewTextHandler(os.Stdout, opts)}) 55 | } 56 | 57 | type handler struct { 58 | slog.Handler 59 | } 60 | 61 | func (h handler) Handle(ctx context.Context, r slog.Record) error { 62 | if sn := ctx.Value("screenName"); sn != nil { 63 | r.AddAttrs(slog.Attr{Key: "screenName", Value: slog.StringValue(sn.(fmt.Stringer).String())}) 64 | } 65 | if ip := ctx.Value("ip"); ip != nil { 66 | r.AddAttrs(slog.Attr{Key: "ip", Value: slog.StringValue(ip.(string))}) 67 | } 68 | return h.Handler.Handle(ctx, r) 69 | } 70 | 71 | func (h handler) WithAttrs(attrs []slog.Attr) slog.Handler { 72 | return handler{h.Handler.WithAttrs(attrs)} 73 | } 74 | 75 | func (h handler) WithGroup(name string) slog.Handler { 76 | return h.Handler.WithGroup(name) 77 | } 78 | 79 | type RouteLogger struct { 80 | Logger *slog.Logger 81 | } 82 | 83 | func (rt RouteLogger) LogRequestAndResponse(ctx context.Context, inFrame wire.SNACFrame, inSNAC any, outFrame wire.SNACFrame, outSNAC any) { 84 | msg := "client request -> server response" 85 | switch { 86 | case rt.Logger.Enabled(ctx, LevelTrace): 87 | rt.Logger.LogAttrs(ctx, LevelTrace, msg, snacLogGroupWithPayload("request", inFrame, inSNAC), 88 | snacLogGroupWithPayload("response", outFrame, outSNAC)) 89 | case rt.Logger.Enabled(ctx, slog.LevelDebug): 90 | rt.Logger.LogAttrs(ctx, slog.LevelDebug, msg, snacLogGroup("request", inFrame), 91 | snacLogGroup("response", outFrame)) 92 | } 93 | } 94 | 95 | func (rt RouteLogger) LogRequestError(ctx context.Context, inFrame wire.SNACFrame, err error) { 96 | LogRequestError(ctx, rt.Logger, inFrame, err) 97 | } 98 | 99 | func LogRequestError(ctx context.Context, logger *slog.Logger, inFrame wire.SNACFrame, err error) { 100 | logger.LogAttrs(ctx, slog.LevelError, "client request error", 101 | slog.Group("request", 102 | slog.String("food_group", wire.FoodGroupName(inFrame.FoodGroup)), 103 | slog.String("sub_group", wire.SubGroupName(inFrame.FoodGroup, inFrame.SubGroup)), 104 | ), 105 | slog.String("err", err.Error()), 106 | ) 107 | } 108 | 109 | func (rt RouteLogger) LogRequest(ctx context.Context, inFrame wire.SNACFrame, inSNAC any) { 110 | LogRequest(ctx, rt.Logger, inFrame, inSNAC) 111 | } 112 | 113 | func LogRequest(ctx context.Context, logger *slog.Logger, inFrame wire.SNACFrame, inSNAC any) { 114 | const msg = "client request" 115 | switch { 116 | case logger.Enabled(ctx, LevelTrace): 117 | logger.LogAttrs(ctx, LevelTrace, msg, snacLogGroupWithPayload("request", inFrame, inSNAC)) 118 | case logger.Enabled(ctx, slog.LevelDebug): 119 | logger.LogAttrs(ctx, slog.LevelDebug, msg, snacLogGroup("request", inFrame)) 120 | } 121 | } 122 | 123 | func snacLogGroup(key string, outFrame wire.SNACFrame) slog.Attr { 124 | return slog.Group(key, 125 | slog.String("food_group", wire.FoodGroupName(outFrame.FoodGroup)), 126 | slog.String("sub_group", wire.SubGroupName(outFrame.FoodGroup, outFrame.SubGroup)), 127 | ) 128 | } 129 | 130 | func snacLogGroupWithPayload(key string, outFrame wire.SNACFrame, outSNAC any) slog.Attr { 131 | return slog.Group(key, 132 | slog.String("food_group", wire.FoodGroupName(outFrame.FoodGroup)), 133 | slog.String("sub_group", wire.SubGroupName(outFrame.FoodGroup, outFrame.SubGroup)), 134 | slog.Any("snac_frame", outFrame), 135 | slog.Any("snac_payload", outSNAC), 136 | ) 137 | } 138 | -------------------------------------------------------------------------------- /server/kerberos/kerberos.go: -------------------------------------------------------------------------------- 1 | package kerberos 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "log/slog" 10 | "net/http" 11 | 12 | "golang.org/x/sync/errgroup" 13 | 14 | "github.com/mk6i/retro-aim-server/config" 15 | "github.com/mk6i/retro-aim-server/state" 16 | "github.com/mk6i/retro-aim-server/wire" 17 | ) 18 | 19 | type AuthService interface { 20 | KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.SNACMessage, error) 21 | } 22 | 23 | func NewKerberosServer(listeners []config.Listener, logger *slog.Logger, authService AuthService) *Server { 24 | servers := make([]*http.Server, 0, len(listeners)) 25 | 26 | for _, l := range listeners { 27 | if l.KerberosListenAddress == "" { 28 | continue 29 | } 30 | 31 | mux := http.NewServeMux() 32 | 33 | mux.HandleFunc("POST /", func(writer http.ResponseWriter, request *http.Request) { 34 | postHandler(writer, request, authService, logger, l.BOSAdvertisedHostSSL) 35 | }) 36 | 37 | servers = append(servers, &http.Server{ 38 | Addr: l.KerberosListenAddress, 39 | Handler: mux, 40 | }) 41 | } 42 | 43 | return &Server{ 44 | servers: servers, 45 | logger: logger, 46 | } 47 | } 48 | 49 | // Server hosts an HTTP endpoint capable of handling AIM-style Kerberos 50 | // authentication. The messages are structured as SNACs transmitted over HTTP. 51 | type Server struct { 52 | servers []*http.Server 53 | logger *slog.Logger 54 | } 55 | 56 | func (s *Server) ListenAndServe() error { 57 | if len(s.servers) == 0 { 58 | s.logger.Debug("no kerberos listeners defined") 59 | return nil 60 | } 61 | 62 | ctx, cancel := context.WithCancel(context.Background()) 63 | defer cancel() 64 | 65 | g, ctx := errgroup.WithContext(ctx) 66 | for _, server := range s.servers { 67 | g.Go(func() error { 68 | s.logger.Info("starting server", "addr", server.Addr) 69 | if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { 70 | cancel() 71 | return fmt.Errorf("unable to start kerberos server: %w", err) 72 | } 73 | return nil 74 | }) 75 | } 76 | 77 | return g.Wait() 78 | } 79 | 80 | func (s *Server) Shutdown(ctx context.Context) error { 81 | if len(s.servers) > 0 { 82 | for _, srv := range s.servers { 83 | _ = srv.Shutdown(ctx) 84 | } 85 | s.logger.Info("shutdown complete") 86 | } 87 | return nil 88 | } 89 | 90 | // postHandler handles AIM-style Kerberos authentication for AIM 6.0+. 91 | func postHandler(w http.ResponseWriter, r *http.Request, authService AuthService, logger *slog.Logger, listenAddress string) { 92 | b, err := io.ReadAll(r.Body) 93 | if err != nil { 94 | http.Error(w, "unable to read HTTP body", http.StatusBadRequest) 95 | return 96 | } 97 | reader := bytes.NewReader(b) 98 | 99 | var header wire.SNACFrame 100 | if err := wire.UnmarshalBE(&header, reader); err != nil { 101 | http.Error(w, "unable to read kerberos login SNAC header", http.StatusBadRequest) 102 | return 103 | } 104 | if header.FoodGroup != wire.Kerberos || header.SubGroup != wire.KerberosLoginRequest { 105 | http.Error(w, "unexpected SNAC type", http.StatusBadRequest) 106 | return 107 | } 108 | 109 | var body wire.SNAC_0x050C_0x0002_KerberosLoginRequest 110 | if err := wire.UnmarshalBE(&body, reader); err != nil { 111 | http.Error(w, "unable to read kerberos login SNAC body", http.StatusBadRequest) 112 | return 113 | } 114 | 115 | response, err := authService.KerberosLogin(r.Context(), body, state.NewStubUser, listenAddress) 116 | if err != nil { 117 | logger.Error("authService.KerberosLogin", "err", err.Error()) 118 | http.Error(w, "internal server error", http.StatusInternalServerError) 119 | return 120 | } 121 | 122 | logger = logger.With("ip", r.RemoteAddr) 123 | switch v := response.Body.(type) { 124 | case wire.SNAC_0x050C_0x0003_KerberosLoginSuccessResponse: 125 | logger.InfoContext(r.Context(), "successful kerberos login", "screen_name", v.ClientPrincipal, "redirect_to", listenAddress) 126 | case wire.SNAC_0x050C_0x0004_KerberosLoginErrResponse: 127 | logger.InfoContext(r.Context(), "failed kerberos login", "screen_name", v.ScreenName) 128 | } 129 | 130 | w.Header().Set("Content-Type", "application/x-snac") 131 | 132 | if err := wire.MarshalBE(response, w); err != nil { 133 | logger.Error("unable to marshal SNAC response", "err", err.Error()) 134 | http.Error(w, "internal server error", http.StatusInternalServerError) 135 | return 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /server/toc/mock_admin_service_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package toc 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/mk6i/retro-aim-server/state" 11 | "github.com/mk6i/retro-aim-server/wire" 12 | mock "github.com/stretchr/testify/mock" 13 | ) 14 | 15 | // newMockAdminService creates a new instance of mockAdminService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 16 | // The first argument is typically a *testing.T value. 17 | func newMockAdminService(t interface { 18 | mock.TestingT 19 | Cleanup(func()) 20 | }) *mockAdminService { 21 | mock := &mockAdminService{} 22 | mock.Mock.Test(t) 23 | 24 | t.Cleanup(func() { mock.AssertExpectations(t) }) 25 | 26 | return mock 27 | } 28 | 29 | // mockAdminService is an autogenerated mock type for the AdminService type 30 | type mockAdminService struct { 31 | mock.Mock 32 | } 33 | 34 | type mockAdminService_Expecter struct { 35 | mock *mock.Mock 36 | } 37 | 38 | func (_m *mockAdminService) EXPECT() *mockAdminService_Expecter { 39 | return &mockAdminService_Expecter{mock: &_m.Mock} 40 | } 41 | 42 | // InfoChangeRequest provides a mock function for the type mockAdminService 43 | func (_mock *mockAdminService) InfoChangeRequest(ctx context.Context, sess *state.Session, frame wire.SNACFrame, body wire.SNAC_0x07_0x04_AdminInfoChangeRequest) (wire.SNACMessage, error) { 44 | ret := _mock.Called(ctx, sess, frame, body) 45 | 46 | if len(ret) == 0 { 47 | panic("no return value specified for InfoChangeRequest") 48 | } 49 | 50 | var r0 wire.SNACMessage 51 | var r1 error 52 | if returnFunc, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x07_0x04_AdminInfoChangeRequest) (wire.SNACMessage, error)); ok { 53 | return returnFunc(ctx, sess, frame, body) 54 | } 55 | if returnFunc, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x07_0x04_AdminInfoChangeRequest) wire.SNACMessage); ok { 56 | r0 = returnFunc(ctx, sess, frame, body) 57 | } else { 58 | r0 = ret.Get(0).(wire.SNACMessage) 59 | } 60 | if returnFunc, ok := ret.Get(1).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x07_0x04_AdminInfoChangeRequest) error); ok { 61 | r1 = returnFunc(ctx, sess, frame, body) 62 | } else { 63 | r1 = ret.Error(1) 64 | } 65 | return r0, r1 66 | } 67 | 68 | // mockAdminService_InfoChangeRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InfoChangeRequest' 69 | type mockAdminService_InfoChangeRequest_Call struct { 70 | *mock.Call 71 | } 72 | 73 | // InfoChangeRequest is a helper method to define mock.On call 74 | // - ctx context.Context 75 | // - sess *state.Session 76 | // - frame wire.SNACFrame 77 | // - body wire.SNAC_0x07_0x04_AdminInfoChangeRequest 78 | func (_e *mockAdminService_Expecter) InfoChangeRequest(ctx interface{}, sess interface{}, frame interface{}, body interface{}) *mockAdminService_InfoChangeRequest_Call { 79 | return &mockAdminService_InfoChangeRequest_Call{Call: _e.mock.On("InfoChangeRequest", ctx, sess, frame, body)} 80 | } 81 | 82 | func (_c *mockAdminService_InfoChangeRequest_Call) Run(run func(ctx context.Context, sess *state.Session, frame wire.SNACFrame, body wire.SNAC_0x07_0x04_AdminInfoChangeRequest)) *mockAdminService_InfoChangeRequest_Call { 83 | _c.Call.Run(func(args mock.Arguments) { 84 | var arg0 context.Context 85 | if args[0] != nil { 86 | arg0 = args[0].(context.Context) 87 | } 88 | var arg1 *state.Session 89 | if args[1] != nil { 90 | arg1 = args[1].(*state.Session) 91 | } 92 | var arg2 wire.SNACFrame 93 | if args[2] != nil { 94 | arg2 = args[2].(wire.SNACFrame) 95 | } 96 | var arg3 wire.SNAC_0x07_0x04_AdminInfoChangeRequest 97 | if args[3] != nil { 98 | arg3 = args[3].(wire.SNAC_0x07_0x04_AdminInfoChangeRequest) 99 | } 100 | run( 101 | arg0, 102 | arg1, 103 | arg2, 104 | arg3, 105 | ) 106 | }) 107 | return _c 108 | } 109 | 110 | func (_c *mockAdminService_InfoChangeRequest_Call) Return(sNACMessage wire.SNACMessage, err error) *mockAdminService_InfoChangeRequest_Call { 111 | _c.Call.Return(sNACMessage, err) 112 | return _c 113 | } 114 | 115 | func (_c *mockAdminService_InfoChangeRequest_Call) RunAndReturn(run func(ctx context.Context, sess *state.Session, frame wire.SNACFrame, body wire.SNAC_0x07_0x04_AdminInfoChangeRequest) (wire.SNACMessage, error)) *mockAdminService_InfoChangeRequest_Call { 116 | _c.Call.Return(run) 117 | return _c 118 | } 119 | -------------------------------------------------------------------------------- /server/toc/mock_chat_service_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package toc 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/mk6i/retro-aim-server/state" 11 | "github.com/mk6i/retro-aim-server/wire" 12 | mock "github.com/stretchr/testify/mock" 13 | ) 14 | 15 | // newMockChatService creates a new instance of mockChatService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 16 | // The first argument is typically a *testing.T value. 17 | func newMockChatService(t interface { 18 | mock.TestingT 19 | Cleanup(func()) 20 | }) *mockChatService { 21 | mock := &mockChatService{} 22 | mock.Mock.Test(t) 23 | 24 | t.Cleanup(func() { mock.AssertExpectations(t) }) 25 | 26 | return mock 27 | } 28 | 29 | // mockChatService is an autogenerated mock type for the ChatService type 30 | type mockChatService struct { 31 | mock.Mock 32 | } 33 | 34 | type mockChatService_Expecter struct { 35 | mock *mock.Mock 36 | } 37 | 38 | func (_m *mockChatService) EXPECT() *mockChatService_Expecter { 39 | return &mockChatService_Expecter{mock: &_m.Mock} 40 | } 41 | 42 | // ChannelMsgToHost provides a mock function for the type mockChatService 43 | func (_mock *mockChatService) ChannelMsgToHost(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*wire.SNACMessage, error) { 44 | ret := _mock.Called(ctx, sess, inFrame, inBody) 45 | 46 | if len(ret) == 0 { 47 | panic("no return value specified for ChannelMsgToHost") 48 | } 49 | 50 | var r0 *wire.SNACMessage 51 | var r1 error 52 | if returnFunc, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*wire.SNACMessage, error)); ok { 53 | return returnFunc(ctx, sess, inFrame, inBody) 54 | } 55 | if returnFunc, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x0E_0x05_ChatChannelMsgToHost) *wire.SNACMessage); ok { 56 | r0 = returnFunc(ctx, sess, inFrame, inBody) 57 | } else { 58 | if ret.Get(0) != nil { 59 | r0 = ret.Get(0).(*wire.SNACMessage) 60 | } 61 | } 62 | if returnFunc, ok := ret.Get(1).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x0E_0x05_ChatChannelMsgToHost) error); ok { 63 | r1 = returnFunc(ctx, sess, inFrame, inBody) 64 | } else { 65 | r1 = ret.Error(1) 66 | } 67 | return r0, r1 68 | } 69 | 70 | // mockChatService_ChannelMsgToHost_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ChannelMsgToHost' 71 | type mockChatService_ChannelMsgToHost_Call struct { 72 | *mock.Call 73 | } 74 | 75 | // ChannelMsgToHost is a helper method to define mock.On call 76 | // - ctx context.Context 77 | // - sess *state.Session 78 | // - inFrame wire.SNACFrame 79 | // - inBody wire.SNAC_0x0E_0x05_ChatChannelMsgToHost 80 | func (_e *mockChatService_Expecter) ChannelMsgToHost(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockChatService_ChannelMsgToHost_Call { 81 | return &mockChatService_ChannelMsgToHost_Call{Call: _e.mock.On("ChannelMsgToHost", ctx, sess, inFrame, inBody)} 82 | } 83 | 84 | func (_c *mockChatService_ChannelMsgToHost_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x05_ChatChannelMsgToHost)) *mockChatService_ChannelMsgToHost_Call { 85 | _c.Call.Run(func(args mock.Arguments) { 86 | var arg0 context.Context 87 | if args[0] != nil { 88 | arg0 = args[0].(context.Context) 89 | } 90 | var arg1 *state.Session 91 | if args[1] != nil { 92 | arg1 = args[1].(*state.Session) 93 | } 94 | var arg2 wire.SNACFrame 95 | if args[2] != nil { 96 | arg2 = args[2].(wire.SNACFrame) 97 | } 98 | var arg3 wire.SNAC_0x0E_0x05_ChatChannelMsgToHost 99 | if args[3] != nil { 100 | arg3 = args[3].(wire.SNAC_0x0E_0x05_ChatChannelMsgToHost) 101 | } 102 | run( 103 | arg0, 104 | arg1, 105 | arg2, 106 | arg3, 107 | ) 108 | }) 109 | return _c 110 | } 111 | 112 | func (_c *mockChatService_ChannelMsgToHost_Call) Return(sNACMessage *wire.SNACMessage, err error) *mockChatService_ChannelMsgToHost_Call { 113 | _c.Call.Return(sNACMessage, err) 114 | return _c 115 | } 116 | 117 | func (_c *mockChatService_ChannelMsgToHost_Call) RunAndReturn(run func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*wire.SNACMessage, error)) *mockChatService_ChannelMsgToHost_Call { 118 | _c.Call.Return(run) 119 | return _c 120 | } 121 | -------------------------------------------------------------------------------- /server/oscar/mock_chat_service_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package oscar 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/mk6i/retro-aim-server/state" 11 | "github.com/mk6i/retro-aim-server/wire" 12 | mock "github.com/stretchr/testify/mock" 13 | ) 14 | 15 | // newMockChatService creates a new instance of mockChatService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 16 | // The first argument is typically a *testing.T value. 17 | func newMockChatService(t interface { 18 | mock.TestingT 19 | Cleanup(func()) 20 | }) *mockChatService { 21 | mock := &mockChatService{} 22 | mock.Mock.Test(t) 23 | 24 | t.Cleanup(func() { mock.AssertExpectations(t) }) 25 | 26 | return mock 27 | } 28 | 29 | // mockChatService is an autogenerated mock type for the ChatService type 30 | type mockChatService struct { 31 | mock.Mock 32 | } 33 | 34 | type mockChatService_Expecter struct { 35 | mock *mock.Mock 36 | } 37 | 38 | func (_m *mockChatService) EXPECT() *mockChatService_Expecter { 39 | return &mockChatService_Expecter{mock: &_m.Mock} 40 | } 41 | 42 | // ChannelMsgToHost provides a mock function for the type mockChatService 43 | func (_mock *mockChatService) ChannelMsgToHost(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*wire.SNACMessage, error) { 44 | ret := _mock.Called(ctx, sess, inFrame, inBody) 45 | 46 | if len(ret) == 0 { 47 | panic("no return value specified for ChannelMsgToHost") 48 | } 49 | 50 | var r0 *wire.SNACMessage 51 | var r1 error 52 | if returnFunc, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*wire.SNACMessage, error)); ok { 53 | return returnFunc(ctx, sess, inFrame, inBody) 54 | } 55 | if returnFunc, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x0E_0x05_ChatChannelMsgToHost) *wire.SNACMessage); ok { 56 | r0 = returnFunc(ctx, sess, inFrame, inBody) 57 | } else { 58 | if ret.Get(0) != nil { 59 | r0 = ret.Get(0).(*wire.SNACMessage) 60 | } 61 | } 62 | if returnFunc, ok := ret.Get(1).(func(context.Context, *state.Session, wire.SNACFrame, wire.SNAC_0x0E_0x05_ChatChannelMsgToHost) error); ok { 63 | r1 = returnFunc(ctx, sess, inFrame, inBody) 64 | } else { 65 | r1 = ret.Error(1) 66 | } 67 | return r0, r1 68 | } 69 | 70 | // mockChatService_ChannelMsgToHost_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ChannelMsgToHost' 71 | type mockChatService_ChannelMsgToHost_Call struct { 72 | *mock.Call 73 | } 74 | 75 | // ChannelMsgToHost is a helper method to define mock.On call 76 | // - ctx context.Context 77 | // - sess *state.Session 78 | // - inFrame wire.SNACFrame 79 | // - inBody wire.SNAC_0x0E_0x05_ChatChannelMsgToHost 80 | func (_e *mockChatService_Expecter) ChannelMsgToHost(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockChatService_ChannelMsgToHost_Call { 81 | return &mockChatService_ChannelMsgToHost_Call{Call: _e.mock.On("ChannelMsgToHost", ctx, sess, inFrame, inBody)} 82 | } 83 | 84 | func (_c *mockChatService_ChannelMsgToHost_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x05_ChatChannelMsgToHost)) *mockChatService_ChannelMsgToHost_Call { 85 | _c.Call.Run(func(args mock.Arguments) { 86 | var arg0 context.Context 87 | if args[0] != nil { 88 | arg0 = args[0].(context.Context) 89 | } 90 | var arg1 *state.Session 91 | if args[1] != nil { 92 | arg1 = args[1].(*state.Session) 93 | } 94 | var arg2 wire.SNACFrame 95 | if args[2] != nil { 96 | arg2 = args[2].(wire.SNACFrame) 97 | } 98 | var arg3 wire.SNAC_0x0E_0x05_ChatChannelMsgToHost 99 | if args[3] != nil { 100 | arg3 = args[3].(wire.SNAC_0x0E_0x05_ChatChannelMsgToHost) 101 | } 102 | run( 103 | arg0, 104 | arg1, 105 | arg2, 106 | arg3, 107 | ) 108 | }) 109 | return _c 110 | } 111 | 112 | func (_c *mockChatService_ChannelMsgToHost_Call) Return(sNACMessage *wire.SNACMessage, err error) *mockChatService_ChannelMsgToHost_Call { 113 | _c.Call.Return(sNACMessage, err) 114 | return _c 115 | } 116 | 117 | func (_c *mockChatService_ChannelMsgToHost_Call) RunAndReturn(run func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*wire.SNACMessage, error)) *mockChatService_ChannelMsgToHost_Call { 118 | _c.Call.Return(run) 119 | return _c 120 | } 121 | -------------------------------------------------------------------------------- /server/http/mock_session_retriever_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package http 6 | 7 | import ( 8 | "github.com/mk6i/retro-aim-server/state" 9 | mock "github.com/stretchr/testify/mock" 10 | ) 11 | 12 | // newMockSessionRetriever creates a new instance of mockSessionRetriever. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 13 | // The first argument is typically a *testing.T value. 14 | func newMockSessionRetriever(t interface { 15 | mock.TestingT 16 | Cleanup(func()) 17 | }) *mockSessionRetriever { 18 | mock := &mockSessionRetriever{} 19 | mock.Mock.Test(t) 20 | 21 | t.Cleanup(func() { mock.AssertExpectations(t) }) 22 | 23 | return mock 24 | } 25 | 26 | // mockSessionRetriever is an autogenerated mock type for the SessionRetriever type 27 | type mockSessionRetriever struct { 28 | mock.Mock 29 | } 30 | 31 | type mockSessionRetriever_Expecter struct { 32 | mock *mock.Mock 33 | } 34 | 35 | func (_m *mockSessionRetriever) EXPECT() *mockSessionRetriever_Expecter { 36 | return &mockSessionRetriever_Expecter{mock: &_m.Mock} 37 | } 38 | 39 | // AllSessions provides a mock function for the type mockSessionRetriever 40 | func (_mock *mockSessionRetriever) AllSessions() []*state.Session { 41 | ret := _mock.Called() 42 | 43 | if len(ret) == 0 { 44 | panic("no return value specified for AllSessions") 45 | } 46 | 47 | var r0 []*state.Session 48 | if returnFunc, ok := ret.Get(0).(func() []*state.Session); ok { 49 | r0 = returnFunc() 50 | } else { 51 | if ret.Get(0) != nil { 52 | r0 = ret.Get(0).([]*state.Session) 53 | } 54 | } 55 | return r0 56 | } 57 | 58 | // mockSessionRetriever_AllSessions_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AllSessions' 59 | type mockSessionRetriever_AllSessions_Call struct { 60 | *mock.Call 61 | } 62 | 63 | // AllSessions is a helper method to define mock.On call 64 | func (_e *mockSessionRetriever_Expecter) AllSessions() *mockSessionRetriever_AllSessions_Call { 65 | return &mockSessionRetriever_AllSessions_Call{Call: _e.mock.On("AllSessions")} 66 | } 67 | 68 | func (_c *mockSessionRetriever_AllSessions_Call) Run(run func()) *mockSessionRetriever_AllSessions_Call { 69 | _c.Call.Run(func(args mock.Arguments) { 70 | run() 71 | }) 72 | return _c 73 | } 74 | 75 | func (_c *mockSessionRetriever_AllSessions_Call) Return(sessions []*state.Session) *mockSessionRetriever_AllSessions_Call { 76 | _c.Call.Return(sessions) 77 | return _c 78 | } 79 | 80 | func (_c *mockSessionRetriever_AllSessions_Call) RunAndReturn(run func() []*state.Session) *mockSessionRetriever_AllSessions_Call { 81 | _c.Call.Return(run) 82 | return _c 83 | } 84 | 85 | // RetrieveSession provides a mock function for the type mockSessionRetriever 86 | func (_mock *mockSessionRetriever) RetrieveSession(screenName state.IdentScreenName) *state.Session { 87 | ret := _mock.Called(screenName) 88 | 89 | if len(ret) == 0 { 90 | panic("no return value specified for RetrieveSession") 91 | } 92 | 93 | var r0 *state.Session 94 | if returnFunc, ok := ret.Get(0).(func(state.IdentScreenName) *state.Session); ok { 95 | r0 = returnFunc(screenName) 96 | } else { 97 | if ret.Get(0) != nil { 98 | r0 = ret.Get(0).(*state.Session) 99 | } 100 | } 101 | return r0 102 | } 103 | 104 | // mockSessionRetriever_RetrieveSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RetrieveSession' 105 | type mockSessionRetriever_RetrieveSession_Call struct { 106 | *mock.Call 107 | } 108 | 109 | // RetrieveSession is a helper method to define mock.On call 110 | // - screenName state.IdentScreenName 111 | func (_e *mockSessionRetriever_Expecter) RetrieveSession(screenName interface{}) *mockSessionRetriever_RetrieveSession_Call { 112 | return &mockSessionRetriever_RetrieveSession_Call{Call: _e.mock.On("RetrieveSession", screenName)} 113 | } 114 | 115 | func (_c *mockSessionRetriever_RetrieveSession_Call) Run(run func(screenName state.IdentScreenName)) *mockSessionRetriever_RetrieveSession_Call { 116 | _c.Call.Run(func(args mock.Arguments) { 117 | var arg0 state.IdentScreenName 118 | if args[0] != nil { 119 | arg0 = args[0].(state.IdentScreenName) 120 | } 121 | run( 122 | arg0, 123 | ) 124 | }) 125 | return _c 126 | } 127 | 128 | func (_c *mockSessionRetriever_RetrieveSession_Call) Return(session *state.Session) *mockSessionRetriever_RetrieveSession_Call { 129 | _c.Call.Return(session) 130 | return _c 131 | } 132 | 133 | func (_c *mockSessionRetriever_RetrieveSession_Call) RunAndReturn(run func(screenName state.IdentScreenName) *state.Session) *mockSessionRetriever_RetrieveSession_Call { 134 | _c.Call.Return(run) 135 | return _c 136 | } 137 | -------------------------------------------------------------------------------- /server/toc/mock_cookie_baker_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package toc 6 | 7 | import ( 8 | mock "github.com/stretchr/testify/mock" 9 | ) 10 | 11 | // newMockCookieBaker creates a new instance of mockCookieBaker. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 12 | // The first argument is typically a *testing.T value. 13 | func newMockCookieBaker(t interface { 14 | mock.TestingT 15 | Cleanup(func()) 16 | }) *mockCookieBaker { 17 | mock := &mockCookieBaker{} 18 | mock.Mock.Test(t) 19 | 20 | t.Cleanup(func() { mock.AssertExpectations(t) }) 21 | 22 | return mock 23 | } 24 | 25 | // mockCookieBaker is an autogenerated mock type for the CookieBaker type 26 | type mockCookieBaker struct { 27 | mock.Mock 28 | } 29 | 30 | type mockCookieBaker_Expecter struct { 31 | mock *mock.Mock 32 | } 33 | 34 | func (_m *mockCookieBaker) EXPECT() *mockCookieBaker_Expecter { 35 | return &mockCookieBaker_Expecter{mock: &_m.Mock} 36 | } 37 | 38 | // Crack provides a mock function for the type mockCookieBaker 39 | func (_mock *mockCookieBaker) Crack(data []byte) ([]byte, error) { 40 | ret := _mock.Called(data) 41 | 42 | if len(ret) == 0 { 43 | panic("no return value specified for Crack") 44 | } 45 | 46 | var r0 []byte 47 | var r1 error 48 | if returnFunc, ok := ret.Get(0).(func([]byte) ([]byte, error)); ok { 49 | return returnFunc(data) 50 | } 51 | if returnFunc, ok := ret.Get(0).(func([]byte) []byte); ok { 52 | r0 = returnFunc(data) 53 | } else { 54 | if ret.Get(0) != nil { 55 | r0 = ret.Get(0).([]byte) 56 | } 57 | } 58 | if returnFunc, ok := ret.Get(1).(func([]byte) error); ok { 59 | r1 = returnFunc(data) 60 | } else { 61 | r1 = ret.Error(1) 62 | } 63 | return r0, r1 64 | } 65 | 66 | // mockCookieBaker_Crack_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Crack' 67 | type mockCookieBaker_Crack_Call struct { 68 | *mock.Call 69 | } 70 | 71 | // Crack is a helper method to define mock.On call 72 | // - data []byte 73 | func (_e *mockCookieBaker_Expecter) Crack(data interface{}) *mockCookieBaker_Crack_Call { 74 | return &mockCookieBaker_Crack_Call{Call: _e.mock.On("Crack", data)} 75 | } 76 | 77 | func (_c *mockCookieBaker_Crack_Call) Run(run func(data []byte)) *mockCookieBaker_Crack_Call { 78 | _c.Call.Run(func(args mock.Arguments) { 79 | var arg0 []byte 80 | if args[0] != nil { 81 | arg0 = args[0].([]byte) 82 | } 83 | run( 84 | arg0, 85 | ) 86 | }) 87 | return _c 88 | } 89 | 90 | func (_c *mockCookieBaker_Crack_Call) Return(bytes []byte, err error) *mockCookieBaker_Crack_Call { 91 | _c.Call.Return(bytes, err) 92 | return _c 93 | } 94 | 95 | func (_c *mockCookieBaker_Crack_Call) RunAndReturn(run func(data []byte) ([]byte, error)) *mockCookieBaker_Crack_Call { 96 | _c.Call.Return(run) 97 | return _c 98 | } 99 | 100 | // Issue provides a mock function for the type mockCookieBaker 101 | func (_mock *mockCookieBaker) Issue(data []byte) ([]byte, error) { 102 | ret := _mock.Called(data) 103 | 104 | if len(ret) == 0 { 105 | panic("no return value specified for Issue") 106 | } 107 | 108 | var r0 []byte 109 | var r1 error 110 | if returnFunc, ok := ret.Get(0).(func([]byte) ([]byte, error)); ok { 111 | return returnFunc(data) 112 | } 113 | if returnFunc, ok := ret.Get(0).(func([]byte) []byte); ok { 114 | r0 = returnFunc(data) 115 | } else { 116 | if ret.Get(0) != nil { 117 | r0 = ret.Get(0).([]byte) 118 | } 119 | } 120 | if returnFunc, ok := ret.Get(1).(func([]byte) error); ok { 121 | r1 = returnFunc(data) 122 | } else { 123 | r1 = ret.Error(1) 124 | } 125 | return r0, r1 126 | } 127 | 128 | // mockCookieBaker_Issue_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Issue' 129 | type mockCookieBaker_Issue_Call struct { 130 | *mock.Call 131 | } 132 | 133 | // Issue is a helper method to define mock.On call 134 | // - data []byte 135 | func (_e *mockCookieBaker_Expecter) Issue(data interface{}) *mockCookieBaker_Issue_Call { 136 | return &mockCookieBaker_Issue_Call{Call: _e.mock.On("Issue", data)} 137 | } 138 | 139 | func (_c *mockCookieBaker_Issue_Call) Run(run func(data []byte)) *mockCookieBaker_Issue_Call { 140 | _c.Call.Run(func(args mock.Arguments) { 141 | var arg0 []byte 142 | if args[0] != nil { 143 | arg0 = args[0].([]byte) 144 | } 145 | run( 146 | arg0, 147 | ) 148 | }) 149 | return _c 150 | } 151 | 152 | func (_c *mockCookieBaker_Issue_Call) Return(bytes []byte, err error) *mockCookieBaker_Issue_Call { 153 | _c.Call.Return(bytes, err) 154 | return _c 155 | } 156 | 157 | func (_c *mockCookieBaker_Issue_Call) RunAndReturn(run func(data []byte) ([]byte, error)) *mockCookieBaker_Issue_Call { 158 | _c.Call.Return(run) 159 | return _c 160 | } 161 | -------------------------------------------------------------------------------- /foodgroup/mock_cookie_baker_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package foodgroup 6 | 7 | import ( 8 | mock "github.com/stretchr/testify/mock" 9 | ) 10 | 11 | // newMockCookieBaker creates a new instance of mockCookieBaker. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 12 | // The first argument is typically a *testing.T value. 13 | func newMockCookieBaker(t interface { 14 | mock.TestingT 15 | Cleanup(func()) 16 | }) *mockCookieBaker { 17 | mock := &mockCookieBaker{} 18 | mock.Mock.Test(t) 19 | 20 | t.Cleanup(func() { mock.AssertExpectations(t) }) 21 | 22 | return mock 23 | } 24 | 25 | // mockCookieBaker is an autogenerated mock type for the CookieBaker type 26 | type mockCookieBaker struct { 27 | mock.Mock 28 | } 29 | 30 | type mockCookieBaker_Expecter struct { 31 | mock *mock.Mock 32 | } 33 | 34 | func (_m *mockCookieBaker) EXPECT() *mockCookieBaker_Expecter { 35 | return &mockCookieBaker_Expecter{mock: &_m.Mock} 36 | } 37 | 38 | // Crack provides a mock function for the type mockCookieBaker 39 | func (_mock *mockCookieBaker) Crack(data []byte) ([]byte, error) { 40 | ret := _mock.Called(data) 41 | 42 | if len(ret) == 0 { 43 | panic("no return value specified for Crack") 44 | } 45 | 46 | var r0 []byte 47 | var r1 error 48 | if returnFunc, ok := ret.Get(0).(func([]byte) ([]byte, error)); ok { 49 | return returnFunc(data) 50 | } 51 | if returnFunc, ok := ret.Get(0).(func([]byte) []byte); ok { 52 | r0 = returnFunc(data) 53 | } else { 54 | if ret.Get(0) != nil { 55 | r0 = ret.Get(0).([]byte) 56 | } 57 | } 58 | if returnFunc, ok := ret.Get(1).(func([]byte) error); ok { 59 | r1 = returnFunc(data) 60 | } else { 61 | r1 = ret.Error(1) 62 | } 63 | return r0, r1 64 | } 65 | 66 | // mockCookieBaker_Crack_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Crack' 67 | type mockCookieBaker_Crack_Call struct { 68 | *mock.Call 69 | } 70 | 71 | // Crack is a helper method to define mock.On call 72 | // - data []byte 73 | func (_e *mockCookieBaker_Expecter) Crack(data interface{}) *mockCookieBaker_Crack_Call { 74 | return &mockCookieBaker_Crack_Call{Call: _e.mock.On("Crack", data)} 75 | } 76 | 77 | func (_c *mockCookieBaker_Crack_Call) Run(run func(data []byte)) *mockCookieBaker_Crack_Call { 78 | _c.Call.Run(func(args mock.Arguments) { 79 | var arg0 []byte 80 | if args[0] != nil { 81 | arg0 = args[0].([]byte) 82 | } 83 | run( 84 | arg0, 85 | ) 86 | }) 87 | return _c 88 | } 89 | 90 | func (_c *mockCookieBaker_Crack_Call) Return(bytes []byte, err error) *mockCookieBaker_Crack_Call { 91 | _c.Call.Return(bytes, err) 92 | return _c 93 | } 94 | 95 | func (_c *mockCookieBaker_Crack_Call) RunAndReturn(run func(data []byte) ([]byte, error)) *mockCookieBaker_Crack_Call { 96 | _c.Call.Return(run) 97 | return _c 98 | } 99 | 100 | // Issue provides a mock function for the type mockCookieBaker 101 | func (_mock *mockCookieBaker) Issue(data []byte) ([]byte, error) { 102 | ret := _mock.Called(data) 103 | 104 | if len(ret) == 0 { 105 | panic("no return value specified for Issue") 106 | } 107 | 108 | var r0 []byte 109 | var r1 error 110 | if returnFunc, ok := ret.Get(0).(func([]byte) ([]byte, error)); ok { 111 | return returnFunc(data) 112 | } 113 | if returnFunc, ok := ret.Get(0).(func([]byte) []byte); ok { 114 | r0 = returnFunc(data) 115 | } else { 116 | if ret.Get(0) != nil { 117 | r0 = ret.Get(0).([]byte) 118 | } 119 | } 120 | if returnFunc, ok := ret.Get(1).(func([]byte) error); ok { 121 | r1 = returnFunc(data) 122 | } else { 123 | r1 = ret.Error(1) 124 | } 125 | return r0, r1 126 | } 127 | 128 | // mockCookieBaker_Issue_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Issue' 129 | type mockCookieBaker_Issue_Call struct { 130 | *mock.Call 131 | } 132 | 133 | // Issue is a helper method to define mock.On call 134 | // - data []byte 135 | func (_e *mockCookieBaker_Expecter) Issue(data interface{}) *mockCookieBaker_Issue_Call { 136 | return &mockCookieBaker_Issue_Call{Call: _e.mock.On("Issue", data)} 137 | } 138 | 139 | func (_c *mockCookieBaker_Issue_Call) Run(run func(data []byte)) *mockCookieBaker_Issue_Call { 140 | _c.Call.Run(func(args mock.Arguments) { 141 | var arg0 []byte 142 | if args[0] != nil { 143 | arg0 = args[0].([]byte) 144 | } 145 | run( 146 | arg0, 147 | ) 148 | }) 149 | return _c 150 | } 151 | 152 | func (_c *mockCookieBaker_Issue_Call) Return(bytes []byte, err error) *mockCookieBaker_Issue_Call { 153 | _c.Call.Return(bytes, err) 154 | return _c 155 | } 156 | 157 | func (_c *mockCookieBaker_Issue_Call) RunAndReturn(run func(data []byte) ([]byte, error)) *mockCookieBaker_Issue_Call { 158 | _c.Call.Return(run) 159 | return _c 160 | } 161 | -------------------------------------------------------------------------------- /server/kerberos/mock_auth_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package kerberos 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/mk6i/retro-aim-server/state" 11 | "github.com/mk6i/retro-aim-server/wire" 12 | mock "github.com/stretchr/testify/mock" 13 | ) 14 | 15 | // newMockAuthService creates a new instance of mockAuthService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 16 | // The first argument is typically a *testing.T value. 17 | func newMockAuthService(t interface { 18 | mock.TestingT 19 | Cleanup(func()) 20 | }) *mockAuthService { 21 | mock := &mockAuthService{} 22 | mock.Mock.Test(t) 23 | 24 | t.Cleanup(func() { mock.AssertExpectations(t) }) 25 | 26 | return mock 27 | } 28 | 29 | // mockAuthService is an autogenerated mock type for the AuthService type 30 | type mockAuthService struct { 31 | mock.Mock 32 | } 33 | 34 | type mockAuthService_Expecter struct { 35 | mock *mock.Mock 36 | } 37 | 38 | func (_m *mockAuthService) EXPECT() *mockAuthService_Expecter { 39 | return &mockAuthService_Expecter{mock: &_m.Mock} 40 | } 41 | 42 | // KerberosLogin provides a mock function for the type mockAuthService 43 | func (_mock *mockAuthService) KerberosLogin(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.SNACMessage, error) { 44 | ret := _mock.Called(ctx, inBody, newUserFn, advertisedHost) 45 | 46 | if len(ret) == 0 { 47 | panic("no return value specified for KerberosLogin") 48 | } 49 | 50 | var r0 wire.SNACMessage 51 | var r1 error 52 | if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, func(screenName state.DisplayScreenName) (state.User, error), string) (wire.SNACMessage, error)); ok { 53 | return returnFunc(ctx, inBody, newUserFn, advertisedHost) 54 | } 55 | if returnFunc, ok := ret.Get(0).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, func(screenName state.DisplayScreenName) (state.User, error), string) wire.SNACMessage); ok { 56 | r0 = returnFunc(ctx, inBody, newUserFn, advertisedHost) 57 | } else { 58 | r0 = ret.Get(0).(wire.SNACMessage) 59 | } 60 | if returnFunc, ok := ret.Get(1).(func(context.Context, wire.SNAC_0x050C_0x0002_KerberosLoginRequest, func(screenName state.DisplayScreenName) (state.User, error), string) error); ok { 61 | r1 = returnFunc(ctx, inBody, newUserFn, advertisedHost) 62 | } else { 63 | r1 = ret.Error(1) 64 | } 65 | return r0, r1 66 | } 67 | 68 | // mockAuthService_KerberosLogin_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'KerberosLogin' 69 | type mockAuthService_KerberosLogin_Call struct { 70 | *mock.Call 71 | } 72 | 73 | // KerberosLogin is a helper method to define mock.On call 74 | // - ctx context.Context 75 | // - inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest 76 | // - newUserFn func(screenName state.DisplayScreenName) (state.User, error) 77 | // - advertisedHost string 78 | func (_e *mockAuthService_Expecter) KerberosLogin(ctx interface{}, inBody interface{}, newUserFn interface{}, advertisedHost interface{}) *mockAuthService_KerberosLogin_Call { 79 | return &mockAuthService_KerberosLogin_Call{Call: _e.mock.On("KerberosLogin", ctx, inBody, newUserFn, advertisedHost)} 80 | } 81 | 82 | func (_c *mockAuthService_KerberosLogin_Call) Run(run func(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string)) *mockAuthService_KerberosLogin_Call { 83 | _c.Call.Run(func(args mock.Arguments) { 84 | var arg0 context.Context 85 | if args[0] != nil { 86 | arg0 = args[0].(context.Context) 87 | } 88 | var arg1 wire.SNAC_0x050C_0x0002_KerberosLoginRequest 89 | if args[1] != nil { 90 | arg1 = args[1].(wire.SNAC_0x050C_0x0002_KerberosLoginRequest) 91 | } 92 | var arg2 func(screenName state.DisplayScreenName) (state.User, error) 93 | if args[2] != nil { 94 | arg2 = args[2].(func(screenName state.DisplayScreenName) (state.User, error)) 95 | } 96 | var arg3 string 97 | if args[3] != nil { 98 | arg3 = args[3].(string) 99 | } 100 | run( 101 | arg0, 102 | arg1, 103 | arg2, 104 | arg3, 105 | ) 106 | }) 107 | return _c 108 | } 109 | 110 | func (_c *mockAuthService_KerberosLogin_Call) Return(sNACMessage wire.SNACMessage, err error) *mockAuthService_KerberosLogin_Call { 111 | _c.Call.Return(sNACMessage, err) 112 | return _c 113 | } 114 | 115 | func (_c *mockAuthService_KerberosLogin_Call) RunAndReturn(run func(ctx context.Context, inBody wire.SNAC_0x050C_0x0002_KerberosLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.SNACMessage, error)) *mockAuthService_KerberosLogin_Call { 116 | _c.Call.Return(run) 117 | return _c 118 | } 119 | -------------------------------------------------------------------------------- /docs/AIM_6_7.md: -------------------------------------------------------------------------------- 1 | # Windows AIM 6.x/7.x Client Setup 2 | 3 | This guide explains how to install and configure **AIM 6.x and 7.x** for use with **Open OSCAR Server**. 4 | 5 |

6 | screenshot of AIM sign-on screen 7 |

8 | 9 | Installation guides are available for the following versions: 10 | 11 | - [AIM 6.0-6.1](#aim-60-61-setup) (BUCP auth) 12 | - [AIM 6.2-7.5](#aim-62-75-setup) (Kerberos auth) 13 | 14 | ## AIM 6.0-6.1 Setup 15 | 16 | ### Installation 17 | 18 | 1. Download AIM 6.x (recommended **AIM 6.1.46.1**) from 19 | the [NINA wiki](https://web.archive.org/web/20250910233232/https://wiki.nina.chat/wiki/Clients/AOL_Instant_Messenger#Windows). 20 | 2. Run the installer and complete the installation. 21 | 3. Close the AIM application. 22 | 4. Open **Task Manager** and end the **AIM (32 bit)** process if it's still running. 23 | 24 | ### Configure Authentication Mode 25 | 26 | AIM 6.x does not expose server settings via the UI. You'll need to edit configuration files manually. 27 | 28 | To switch from the default Kerberos-based auth (AAM/AAMUAS) to BUCP: 29 | 30 | 1. Open **Notepad as Administrator** (Start → type "Notepad" → right-click → **Run as Administrator**). 31 | 2. In Notepad, go to **File → Open**. 32 | 3. Navigate to: 33 | ``` 34 | C:\Program Files (x86)\AIM6\services\im\ver1_14_9_1 35 | ``` 36 | 4. Change the file filter to **All Files**. 37 | 5. Open `serviceManifest.xml`. 38 | 6. Locate the `aol.im.connect.mode` and `aol.im.connect.mode2` preferences and change them from `AAM` and `AAMUAS` to 39 | `BUCP`: 40 | 41 | ```diff 42 | -AAM 43 | +BUCP 44 | -AAMUAS 45 | +BUCP 46 | ``` 47 | 48 | 7. Save the file. 49 | 50 | ### Configure Server Hostname 51 | 52 | To point the client to your Open OSCAR Server: 53 | 54 | 1. In Notepad, go to **File → Open** again. 55 | 2. Navigate to: 56 | ``` 57 | C:\Program Files (x86)\AIM6\services\imApp\ver6_1_46_1 58 | ``` 59 | 3. Set the file filter to **All Files**. 60 | 4. Open `serviceManifest.xml`. 61 | 5. Find the `aol.aimcc.connect.host.address` preference and update it to match the hostname from your 62 | `OSCAR_ADVERTISED_LISTENERS_PLAIN` Open OSCAR Server config. For example, if 63 | `OSCAR_ADVERTISED_LISTENERS_PLAIN=LOCAL://127.0.0.1:5190`, use `127.0.0.1`. 64 | 65 | ```diff 66 | -login.oscar.aol.com 67 | +127.0.0.1 68 | ``` 69 | 70 | 6. Save the file. 71 | 72 | ## AIM 6.2-7.5 Setup 73 | 74 | ### Installation 75 | 76 | 1. Download an AIM 6.x or 7.x client (recommended **6.9.17.2** or **7.5.8.2**) from the 77 | [NINA wiki](https://web.archive.org/web/20250910233232/https://wiki.nina.chat/wiki/Clients/AOL_Instant_Messenger#Windows). 78 | 2. Run the installer and complete the installation. 79 | 3. Close the AIM application. 80 | 4. Open **Task Manager** and end the **AIM (32 bit)** process if it's still running. 81 | 82 | ### Install SSL Certificate Database 83 | 84 | Install your server's SSL certificate database generated from the [server setup guide](DOCKER.md) or provided by the 85 | server operator. 86 | 87 | Copy the following files to `%APPDATA%\acccore\nss`. Note that the `nss` directory must be created if it does not exist. 88 | 89 | - `cert8.db` 90 | - `key3.db` 91 | - `secmod.db` 92 | 93 | ### Configure Server Hostname 94 | 95 | Tell AIM where to connect: 96 | 97 | 1. Start AIM. 98 | 2. Open **Settings**, then go to the **Connection** tab. 99 | 3. In the **Host** field, enter the domain name that matches the certificate's Common Name (CN). 100 | 4. In the **Port** field, enter `443`. 101 | 5. Click **Save**, then sign in! 102 | 103 | ## Enable Legacy JavaScript Engine (Windows 11 24H2+ Only) 104 | 105 | AIM 6.x's frontend breaks under the new JavaScript engine introduced in Windows 11 24H2. A workaround described by 106 | [axelsw.it](https://www.axelsw.it/pwiki/index.php/JScript_Windows11) forces Windows to use an older JavaScript engine 107 | compatible with AIM 6.x. 108 | 109 | > ⚠️ Downgrading the JavaScript engine is generally a bad idea, as it may expose your system to vulnerabilities fixed in 110 | > newer engines. 111 | > **Proceed at your own risk!** 112 | 113 | To implement the workaround, create a `.reg` file with the following content. Double-click the file in Windows Explorer 114 | to apply the change. 115 | 116 | ``` 117 | Windows Registry Editor Version 5.00 118 | 119 | [HKEY_CURRENT_USER\Software\Policies\Microsoft\Internet Explorer\Main] 120 | "JScriptReplacement"=dword:00000000 121 | ``` 122 | --------------------------------------------------------------------------------