From d4b4de17aaf3599d974b62b907cae3510dd428ac Mon Sep 17 00:00:00 2001 From: Jaron Date: Wed, 16 Sep 2026 09:30:29 -0500 Subject: [PATCH] local docker dev + starting on gameplay --- Dockerfile | 2 +- README.md | 236 ++++++++++++++++++++++++++++++++++++ cmd/{api => server}/main.go | 129 ++++++++++++++++---- compose.override.yml | 5 +- compose.yml | 1 + 5 files changed, 348 insertions(+), 25 deletions(-) create mode 100644 README.md rename cmd/{api => server}/main.go (78%) diff --git a/Dockerfile b/Dockerfile index 4196dc0..a845e0e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ RUN go mod download COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -o app ./cmd/api +RUN CGO_ENABLED=0 GOOS=linux go build -o app ./cmd/server FROM alpine:3.22 diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a34a78 --- /dev/null +++ b/README.md @@ -0,0 +1,236 @@ +# Deployment + +`go-fishing` runs on a single VPS with Docker Compose. + +Images are stored in the private Gitea registry at: + +```text +git.hat.fish +``` + +## Traffic + +```text +fishing.jp.wtf:22 + -> go-fishing SSH server + +fishing.jp.wtf:80 + -> Nginx + -> redirect to HTTPS + +fishing.jp.wtf:443 + -> Nginx + -> go-fishing HTTP server + +VPS:2222 + -> OpenSSH +``` + +Port `22` belongs to the application. + +The VPS's administrative SSH server listens on `2222`. + +## Docker + +The Compose stack contains: + +```text +go-fishing +postgres +``` + +The application image is: + +```text +git.hat.fish/jaron/go-fishing:latest +``` + +PostgreSQL uses: + +```text +postgres:18-alpine +``` + +PostgreSQL data is stored in a Docker volume. + +The database is only accessible through the Docker network. + +```text +go-fishing -> db:5432 +``` + +There is no reason to expose PostgreSQL publicly. + +## HTTP + +The Go application exposes an HTTP server on: + +```text +9900 +``` + +It is not intended to be accessed directly. + +Nginx proxies HTTPS traffic to it: + +```text +https://fishing.jp.wtf + -> nginx :443 + -> 127.0.0.1:9900 +``` + +A minimal Nginx configuration looks like: + +```nginx +server { + listen 443 ssl; + server_name fishing.jp.wtf; + + ssl_certificate /etc/letsencrypt/live/fishing.jp.wtf/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/fishing.jp.wtf/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:9900; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} + +server { + listen 80; + server_name fishing.jp.wtf; + + return 301 https://$host$request_uri; +} +``` + +Nginx does not handle SSH traffic. + +## TLS + +TLS certificates come from Let's Encrypt and are managed by Certbot. + +Certificate files live under: + +```text +/etc/letsencrypt/live/fishing.jp.wtf/ +``` + +The important files are: + +```text +fullchain.pem +privkey.pem +``` + +The certificate was created with: + +```bash +sudo certbot --nginx -d fishing.jp.wtf +``` + +Renewal is handled automatically by Certbot. + +Check it with: + +```bash +systemctl status certbot.timer +``` + +Test renewal with: + +```bash +sudo certbot renew --dry-run +``` + +The Go application does not manage TLS itself. + +## SSH + +`go-fishing` runs an SSH server using Charm Wish. + +Users connect with: + +```bash +ssh user@fishing.jp.wtf +``` + +The VPS itself is administered with: + +```bash +ssh -p 2222 user@ +``` + +Nginx and the TLS certificate are unrelated to SSH. + +## Configuration + +Secrets and environment-specific configuration live in `.env` on the VPS. + +Example: + +```env +COOKIE_STORE_SECRET="top secret secret" +DB_CONNECTION_STRING="postgres://dev:dev@localhost:5432/go_fishing" +POSTGRES_USER="dev" +POSTGRES_PASSWORD="dev" +POSTGRES_DB="go_fishing" +ENV="DEVELOPMENT" +``` + +## Deployment + +Deployment is currently manual. + +Build and push: + +```bash +# If building for same CPU architecture +docker build -t git.hat.fish/jaron/go-fishing:latest . +# If building from another architecture +docker build --platform=linux/amd64 -t git.hat.fish/jaron/go-fishing:latest . + +docker push git.hat.fish/jaron/go-fishing:latest +``` + +Deploy on the VPS: + +```bash +docker compose pull +docker compose up -d +``` + +That's the deployment process. + +There is no CI/CD pipeline yet. + +## Infrastructure + +The complete production setup is: + +```text +DNS +└── fishing.jp.wtf + | + v +VPS +├── OpenSSH :2222 +├── Nginx :80/:443 +│ ├── Let's Encrypt certificate +│ └── proxy -> localhost:9900 +└── Docker Compose + ├── go-fishing + │ └── SSH :22 + └── PostgreSQL + └── Docker volume + +Gitea +└── container registry +``` + +There is no Kubernetes, managed database, load balancer, deployment platform, or other infrastructure layer. + +None is currently needed. diff --git a/cmd/api/main.go b/cmd/server/main.go similarity index 78% rename from cmd/api/main.go rename to cmd/server/main.go index 9226640..82f5cc5 100644 --- a/cmd/api/main.go +++ b/cmd/server/main.go @@ -217,18 +217,21 @@ const ( ) type model struct { - DB *db.Database - page page - tabs []string - tab tab - term string profile string + term string width int height int bg string + DB *db.Database + page page + angler models.Angler + tabs []string + tab tab + error string ti textinput.Model spinner spinner.Model - angler models.Angler + active tea.Model + cast models.Cast } func (m model) Init() tea.Cmd { @@ -249,7 +252,13 @@ func (m model) Init() tea.Cmd { } func (m model) ChooseNameView() tea.View { - input := lipgloss.JoinVertical(lipgloss.Top, "Got a name?", m.ti.View()) + var error string + + if len(m.error) > 0 { + error = lipgloss.NewStyle().Foreground(lipgloss.Red).Render(m.error) + } + + input := lipgloss.JoinVertical(lipgloss.Top, "aye fresh meat, got a name laddie?", m.ti.View(), error) center := lipgloss.Place( m.width, @@ -279,7 +288,7 @@ func (m model) ChooseNameView() tea.View { c = m.ti.Cursor() - c.Y += lipgloss.Height("aye, fresh meat – got a name laddie?") + (m.height-3-lipgloss.Height(input))/2 + c.Y += lipgloss.Height("aye fresh meat, got a name laddie?") + (m.height-3-lipgloss.Height(input))/2 c.X += (m.width - lipgloss.Width(input)) / 2 v := tea.NewView(str) @@ -294,20 +303,33 @@ func (m model) ChooseNameUpdate(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: + m.error = "" switch msg.String() { case "enter": - err := m.DB.Conn.Get( - &m.angler, - "UPDATE anglers SET angler_username = $1 WHERE angler_id = $2 RETURNING angler_id, angler_username, angler_email", - m.ti.Value(), m.angler.ID, - ) + existing := 0 + desired_username := m.ti.Value() + err := m.DB.Conn.Get(&existing, "SELECT 1 FROM anglers WHERE angler_username = $1", desired_username) if err != nil { // TODO: handle error } - m, cmd = m.gotoGameplayView() - cmds = append(cmds, cmd) + if existing > 0 { + m.error = "that one is taken" + } else { + err = m.DB.Conn.Get( + &m.angler, + "UPDATE anglers SET angler_username = $1 WHERE angler_id = $2 RETURNING angler_id, angler_username, angler_email", + desired_username, m.angler.ID, + ) + + if err != nil { + // TODO: handle error + } + + m, cmd = m.gotoGameplayView() + cmds = append(cmds, cmd) + } } } @@ -318,7 +340,8 @@ func (m model) ChooseNameUpdate(msg tea.Msg) (model, tea.Cmd) { return m, tea.Batch(cmds...) } -var buttonStyle = lipgloss.NewStyle().Background(lipgloss.Color("30")).Padding(0, 3).Align(lipgloss.Center) +var buttonStyle = lipgloss.NewStyle().Padding(0, 2).Align(lipgloss.Center).Background(lipgloss.Color("8")) +var buttonActiveStyle = buttonStyle.Background(lipgloss.Color("30")) var sidebarBtnStyle = lipgloss.NewStyle().Padding(0, 2).Align(lipgloss.Center).Foreground(lipgloss.Color("30")) var sidebarBtnActiveStyle = sidebarBtnStyle.UnsetForeground().Background(lipgloss.Color("30")) @@ -348,7 +371,11 @@ func (m model) GameplayView() tea.View { BorderStyle(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("30")). Padding(paddingY, paddingX). - Render(fmt.Sprintf("Ay matie %s", m.angler.Username)) + Render(lipgloss.JoinVertical( + lipgloss.Left, + lipgloss.NewStyle().MarginBottom(1).Render(fmt.Sprintf("Aye matie %s...time to get fishin'", m.angler.Username)), + buttonActiveStyle.Render("Cast"), + )) footer := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("(esc to quit)") @@ -387,17 +414,54 @@ func (m model) GameplayView() tea.View { type GameplayKeyMap struct { CycleTab key.Binding AltCycleTab key.Binding + Interact key.Binding } var DefaultGameplayKeyMap = GameplayKeyMap{ CycleTab: key.NewBinding( key.WithKeys("tab"), - key.WithHelp("shift+tab", "Cycle Tab Right"), + key.WithHelp("tab", "Cycle Tab Right"), ), AltCycleTab: key.NewBinding( key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "Cycle Tab Left"), ), + Interact: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "Interact with selected option"), + ), +} + +var sayings = []string{ + // Near Misses + "Close but no cigar", + "Almost only counts in horseshoes and hand grenades", + "A day late and a dollar short", + "A miss is as good as a mile", + "Always a bridesmaid, never a bride", + "Snatched defeat from the jaws of victory", + "Fell at the last hurdle", + "Missed the boat", + "Missed it by a whisker", + "So near, and yet so far", + "A swing and a miss", + + // Futile Efforts + "Beating a dead horse", + "Tilting at windmills", + "Squeezing blood from a stone", + "Nailing jelly to a wall", + "Herding cats", + "Rearranging deck chairs on the Titanic", + "Pushing a rope", + "Spitting in the wind", + "Putting toothpaste back in the tube", + "Shoveling snow in a blizzard", + "Spinning your wheels", + "Emptying the ocean with a teaspoon", + "Barking up the wrong tree", + "A wild goose chase", + "Catching the wind in a net", } func (m model) GameplayUpdate(msg tea.Msg) (model, tea.Cmd) { @@ -422,6 +486,25 @@ func (m model) GameplayUpdate(msg tea.Msg) (model, tea.Cmd) { } else { m.tab -= 1 } + case key.Matches(msg, DefaultGameplayKeyMap.Interact): + // if m.active = { + is_casting, err := m.DB.IsCasting(m.angler.ID) + + if is_casting { + m.error = "looks like you are already casting somewhere" + // TODO: clear after a timeout + } + + m.cast, err = m.DB.CreateNewCast(m.angler.ID) + + if err != nil { + m.error = "unable to start your cast" + // TODO: handle error + } + + // TODO: implement rest of casting logic + + // } } } @@ -498,17 +581,17 @@ func teaHandler(DB *db.Database) func(ssh.Session) (tea.Model, []tea.ProgramOpti angler, _ := s.Context().Value("angler").(models.Angler) m := model{ - DB: DB, - tabs: []string{"fishin'", "wares", "dock", "settings"}, - tab: TabFishin, - page: PageChooseName, term: pty.Term, width: pty.Window.Width, height: pty.Window.Height, bg: "light", + DB: DB, + page: PageChooseName, + angler: angler, + tabs: []string{"fishin'", "wares", "dock", "settings"}, + tab: TabFishin, ti: textinput.New(), spinner: spinner.New(), - angler: angler, } if angler.Username == gossh.FingerprintSHA256(s.PublicKey()) { diff --git a/compose.override.yml b/compose.override.yml index 9ca0038..a457a67 100644 --- a/compose.override.yml +++ b/compose.override.yml @@ -5,7 +5,10 @@ services: dockerfile: Dockerfile.dev volumes: - .:/app - command: air --build.cmd "go build -o /tmp/api ./cmd/api" --build.entrypoint "/tmp/api" + command: air --misc.startup_banner "====== fishin' 🎣 ======" --build.cmd "go build -o /tmp/server ./cmd/server" --build.entrypoint "/tmp/server" ports: - "9900:9900" - "2222:22" + db: + ports: + - "5432:5432" diff --git a/compose.yml b/compose.yml index c61eace..048bfa9 100644 --- a/compose.yml +++ b/compose.yml @@ -8,6 +8,7 @@ services: environment: COOKIE_STORE_SECRET: ${COOKIE_STORE_SECRET} DB_CONNECTION_STRING: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + ENV: ${ENV} ports: - "22:22" - "9900:9900"