aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author2025-12-30 19:33:39 +0300
committer2025-12-30 19:33:39 +0300
commitd317e8f6df0e0e16445db606da1d683a6b35f531 (patch)
tree4b80de04e17a137cff2dc309508b5f841f48c994
downloadqchat-d317e8f6df0e0e16445db606da1d683a6b35f531.tar.gz
qchat-d317e8f6df0e0e16445db606da1d683a6b35f531.tar.bz2
qchat-d317e8f6df0e0e16445db606da1d683a6b35f531.tar.xz
qchat-d317e8f6df0e0e16445db606da1d683a6b35f531.zip
начальный коммит
-rw-r--r--.dockerignore1
-rw-r--r--.gitignore1
-rw-r--r--Dockerfile30
-rw-r--r--cmd/qchat/main.go93
-rw-r--r--go.mod11
-rw-r--r--go.sum8
-rw-r--r--internal/chat/chan.go69
-rw-r--r--internal/chat/chat.go178
-rw-r--r--internal/chat/message.go20
-rw-r--r--internal/chat/presence.go17
-rw-r--r--internal/chat/user.go35
-rw-r--r--internal/config/config.go46
-rw-r--r--internal/server/client.go58
-rw-r--r--internal/server/conn.go67
-rw-r--r--internal/server/display.go83
-rw-r--r--internal/server/escapes.go49
-rw-r--r--internal/server/server.go70
17 files changed, 836 insertions, 0 deletions
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..0cffcb3
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1 @@
+config.json \ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0cffcb3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+config.json \ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..77d54d6
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,30 @@
+FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
+
+ARG TARGETPLATFORM
+ARG BUILDPLATFORM
+
+RUN apk add --no-cache git
+
+WORKDIR /app
+
+COPY . .
+
+RUN CGO_ENABLED=0 GOOS=$(echo $TARGETPLATFORM | cut -d'/' -f1) \
+ GOARCH=$(echo $TARGETPLATFORM | cut -d'/' -f2) \
+ GOARM=$(echo $TARGETPLATFORM | cut -d'/' -f3 | sed 's/v//' || echo "7") \
+ go build \
+ -trimpath \
+ -ldflags="-s -w -X main.version=$(git describe --tags --always --dirty 2>/dev/null || echo 'dev')" \
+ -o /app ./cmd/...
+
+FROM scratch
+
+LABEL maintainer="neonxp" \
+ description="qChat - quick ssh chat" \
+ version="1.0"
+
+COPY --from=builder /app /app
+
+ENV TZ=Europe/Moscow
+
+EXPOSE 1337
diff --git a/cmd/qchat/main.go b/cmd/qchat/main.go
new file mode 100644
index 0000000..0e28ab5
--- /dev/null
+++ b/cmd/qchat/main.go
@@ -0,0 +1,93 @@
+package main
+
+import (
+ "context"
+ "encoding/pem"
+ "errors"
+ "flag"
+ "fmt"
+ "log/slog"
+ "os"
+ "os/signal"
+
+ "go.neonxp.ru/qchat/internal/chat"
+ "go.neonxp.ru/qchat/internal/config"
+ "go.neonxp.ru/qchat/internal/server"
+ "golang.org/x/crypto/ed25519"
+ "golang.org/x/crypto/ssh"
+ "golang.org/x/sync/errgroup"
+)
+
+var (
+ configFile = flag.String("config", "./config.json", "Config file")
+ debug = flag.Bool("debug", false, "Debug mode")
+ version string
+)
+
+func main() {
+ flag.Parse()
+ if *configFile == "" {
+ flag.Usage()
+ os.Exit(1)
+ }
+
+ ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
+ defer cancel()
+
+ slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{})))
+ if *debug {
+ slog.SetLogLoggerLevel(slog.LevelDebug)
+ }
+
+ slog.InfoContext(ctx, "qChat by NeonXP. https://neonxp.ru/", slog.String("version", version))
+
+ cfg, err := config.Load(*configFile)
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ panic(err)
+ }
+ if err != nil {
+ hostname, err := os.Hostname()
+ if err != nil {
+ panic(err)
+ }
+ _, privKey, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ panic(err)
+ }
+
+ privateKeyOpenSSH, err := ssh.MarshalPrivateKey(privKey, "")
+ if err != nil {
+ panic(fmt.Sprintf("Failed to marshal private key: %v", err))
+ }
+
+ cfg = &config.Config{
+ Server: config.Server{
+ PrivateKey: string(pem.EncodeToMemory(privateKeyOpenSSH)),
+ Addr: ":1337",
+ Name: hostname,
+ },
+ Channels: []config.Channel{
+ {Name: "main"},
+ },
+ }
+
+ slog.InfoContext(ctx, "no config file. creating new.", slog.String("config", *configFile))
+ if err := config.Save(*configFile, cfg); err != nil {
+ panic(err)
+ }
+ }
+
+ ch := chat.New(cfg)
+
+ serv := server.New(cfg, ch)
+
+ eg, ectx := errgroup.WithContext(ctx)
+
+ eg.Go(func() error { return ch.Run(ectx) })
+
+ eg.Go(func() error { return serv.Run(ectx) })
+
+ if err := eg.Wait(); err != nil {
+ panic(err)
+ }
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..9064eea
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,11 @@
+module go.neonxp.ru/qchat
+
+go 1.25.5
+
+require golang.org/x/crypto v0.46.0
+
+require (
+ golang.org/x/sync v0.19.0
+ golang.org/x/sys v0.39.0 // indirect
+ golang.org/x/term v0.38.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..10b932b
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,8 @@
+golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
+golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
+golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
+golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
diff --git a/internal/chat/chan.go b/internal/chat/chan.go
new file mode 100644
index 0000000..ae78bb6
--- /dev/null
+++ b/internal/chat/chan.go
@@ -0,0 +1,69 @@
+package chat
+
+import (
+ "context"
+ "log/slog"
+ "sync"
+ "time"
+)
+
+type Channel struct {
+ Name string
+ Users map[*User]struct{}
+ Events chan any
+ mu sync.RWMutex
+}
+
+func (c *Channel) Listen(ctx context.Context) {
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case ev := <-c.Events:
+ c.processEvent(ev)
+ }
+ }
+}
+
+func (c *Channel) processEvent(ev any) {
+ slog.Info("channel event", slog.String("channel", c.Name), slog.Any("event", ev))
+
+ for u := range c.Users {
+ select {
+ case u.Events <- ev:
+ default:
+ }
+ }
+}
+
+func (c *Channel) Join(u *User) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ if _, ok := c.Users[u]; ok {
+ return
+ }
+ c.Events <- UserJoined{
+ User: u,
+ Time: time.Now(),
+ Chan: c,
+ }
+
+ c.Users[u] = struct{}{}
+}
+
+func (c *Channel) Leave(u *User) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ if _, ok := c.Users[u]; !ok {
+ return
+ }
+ c.Events <- UserLeft{
+ User: u,
+ Time: time.Now(),
+ Chan: c,
+ }
+
+ delete(c.Users, u)
+}
diff --git a/internal/chat/chat.go b/internal/chat/chat.go
new file mode 100644
index 0000000..4a639ba
--- /dev/null
+++ b/internal/chat/chat.go
@@ -0,0 +1,178 @@
+package chat
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "sync"
+ "time"
+
+ "go.neonxp.ru/qchat/internal/config"
+)
+
+var (
+ helpMessage = "Available commands:\n" +
+ "/join [chan] - change current channel to [chan]\n" +
+ "/chans - list all chans\n" +
+ "/users - list all online users\n" +
+ "ctrl+c - leave chat"
+)
+
+type Chat struct {
+ cfg *config.Config
+ users map[*User]struct{}
+ channels map[*Channel]struct{}
+ wg sync.WaitGroup
+ mu sync.RWMutex
+}
+
+func New(cfg *config.Config) *Chat {
+ return &Chat{
+ cfg: cfg,
+ users: make(map[*User]struct{}, 32),
+ channels: make(map[*Channel]struct{}, 32),
+ wg: sync.WaitGroup{},
+ mu: sync.RWMutex{},
+ }
+}
+
+func (c *Chat) Run(ctx context.Context) error {
+ for _, channel := range c.cfg.Channels {
+ c.NewChannel(ctx, channel.Name)
+ }
+
+ c.wg.Wait()
+
+ return nil
+}
+
+func (c *Chat) NewUser(username, identify string) *User {
+ u := &User{
+ Username: username,
+ Identify: identify,
+ Chans: map[string]*Channel{},
+ CurrentChan: nil,
+ Events: make(chan any, 32),
+ mu: sync.RWMutex{},
+ }
+
+ ch := c.GetChannel("main")
+ if ch != nil {
+ u.JoinChan(ch)
+ }
+
+ c.users[u] = struct{}{}
+
+ u.Events <- SystemMessage{
+ Message: fmt.Sprintf("Connected to %s chat server...", c.cfg.Server.Name),
+ }
+
+ return u
+}
+
+func (c *Chat) RemoveUser(u *User) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ for ch := range c.channels {
+ ch.Leave(u)
+ }
+
+ close(u.Events)
+
+ delete(c.users, u)
+}
+
+func (c *Chat) NewChannel(ctx context.Context, name string) *Channel {
+ ch := &Channel{
+ Name: name,
+ Users: make(map[*User]struct{}, 32),
+ Events: make(chan any),
+ mu: sync.RWMutex{},
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.channels[ch] = struct{}{}
+ c.wg.Go(func() {
+ ch.Listen(ctx)
+ })
+
+ return ch
+}
+
+func (c *Chat) GetChannel(name string) *Channel {
+ for ch := range c.channels {
+ if ch.Name == name {
+ return ch
+ }
+ }
+
+ return nil
+}
+
+func (c *Chat) Message(user *User, message string) {
+ if user.CurrentChan == nil {
+ return
+ }
+
+ user.CurrentChan.Events <- Message{
+ User: user,
+ Message: message,
+ Time: time.Now(),
+ }
+}
+
+func (c *Chat) SelfMessage(user *User, message string) {
+ if user.CurrentChan == nil {
+ return
+ }
+
+ user.CurrentChan.Events <- SelfMessage{
+ User: user,
+ Message: message,
+ }
+}
+func (c *Chat) SystemMessage(user *User, message string) {
+ user.Events <- SystemMessage{
+ Message: message,
+ }
+}
+
+func (c *Chat) Input(ctx context.Context, user *User, input string) {
+ cmd := strings.ToLower(input)
+ switch {
+ case strings.HasPrefix(cmd, "/me "):
+ c.SelfMessage(user, strings.TrimPrefix(input, "/me "))
+ case cmd == "/help":
+ c.SystemMessage(user, helpMessage)
+ case cmd == "/list":
+ list := make([]string, 0, len(c.channels))
+ for ch := range c.channels {
+ list = append(list, ch.Name)
+ }
+ c.SystemMessage(user, "Chans:\n"+strings.Join(list, "\n"))
+ case cmd == "/users":
+ list := make([]string, 0, len(c.users))
+ for u := range c.users {
+ list = append(list, u.NUsername())
+ }
+ c.SystemMessage(user, "Users:\n"+strings.Join(list, "\n"))
+ case strings.HasPrefix(cmd, "/join "):
+ newChanName := strings.TrimPrefix(input, "/join ")
+ var newChan *Channel
+ for ch := range c.channels {
+ if ch.Name == newChanName {
+ newChan = ch
+ break
+ }
+ }
+ if newChan == nil {
+ newChan = c.NewChannel(ctx, newChanName)
+ }
+ user.CurrentChan.Leave(user)
+ user.CurrentChan = newChan
+ newChan.Join(user)
+ default:
+ c.Message(user, input)
+ }
+}
diff --git a/internal/chat/message.go b/internal/chat/message.go
new file mode 100644
index 0000000..081d640
--- /dev/null
+++ b/internal/chat/message.go
@@ -0,0 +1,20 @@
+package chat
+
+import (
+ "time"
+)
+
+type SystemMessage struct {
+ Message string
+}
+
+type Message struct {
+ User *User
+ Time time.Time
+ Message string
+}
+
+type SelfMessage struct {
+ User *User
+ Message string
+}
diff --git a/internal/chat/presence.go b/internal/chat/presence.go
new file mode 100644
index 0000000..995f214
--- /dev/null
+++ b/internal/chat/presence.go
@@ -0,0 +1,17 @@
+package chat
+
+import (
+ "time"
+)
+
+type UserJoined struct {
+ User *User
+ Chan *Channel
+ Time time.Time
+}
+
+type UserLeft struct {
+ User *User
+ Chan *Channel
+ Time time.Time
+}
diff --git a/internal/chat/user.go b/internal/chat/user.go
new file mode 100644
index 0000000..e897f32
--- /dev/null
+++ b/internal/chat/user.go
@@ -0,0 +1,35 @@
+package chat
+
+import (
+ "sync"
+)
+
+type User struct {
+ Username string
+ Identify string
+ Chans map[string]*Channel
+ CurrentChan *Channel
+ Events chan any
+ mu sync.RWMutex
+}
+
+func (u *User) JoinChan(channel *Channel) {
+ u.mu.Lock()
+ defer u.mu.Unlock()
+
+ if _, ok := u.Chans[channel.Name]; ok {
+ return
+ }
+
+ u.Chans[channel.Name] = channel
+ u.CurrentChan = channel
+ channel.Join(u)
+}
+
+func (u *User) NUsername() string {
+ username := u.Username
+ identify := u.Identify
+ shortIdentify := identify[max(0, len(identify)-5):]
+
+ return username[:min(len(username), 10)] + " (" + shortIdentify + ")"
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..94ff011
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,46 @@
+package config
+
+import (
+ "encoding/json"
+ "os"
+)
+
+type Config struct {
+ Server Server `json:"server"`
+ Channels []Channel `json:"channels"`
+}
+
+type Server struct {
+ PrivateKey string `json:"private_key"`
+ Addr string `json:"addr"`
+ Name string `json:"name"`
+}
+
+type Channel struct {
+ Name string `json:"name"`
+}
+
+func Load(file string) (*Config, error) {
+ fp, err := os.Open(file)
+ if err != nil {
+ return nil, err
+ }
+ defer fp.Close()
+
+ cfg := new(Config)
+
+ return cfg, json.NewDecoder(fp).Decode(cfg)
+}
+
+func Save(file string, cfg *Config) error {
+ fp, err := os.Create(file)
+ if err != nil {
+ return err
+ }
+
+ enc := json.NewEncoder(fp)
+ enc.SetIndent("", " ")
+ enc.SetEscapeHTML(false)
+
+ return enc.Encode(cfg)
+}
diff --git a/internal/server/client.go b/internal/server/client.go
new file mode 100644
index 0000000..dbea5a9
--- /dev/null
+++ b/internal/server/client.go
@@ -0,0 +1,58 @@
+package server
+
+import (
+ "context"
+ "io"
+ "log/slog"
+
+ "go.neonxp.ru/qchat/internal/chat"
+ "golang.org/x/term"
+)
+
+func (s *Server) serveClient(ctx context.Context, rw io.ReadWriteCloser, user *chat.User) {
+ t := term.NewTerminal(rw, "[] ")
+
+ go func() {
+ // defer s.bus.Unsubscribe(evCh)
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ default:
+ }
+ if user.CurrentChan != nil {
+ t.SetPrompt("[" + user.CurrentChan.Name + "] ")
+ }
+ line, err := t.ReadLine()
+ if err != nil {
+ s.chat.RemoveUser(user)
+
+ if err != io.EOF {
+ slog.Error("failed read line", slog.Any("err", err))
+ }
+
+ return
+ }
+ if len(line) == 0 {
+ continue
+ }
+
+ s.chat.Input(ctx, user, line)
+ }
+ }()
+
+ for message := range user.Events {
+ switch message := message.(type) {
+ case chat.Message:
+ displayMessage(t, message, user)
+ case chat.SelfMessage:
+ displaySelfMessage(t, message)
+ case chat.SystemMessage:
+ displaySystemMessage(t, message)
+ case chat.UserJoined:
+ displayUserJoined(t, message)
+ case chat.UserLeft:
+ displayUserLeft(t, message)
+ }
+ }
+}
diff --git a/internal/server/conn.go b/internal/server/conn.go
new file mode 100644
index 0000000..1069b0b
--- /dev/null
+++ b/internal/server/conn.go
@@ -0,0 +1,67 @@
+package server
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "net"
+ "sync"
+
+ "golang.org/x/crypto/ssh"
+)
+
+func (s *Server) serveConn(ctx context.Context, nConn net.Conn, config *ssh.ServerConfig) error {
+ conn, chans, reqs, err := ssh.NewServerConn(nConn, config)
+ if err != nil {
+ return fmt.Errorf("failed to handshake: %w", err)
+ }
+ slog.Info("user connected", slog.Any("user", conn.User()), slog.String("ip", conn.RemoteAddr().String()))
+
+ var wg sync.WaitGroup
+ defer wg.Wait()
+
+ wg.Go(func() {
+ ssh.DiscardRequests(reqs)
+ })
+
+ for newChannel := range chans {
+ if newChannel.ChannelType() != "session" {
+ newChannel.Reject(ssh.UnknownChannelType, "unknown channel type")
+ continue
+ }
+ channel, requests, err := newChannel.Accept()
+ if err != nil {
+ return fmt.Errorf("could not accept channel: %w", err)
+ }
+
+ wg.Go(func() {
+ for req := range requests {
+ switch req.Type {
+ case "pty-req":
+ req.Reply(true, nil)
+ case "shell":
+ req.Reply(true, nil)
+ default:
+ req.Reply(false, nil)
+ }
+ slog.Debug(
+ "req",
+ slog.String("type", req.Type),
+ slog.Bool("want-reply", req.WantReply),
+ slog.String("payload", string(req.Payload)),
+ )
+ }
+ })
+
+ wg.Go(func() {
+ identify := conn.Permissions.ExtraData["identify"].(string)
+ user := s.chat.NewUser(conn.User(), identify)
+ slog.Info("joined", slog.String("user", user.NUsername()))
+ s.serveClient(ctx, channel, user)
+ slog.Info("disconnected", slog.String("user", user.NUsername()))
+ conn.Close()
+ })
+ }
+
+ return nil
+}
diff --git a/internal/server/display.go b/internal/server/display.go
new file mode 100644
index 0000000..90d481a
--- /dev/null
+++ b/internal/server/display.go
@@ -0,0 +1,83 @@
+package server
+
+import (
+ "fmt"
+ "io"
+ "regexp"
+ "strings"
+
+ "go.neonxp.ru/qchat/internal/chat"
+)
+
+var replacements = []replacement{
+ {
+ From: *regexp.MustCompile(`\*(.+?)\*`),
+ To: fmt.Sprintf("%s$1%s", escapeCodes[Bold], escapeCodes[Reset]),
+ },
+ {
+ From: *regexp.MustCompile(`_(.+?)_`),
+ To: fmt.Sprintf("%s$1%s", escapeCodes[Italic], escapeCodes[Reset]),
+ },
+}
+
+func displayMessage(t io.Writer, message chat.Message, user *chat.User) {
+ msg := message.Message
+ mentionFrom := "@" + user.Username
+ mentionTo := string(escapeCodes[Blink]) + string(escapeCodes[Underline]) + "@" + user.Username + string(escapeCodes[Reset])
+ if strings.Contains(msg, mentionFrom) {
+ msg = strings.ReplaceAll(msg, mentionFrom, mentionTo)
+ msg = "\x07\a" + msg
+ }
+
+ for _, r := range replacements {
+ msg = r.From.ReplaceAllString(msg, r.To)
+ }
+
+ fmt.Fprintf(t, "%s%s%s\t[%s]%s: %s\n",
+ escapeCodes[Blue],
+ message.User.NUsername(),
+ escapeCodes[Green],
+ message.Time.Format("15:04:05"),
+ escapeCodes[Reset],
+ msg,
+ )
+}
+
+func displaySystemMessage(t io.Writer, message chat.SystemMessage) {
+ fmt.Fprintf(t, "%s* %s %s\n",
+ escapeCodes[Green],
+ message.Message,
+ escapeCodes[Reset],
+ )
+}
+func displaySelfMessage(t io.Writer, message chat.SelfMessage) {
+ fmt.Fprintf(t, "%s* %s %s%s\n",
+ escapeCodes[Blue],
+ message.User.NUsername(),
+ message.Message,
+ escapeCodes[Reset],
+ )
+}
+
+func displayUserJoined(t io.Writer, presence chat.UserJoined) {
+ fmt.Fprintf(t, "%s* %s joined to %s chan%s\n",
+ escapeCodes[Green],
+ presence.User.NUsername(),
+ presence.Chan.Name,
+ escapeCodes[Reset],
+ )
+}
+
+func displayUserLeft(t io.Writer, presence chat.UserLeft) {
+ fmt.Fprintf(t, "%s* %s left %s chan%s\n",
+ escapeCodes[Red],
+ presence.User.NUsername(),
+ presence.Chan.Name,
+ escapeCodes[Reset],
+ )
+}
+
+type replacement struct {
+ From regexp.Regexp
+ To string
+}
diff --git a/internal/server/escapes.go b/internal/server/escapes.go
new file mode 100644
index 0000000..f495345
--- /dev/null
+++ b/internal/server/escapes.go
@@ -0,0 +1,49 @@
+package server
+
+const (
+ keyEscape = 27
+)
+
+var escapeCodes = map[color][]byte{
+ Black: {keyEscape, '[', '3', '0', 'm'},
+ Red: {keyEscape, '[', '3', '1', 'm'},
+ Green: {keyEscape, '[', '3', '2', 'm'},
+ Yellow: {keyEscape, '[', '3', '3', 'm'},
+ Blue: {keyEscape, '[', '3', '4', 'm'},
+ Magenta: {keyEscape, '[', '3', '5', 'm'},
+ Cyan: {keyEscape, '[', '3', '6', 'm'},
+ White: {keyEscape, '[', '3', '7', 'm'},
+ Reset: {keyEscape, '[', '0', 'm'},
+ Bold: {keyEscape, '[', '1', 'm'},
+ Faint: {keyEscape, '[', '2', 'm'},
+ Italic: {keyEscape, '[', '3', 'm'},
+ Underline: {keyEscape, '[', '4', 'm'},
+ Blink: {keyEscape, '[', '5', 'm'},
+ Rapid: {keyEscape, '[', '6', 'm'},
+ Reverse: {keyEscape, '[', '7', 'm'},
+ Conceal: {keyEscape, '[', '8', 'm'},
+ Strike: {keyEscape, '[', '9', 'm'},
+}
+
+type color int
+
+const (
+ Black color = iota
+ Red
+ Green
+ Yellow
+ Blue
+ Magenta
+ Cyan
+ White
+ Reset
+ Bold
+ Faint
+ Italic
+ Underline
+ Blink
+ Rapid
+ Reverse
+ Conceal
+ Strike
+)
diff --git a/internal/server/server.go b/internal/server/server.go
new file mode 100644
index 0000000..1c28cd5
--- /dev/null
+++ b/internal/server/server.go
@@ -0,0 +1,70 @@
+package server
+
+import (
+ "context"
+ "encoding/hex"
+ "fmt"
+ "log/slog"
+ "net"
+ "sync"
+
+ "go.neonxp.ru/qchat/internal/chat"
+ "go.neonxp.ru/qchat/internal/config"
+ "golang.org/x/crypto/ssh"
+)
+
+type Server struct {
+ cfg *config.Config
+ chat *chat.Chat
+}
+
+func New(cfg *config.Config, ch *chat.Chat) *Server {
+ return &Server{
+ cfg: cfg,
+ chat: ch,
+ }
+}
+
+func (s *Server) Run(ctx context.Context) error {
+ config := &ssh.ServerConfig{
+ PublicKeyCallback: func(c ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, error) {
+ return &ssh.Permissions{
+ ExtraData: map[any]any{
+ "identify": hex.EncodeToString(pubKey.Marshal()),
+ },
+ }, nil
+ },
+ }
+
+ private, err := ssh.ParsePrivateKey([]byte(s.cfg.Server.PrivateKey))
+ if err != nil {
+ return fmt.Errorf("failed to parse private key: %w", err)
+ }
+ config.AddHostKey(private)
+ listener, err := net.Listen("tcp", s.cfg.Server.Addr)
+ if err != nil {
+ return fmt.Errorf("failed to parse private key: %w", err)
+ }
+ go func() {
+ <-ctx.Done()
+ listener.Close()
+ }()
+
+ wg := sync.WaitGroup{}
+ defer wg.Wait()
+
+ slog.InfoContext(ctx, "started server at", slog.String("addr", s.cfg.Server.Addr))
+
+ for {
+ nConn, err := listener.Accept()
+ if err != nil {
+ slog.ErrorContext(ctx, "failed to accept incoming connection", slog.Any("err", err))
+ continue
+ }
+ wg.Go(func() {
+ if err := s.serveConn(ctx, nConn, config); err != nil {
+ slog.ErrorContext(ctx, "connection error", slog.Any("err", err))
+ }
+ })
+ }
+}