Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,2 +1,9 @@
bundle/*.tar.xz filter=lfs diff=lfs merge=lfs -text
bundle/*.sha256 -text

# Go tooling (gofmt, staticcheck) assumes LF; without this, Windows
# checkouts under core.autocrlf=true get CRLF working copies that make
# `gofmt -l` flag every file.
*.go text eol=lf
go.mod text eol=lf
go.sum text eol=lf
63 changes: 63 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Build, vet, test, and static-analyze on every push and PR. Runs on Linux
# deliberately: internal/carbonyl's runner is //go:build linux, so analyzing
# under any other GOOS false-positives its helpers as dead code.
#
# No LFS checkout here — the Carbonyl bundle is only needed for docker
# builds (deploy.yml); pulling ~107 MB per CI run would burn LFS bandwidth
# for nothing.
name: ci

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
test:
name: build + test + lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- uses: actions/setup-go@v6
with:
go-version-file: go.mod

- name: Build
run: go build ./...

- name: Vet
run: go vet ./...

- name: Test
run: go test ./...

- name: gofmt
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "::error::gofmt needs to be run on:"
echo "$unformatted"
exit 1
fi

- name: staticcheck
run: |
go install honnef.co/go/tools/cmd/staticcheck@latest
staticcheck ./...

# The deadcode tool always exits 0, so fail on any output. Whole-program
# reachability from every cmd/ entry point; test-only helpers don't
# count as live, which is why the known noop-recorder line is allowed.
- name: deadcode
run: |
go install golang.org/x/tools/cmd/deadcode@latest
out=$(deadcode ./cmd/... | grep -v "NoopRecorder.Record" || true)
if [ -n "$out" ]; then
echo "::error::unreachable code found:"
echo "$out"
exit 1
fi
4 changes: 1 addition & 3 deletions cmd/ansiconvert/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -332,9 +332,7 @@ var palette16 = [16][3]uint8{
var palette256 [256][3]uint8

func init() {
for i, c := range palette16 {
palette256[i] = c
}
copy(palette256[:], palette16[:])
cube := [6]uint8{0, 95, 135, 175, 215, 255}
idx := 16
for r := 0; r < 6; r++ {
Expand Down
2 changes: 1 addition & 1 deletion cmd/nightms/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,7 @@ func buildSessionDeps(
// phase machine — the wheel runs forever, even with zero subscribers.
walletSvc := &doors.WalletService{Queries: queries}
rouletteReg := roulettemp.NewRegistry(
ctx, queries, mpLedger,
queries, mpLedger,
&roulettemp.WalletAdapter{Svc: walletSvc},
doors.CryptoRng{},
logger.With("component", "roulette-mp"),
Expand Down
2 changes: 1 addition & 1 deletion cmd/smoketest/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ func stripAnsi(s string) string {
// ESC followed by '[' → CSI. Skip until a letter byte (final).
if in[i+1] == '[' {
i += 2
for i < len(in) && !((in[i] >= 0x40 && in[i] <= 0x7E)) {
for i < len(in) && !(in[i] >= 0x40 && in[i] <= 0x7E) {
i++
}
continue
Expand Down
12 changes: 6 additions & 6 deletions cmd/wsprobe/main.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
// wsprobe is a one-shot client that exercises the web login + /ws/bbs path
// without needing a browser. Mirrors what xterm.js does in the page:
//
// 1. GET /login (collect the CSRF cookie + token)
// 2. POST /login with credentials and the token (gets nightms_session cookie)
// 3. WebSocket upgrade /ws/bbs (passing the session cookie + Origin)
// 4. Send {"type":"resize","cols":80,"rows":24}
// 5. Read binary frames for ~2s and assert an expected substring appears
// in the rendered output.
// 1. GET /login (collect the CSRF cookie + token)
// 2. POST /login with credentials and the token (gets nightms_session cookie)
// 3. WebSocket upgrade /ws/bbs (passing the session cookie + Origin)
// 4. Send {"type":"resize","cols":80,"rows":24}
// 5. Read binary frames for ~2s and assert an expected substring appears
// in the rendered output.
package main

import (
Expand Down
6 changes: 4 additions & 2 deletions internal/auth/argon2id.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ type Hasher struct {

dummyOnce sync.Once
dummyHash []byte
dummyAlgo string
}

func NewHasher(p Argon2Params) *Hasher { return &Hasher{params: p} }
Expand Down Expand Up @@ -137,7 +136,10 @@ func (h *Hasher) VerifyDummy(password string) {
h.params.Iterations, h.params.MemoryKB, h.params.Parallelism, h.params.HashBytes)
return
}
_ = h.Verify(password, h.dummyHash, "")
// Evaluate the PHC path directly rather than going back through Verify —
// Verify's unknown-format branch calls VerifyDummy, so a dummyHash that
// ever lost its PHC prefix would recurse forever.
_, _ = h.verifyPHC(password, h.dummyHash)
}

// --- legacy ---
Expand Down
24 changes: 12 additions & 12 deletions internal/auth/devicecode/devicecode.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,10 @@ import (
// scan from the sysop console can survey active flows without sweeping the
// whole keyspace.
const (
keyFlowPrefix = "oauth:device:flow:" // + flow_id → JSON state, TTL = expires_in
keyUserPrefix = "oauth:device:user:" // + user_id:provider → flow_id, TTL = expires_in
keyBeginBucket = "oauth:device:begin:" // + user_id → INCR counter, EX 60s
beginRateLimit = 6 // starts per minute per user
keyFlowPrefix = "oauth:device:flow:" // + flow_id → JSON state, TTL = expires_in
keyUserPrefix = "oauth:device:user:" // + user_id:provider → flow_id, TTL = expires_in
keyBeginBucket = "oauth:device:begin:" // + user_id → INCR counter, EX 60s
beginRateLimit = 6 // starts per minute per user
beginRateTTL = 60 * time.Second
)

Expand All @@ -71,7 +71,7 @@ var ErrRateLimited = errors.New("devicecode: too many recent attempts")
// (UserCode, VerificationURL) plus the opaque server-side handle the TUI
// uses on subsequent Poll calls.
type Flow struct {
ID string // opaque server-minted handle; client passes back to Poll
ID string // opaque server-minted handle; client passes back to Poll
Provider auth.OAuthProviderKind
UserCode string // short code the user types into the verification page
VerificationURL string // URL the user opens in a browser
Expand Down Expand Up @@ -509,13 +509,13 @@ type deviceCodeResp struct {
}

type rawDeviceCodeResp struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
VerificationURIComplete string `json:"verification_uri_complete"`
VerificationURL string `json:"verification_url"` // Google's older field name
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
VerificationURIComplete string `json:"verification_uri_complete"`
VerificationURL string `json:"verification_url"` // Google's older field name
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}

func (s *Service) requestDeviceCode(ctx context.Context, p *auth.OAuthProvider) (*deviceCodeResp, error) {
Expand Down
10 changes: 5 additions & 5 deletions internal/auth/oauthrefresh/refresher.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,12 @@ import (
// per-tick batch of 50 covers a fully-saturated keyspace within one tick,
// and the 4-worker pool keeps provider HTTP RTTs from serializing.
const (
defaultInterval = 60 * time.Second
defaultLeadTime = 10 * time.Minute
defaultWorkers = 4
defaultBatchSize = 50
defaultInterval = 60 * time.Second
defaultLeadTime = 10 * time.Minute
defaultWorkers = 4
defaultBatchSize = 50
defaultReauthAfter = 5 // soft failures before we give up and flip needs_reauth
perWorkerTimeout = 20 * time.Second
perWorkerTimeout = 20 * time.Second
)

// Config bundles construction params. Most have sensible zero-value
Expand Down
10 changes: 0 additions & 10 deletions internal/auth/ratelimit.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,3 @@ type RateLimiter interface {
RecordFailure(ctx context.Context, handle string, sourceIP net.Addr) error
Clear(ctx context.Context, handle string) error
}

// NoopRateLimiter never locks out. Used by tests that don't need to exercise
// the real limiter.
type NoopRateLimiter struct{}

func (NoopRateLimiter) Check(_ context.Context, _ string, _ net.Addr) (RateLimitCheck, error) {
return RateLimitCheck{}, nil
}
func (NoopRateLimiter) RecordFailure(_ context.Context, _ string, _ net.Addr) error { return nil }
func (NoopRateLimiter) Clear(_ context.Context, _ string) error { return nil }
29 changes: 8 additions & 21 deletions internal/auth/ratelimit_redis.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,21 +46,6 @@ type RateLimitParams struct {
LockcountWindow time.Duration
}

// DefaultRateLimitParams returns the same numbers the legacy stack defaults
// to, so two stacks pointed at the same Redis would lock the same accounts
// at the same thresholds during cutover. Adds the Phase B defaults.
func DefaultRateLimitParams() RateLimitParams {
return RateLimitParams{
HandleThreshold: 5,
IPThreshold: 20,
WindowDuration: 15 * time.Minute,
LockDuration: 15 * time.Minute,
BackoffMax: 5,
PersistentBanThreshold: 3,
LockcountWindow: 24 * time.Hour,
}
}

// RedisRateLimiter is the production limiter, backed by Redis INCR/EXPIRE
// counters and TTL'd lock keys. Implements the RateLimiter interface.
//
Expand Down Expand Up @@ -115,12 +100,14 @@ func (r *RedisRateLimiter) effective() RateLimitParams {
return p
}

func failHandleKey(handle string) string { return "auth:fail:handle:" + strings.ToLower(handle) }
func failIPKey(ip string) string { return "auth:fail:ip:" + ip }
func lockHandleKey(handle string) string { return "auth:lock:handle:" + strings.ToLower(handle) }
func lockIPKey(ip string) string { return "auth:lock:ip:" + ip }
func lockcountHandleKey(handle string) string { return "auth:lockcount:handle:" + strings.ToLower(handle) }
func lockcountIPKey(ip string) string { return "auth:lockcount:ip:" + ip }
func failHandleKey(handle string) string { return "auth:fail:handle:" + strings.ToLower(handle) }
func failIPKey(ip string) string { return "auth:fail:ip:" + ip }
func lockHandleKey(handle string) string { return "auth:lock:handle:" + strings.ToLower(handle) }
func lockIPKey(ip string) string { return "auth:lock:ip:" + ip }
func lockcountHandleKey(handle string) string {
return "auth:lockcount:handle:" + strings.ToLower(handle)
}
func lockcountIPKey(ip string) string { return "auth:lockcount:ip:" + ip }

// normalizeIP strips the port from a net.Addr.String() so the lockout key is
// stable across multiple connection attempts from the same address.
Expand Down
2 changes: 1 addition & 1 deletion internal/auth/ratelimit_redis_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ func TestComputeLockDuration_ExponentialSequence(t *testing.T) {
{4, 2 * time.Hour},
{5, 4 * time.Hour},
{6, 8 * time.Hour},
{7, 8 * time.Hour}, // capped
{7, 8 * time.Hour}, // capped
{20, 8 * time.Hour}, // still capped, no overflow
}
for _, tc := range cases {
Expand Down
3 changes: 0 additions & 3 deletions internal/auth/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"strings"
"time"

"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"

Expand Down Expand Up @@ -206,7 +205,5 @@ func classifyInsertError(err error) error {
return &RegistrationErr{Kind: RegErrKeyAlreadyUsed, Err: err}
}
}
// pgx may wrap differently; fall through.
_ = pgx.ErrNoRows
return &RegistrationErr{Kind: RegErrInternal, Err: err}
}
6 changes: 3 additions & 3 deletions internal/auth/sysop.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@ func BootstrapSysop(
defer tx.Rollback(ctx)

var (
userID int64
isSysop bool
hasPwd bool
userID int64
isSysop bool
hasPwd bool
)
err = tx.QueryRow(ctx,
`SELECT id, is_sysop, password_hash IS NOT NULL FROM users WHERE handle = $1`,
Expand Down
6 changes: 3 additions & 3 deletions internal/config/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,14 @@ type Options struct {
BootstrapSysopHandle string
BootstrapSysopPassword string

WebPublicHost string
WebPublicHost string
// WebSSHHost is the host string rendered into "ssh -p 2222 you@<host>"
// snippets on the landing/login/profile pages. Distinct from WebPublicHost
// because the SSH listener and the HTTP listener can live on different
// names (e.g. SSH direct at ssh.night.ms, HTTP behind Cloudflare at
// k.night.ms). Defaults to WebPublicHost when NIGHTMS_SSH_HOST is unset,
// preserving the old single-host behavior.
WebSSHHost string
WebSSHHost string
// WebSSHPort is the externally-reachable SSH port shown to users in those
// same snippets. Distinct from the bind port (BBS_SSH_PORT) because the
// container may bind to a non-privileged port while the host forwards the
Expand Down Expand Up @@ -286,7 +286,7 @@ func Load() Options {
OAuthRefreshBatchSize: int(uintEnv("NIGHTMS_OAUTH_REFRESH_BATCH_SIZE", 50)),
OAuthRefreshWorkers: int(uintEnv("NIGHTMS_OAUTH_REFRESH_WORKERS", 4)),
OAuthTokenSecret: hexBytesEnv("NIGHTMS_OAUTH_TOKEN_SECRET"),
ORSAPIKey: os.Getenv("NIGHTMS_ORS_API_KEY"),
ORSAPIKey: os.Getenv("NIGHTMS_ORS_API_KEY"),
Carbonyl: CarbonylOptions{
BinPath: envOr("NIGHTMS_CARBONYL_BIN_PATH", "/opt/carbonyl/carbonyl"),
DataDir: envOr("NIGHTMS_CARBONYL_DATA_DIR", filepath.Join("data", "carbonyl")),
Expand Down
1 change: 0 additions & 1 deletion internal/data/events_filtered.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,4 +184,3 @@ func ListUnifiedEventsFiltered(
}
return out, nil
}

30 changes: 15 additions & 15 deletions internal/doors/holdem/holdem.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
// multiplayer table actor model lives in the sibling multiplayer/ package.
//
// Lifecycle per hand:
// 1. Post blinds (CPU=SB, player=BB or vice-versa, alternating).
// 2. Deal hole cards.
// 3. Preflop betting round (button acts first preflop in HU).
// 4. Flop, turn, river — each followed by a betting round.
// 5. Showdown or fold; pot awarded; advance button.
// 1. Post blinds (CPU=SB, player=BB or vice-versa, alternating).
// 2. Deal hole cards.
// 3. Preflop betting round (button acts first preflop in HU).
// 4. Flop, turn, river — each followed by a betting round.
// 5. Showdown or fold; pot awarded; advance button.
package holdem

import (
Expand Down Expand Up @@ -79,7 +79,7 @@ type Game struct {
bigBlind int32
rng doors.CryptoRng

winnerSeat int // -1 = no winner yet
winnerSeat int // -1 = no winner yet
winRank cards.HandRank
winBoard bool // true at showdown so screen reveals bot's cards
}
Expand Down Expand Up @@ -148,15 +148,15 @@ func (g *Game) BotHole() ([2]cards.Card, bool) {
}
return [2]cards.Card{}, false
}
func (g *Game) Board() []cards.Card { return append([]cards.Card(nil), g.board...) }
func (g *Game) Stacks() [2]int32 { return g.stacks }
func (g *Game) Bets() [2]int32 { return g.bets }
func (g *Game) Pot() int32 { return g.pot + g.bets[0] + g.bets[1] }
func (g *Game) Street() Street { return g.street }
func (g *Game) ToAct() int { return g.toAct }
func (g *Game) WinnerSeat() int { return g.winnerSeat }
func (g *Game) WinRank() cards.HandRank { return g.winRank }
func (g *Game) BigBlind() int32 { return g.bigBlind }
func (g *Game) Board() []cards.Card { return append([]cards.Card(nil), g.board...) }
func (g *Game) Stacks() [2]int32 { return g.stacks }
func (g *Game) Bets() [2]int32 { return g.bets }
func (g *Game) Pot() int32 { return g.pot + g.bets[0] + g.bets[1] }
func (g *Game) Street() Street { return g.street }
func (g *Game) ToAct() int { return g.toAct }
func (g *Game) WinnerSeat() int { return g.winnerSeat }
func (g *Game) WinRank() cards.HandRank { return g.winRank }
func (g *Game) BigBlind() int32 { return g.bigBlind }

// ToCall returns the amount the to-act seat must put in to call.
func (g *Game) ToCall() int32 {
Expand Down
Loading