This commit is contained in:
Yakov Shatilov 2025-02-25 16:07:22 +03:00
commit ab75431b8a
19 changed files with 631 additions and 0 deletions

17
Dockerfile Normal file
View File

@ -0,0 +1,17 @@
FROM golang:alpine AS builder
LABEL builder=true
WORKDIR /build
ADD go.mod .
COPY . .
RUN go mod download
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags "-w -s" -o ./cmd/app/main ./cmd/app/main.go
FROM alpine
WORKDIR /build
COPY --from=builder /build/cmd/app/main /build/cmd/main
CMD ["./cmd/main"]

4
Makefile Normal file
View File

@ -0,0 +1,4 @@
run:
go run ./cmd/app/main.go
update:
go get -u ./...

36
cmd/app/main.go Normal file
View File

@ -0,0 +1,36 @@
package main
import (
"gommunicator/internal/communicator"
"gommunicator/internal/input"
"gommunicator/internal/logger"
"gommunicator/internal/memory"
"gommunicator/internal/ssh"
"gommunicator/internal/usecase"
)
const key = `-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACC2W7jqv+IrEDb6dg2vR81uoUHnpCUueBZB0ajetr13hAAAAJjLUSTky1Ek
5AAAAAtzc2gtZWQyNTUxOQAAACC2W7jqv+IrEDb6dg2vR81uoUHnpCUueBZB0ajetr13hA
AAAEA1C1fC0k8MZ5S5vjcg43y2//ikwDvRbOp6GsLLAn/ZzrZbuOq/4isQNvp2Da9HzW6h
QeekJS54FkHRqN62vXeEAAAADnlhc2hAeWFzaC1hc3VzAQIDBAUGBw==
-----END OPENSSH PRIVATE KEY-----`
func main() {
log := logger.SetupLogger("prod")
mem := memory.New()
communicator := communicator.New().WithDefaultServerClient()
uc := usecase.New(log, mem, communicator)
go input.New(uc, log).Run()
srv, err := ssh.New(log, uc, []byte(key))
if err != nil {
panic(err)
}
if err := srv.Start("127.0.0.1:8022"); err != nil {
panic(err)
}
}

22
go.mod Normal file
View File

@ -0,0 +1,22 @@
module gommunicator
go 1.23.6
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/lipgloss v0.10.0 // indirect
github.com/charmbracelet/log v0.4.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/crypto v0.34.0 // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/term v0.29.0 // indirect
)

40
go.sum Normal file
View File

@ -0,0 +1,40 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s=
github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE=
github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM=
github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
golang.org/x/crypto v0.34.0 h1:+/C6tk6rf/+t5DhUketUbD1aNGqiSX3j15Z6xuIDlBA=
golang.org/x/crypto v0.34.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=

View File

@ -0,0 +1,48 @@
package communicator
import (
"fmt"
"log/slog"
"gommunicator/internal/entity"
)
type Client interface {
Send(msg string) error
Username() string
}
type Communicator struct {
log *slog.Logger
clients []Client
}
func New() *Communicator {
return &Communicator{
clients: make([]Client, 0),
}
}
func (c *Communicator) WithDefaultServerClient() *Communicator {
client := entity.NewServerClient()
c.clients = append(c.clients, client)
return c
}
func (c *Communicator) AddClient(client Client) {
c.clients = append(c.clients, client)
}
func (c *Communicator) SendMsg(msg, fromUsername string) {
// format
msg = fmt.Sprintf("%s > %s", fromUsername, msg)
// send
for _, client := range c.clients {
if fromUsername == client.Username() {
continue
}
if err := client.Send(msg); err != nil {
c.log.Error("failed to send message", "err", err)
}
}
}

46
internal/entity/client.go Normal file
View File

@ -0,0 +1,46 @@
package entity
import (
"fmt"
"golang.org/x/term"
)
type ServerClient struct{}
func NewServerClient() *ServerClient {
return &ServerClient{}
}
func (c *ServerClient) Send(msg string) error {
fmt.Println(msg)
return nil
}
func (c *ServerClient) Username() string {
return "server"
}
type TerminalClient struct {
terminal *term.Terminal
username string
}
func NewTerminalClient(term *term.Terminal, username string) *TerminalClient {
return &TerminalClient{
terminal: term,
username: username,
}
}
func (c *TerminalClient) Send(msg string) error {
_, err := fmt.Fprintf(c.terminal, "%s\n", msg)
if err != nil {
return err
}
return nil
}
func (c *TerminalClient) Username() string {
return c.username
}

5
internal/input/const.go Normal file
View File

@ -0,0 +1,5 @@
package input
const helpMessage = `server commands:
/help, ? - show this message
/new-password - generate new password for clients`

57
internal/input/input.go Normal file
View File

@ -0,0 +1,57 @@
package input
import (
"bufio"
"fmt"
"log/slog"
"os"
"gommunicator/internal/usecase"
)
type Input struct {
log *slog.Logger
uc *usecase.Usecase
}
func New(uc *usecase.Usecase, log *slog.Logger) *Input {
return &Input{
log: log,
uc: uc,
}
}
func (i *Input) Run() {
for {
reader := bufio.NewReader(os.Stdin)
msg, err := reader.ReadString('\n')
if err != nil {
i.log.Error("failed to scan input", "err", err)
continue
}
if len(msg) < 1 {
continue
}
// observe command
if msg[0] == '/' || msg == "?" {
switch msg {
case "?", "/help":
fmt.Println(helpMessage)
case "/new-password":
pass, err := i.uc.GenNewPassword()
if err != nil {
i.log.Error("failed to generate new client's password", "err", err)
break
}
i.log.Info("new server password generated", "password", pass)
}
continue
}
if msg[len(msg)-1] == '\n' {
msg = msg[:len(msg)-1]
}
i.uc.SendMessage(msg, "server")
}
}

34
internal/logger/logger.go Normal file
View File

@ -0,0 +1,34 @@
package logger
import (
"log/slog"
"os"
prettyLogger "github.com/charmbracelet/log"
)
const (
envDev = "dev"
envProd = "prod"
)
func SetupLogger(env string) *slog.Logger {
var log *slog.Logger
switch env {
case envDev:
handler := prettyLogger.NewWithOptions(os.Stdout, prettyLogger.Options{
Level: prettyLogger.DebugLevel,
ReportTimestamp: true,
})
log = slog.New(handler)
case envProd:
handler := prettyLogger.NewWithOptions(os.Stdout, prettyLogger.Options{
Level: prettyLogger.InfoLevel,
ReportTimestamp: true,
})
log = slog.New(handler)
}
return log
}

35
internal/memory/memory.go Normal file
View File

@ -0,0 +1,35 @@
package memory
import "sync"
type Memory struct {
passwords map[string]struct{}
mu sync.RWMutex
}
func New() *Memory {
mem := Memory{
passwords: make(map[string]struct{}, 16),
mu: sync.RWMutex{},
}
return &mem
}
func (m *Memory) SavePassword(password string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.passwords[password] = struct{}{}
return nil
}
func (m *Memory) PasswordExists(password string) (bool, error) {
m.mu.RLock()
defer m.mu.RUnlock()
if _, ok := m.passwords[password]; ok {
return true, nil
}
return false, nil
}

6
internal/ssh/const.go Normal file
View File

@ -0,0 +1,6 @@
package ssh
const helpMessage = `server commands:
/help - show this message
/exit - disconnect from the server
`

44
internal/ssh/password.go Normal file
View File

@ -0,0 +1,44 @@
package ssh
import (
"errors"
"time"
"golang.org/x/term"
)
func (s *Server) newPassword() error {
password, err := s.uc.GenNewPassword()
if err != nil {
s.log.Error("failed to generate new password", "err", err)
return err
}
s.log.Info("new server password generated", "password", password)
return nil
}
// get user password from terminal
func (s *Server) passwordRequest(term *term.Terminal) error {
// check password
attempts := 3
for {
pass, err := term.ReadPassword("Enter your password: ")
if err != nil {
return err
}
ok, err := s.uc.PasswordExists(pass)
if err != nil {
return err
}
if ok {
term.Write([]byte("Login success\n"))
return nil
}
time.Sleep(time.Second * 1)
attempts -= 1
if attempts <= 0 {
return errors.New("invalid password")
}
term.Write([]byte("Permission denied, please try again.\n"))
}
}

154
internal/ssh/server.go Normal file
View File

@ -0,0 +1,154 @@
package ssh
import (
"fmt"
"log/slog"
"net"
"gommunicator/internal/entity"
"gommunicator/internal/usecase"
"golang.org/x/crypto/ssh"
"golang.org/x/term"
)
type Server struct {
log *slog.Logger
uc *usecase.Usecase
sshConfig *ssh.ServerConfig
sshSigner *ssh.Signer
socket *net.Listener
}
func New(log *slog.Logger, uc *usecase.Usecase, privateKey []byte) (*Server, error) {
signer, err := ssh.ParsePrivateKey(privateKey)
if err != nil {
return nil, err
}
config := ssh.ServerConfig{
NoClientAuth: true,
}
config.AddHostKey(signer)
server := Server{
sshConfig: &config,
sshSigner: &signer,
log: log,
uc: uc,
}
return &server, nil
}
func (s *Server) Start(laddr string) error {
// Once a ServerConfig has been configured, connections can be accepted.
socket, err := net.Listen("tcp", laddr)
if err != nil {
return err
}
s.socket = &socket
s.log.Info("Listening on", "addr", laddr)
// generate password
if err := s.newPassword(); err != nil {
return err
}
// handle connnections
for {
conn, err := socket.Accept()
if err != nil {
s.log.Info("Failed to accept connection, aborting loop", "err", err)
return err
}
// From a standard TCP connection to an encrypted SSH connection
sshConn, channels, requests, err := ssh.NewServerConn(conn, s.sshConfig)
if err != nil {
s.log.Info("Failed to handshake", "err", err)
continue
}
s.log.Info("New connection", "addr", sshConn.RemoteAddr(), "user", sshConn.User())
go ssh.DiscardRequests(requests)
go s.handleChannels(channels, sshConn)
}
}
func (s *Server) handleChannels(channels <-chan ssh.NewChannel, conn *ssh.ServerConn) {
for ch := range channels {
if t := ch.ChannelType(); t != "session" {
ch.Reject(ssh.UnknownChannelType, fmt.Sprintf("unknown channel type: %s", t))
continue
}
channel, requests, err := ch.Accept()
if err != nil {
continue
}
go func(in <-chan *ssh.Request) {
defer channel.Close()
for req := range in {
// log.Println("Request: ", req.Type, string(req.Payload))
ok := false
switch req.Type {
case "shell":
// We don't accept any commands (Payload),
// only the default shell.
if len(req.Payload) == 0 {
ok = true
}
case "pty-req":
// Responding 'ok' here will let the client
// know we have a pty ready for input
ok = true
case "window-change":
continue // no response
}
req.Reply(ok, nil)
}
}(requests)
go s.handleShell(channel, conn.User())
}
}
func (s *Server) handleShell(channel ssh.Channel, username string) {
defer func() {
channel.Close()
s.log.Info("User disconnected", "user", username)
}()
// create terminal
term := term.NewTerminal(channel, fmt.Sprintf("%s > ", username))
// check password
err := s.passwordRequest(term)
if err != nil {
return
}
// add as client
s.uc.AddMessageClient(entity.NewTerminalClient(term, username))
// Recieve user input and send to server
for {
// get user input
line, err := term.ReadLine()
if err != nil {
break
}
if len(line) > 0 {
if string(line[0]) == "/" {
switch line {
case "/exit":
return
case "/help":
term.Write([]byte(helpMessage))
default:
term.Write([]byte(helpMessage))
}
continue
}
s.log.Debug("new message", "user", username, "messg", line)
// send message to room
s.uc.SendMessage(line, username)
}
}
}

11
internal/usecase/msg.go Normal file
View File

@ -0,0 +1,11 @@
package usecase
import "gommunicator/internal/communicator"
func (u *Usecase) AddMessageClient(client communicator.Client) {
u.communicator.AddClient(client)
}
func (u *Usecase) SendMessage(msg, fromUsername string) {
u.communicator.SendMsg(msg, fromUsername)
}

View File

@ -0,0 +1,42 @@
package usecase
import (
"fmt"
"time"
"golang.org/x/exp/rand"
)
// GenNewPassword generates new password, writes it to the memory and returns.
func (u *Usecase) GenNewPassword() (string, error) {
rand.Seed(uint64(time.Now().UnixNano()))
// gen new password
pass := genPasswordString()
// save to the memory
if err := u.memory.SavePassword(pass); err != nil {
return "", fmt.Errorf("failed to save password in the memory: %w", err)
}
return pass, nil
}
const (
passwordLen = 8
letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"
)
func genPasswordString() string {
b := make([]byte, passwordLen)
for i := range b {
b[i] = letterBytes[rand.Intn(len(letterBytes))]
}
return string(b)
}
// PasswordExists checks that password exists in the memory.
func (u *Usecase) PasswordExists(password string) (bool, error) {
exists, err := u.memory.PasswordExists(password)
if err != nil {
return false, fmt.Errorf("failed to check password in the memory: %w", err)
}
return exists, nil
}

View File

@ -0,0 +1,22 @@
package usecase
import (
"log/slog"
"gommunicator/internal/communicator"
"gommunicator/internal/memory"
)
type Usecase struct {
log *slog.Logger
memory *memory.Memory
communicator *communicator.Communicator
}
func New(log *slog.Logger, memory *memory.Memory, communicator *communicator.Communicator) *Usecase {
return &Usecase{
log: log,
memory: memory,
communicator: communicator,
}
}

7
keys/gommunicator Normal file
View File

@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACC2W7jqv+IrEDb6dg2vR81uoUHnpCUueBZB0ajetr13hAAAAJjLUSTky1Ek
5AAAAAtzc2gtZWQyNTUxOQAAACC2W7jqv+IrEDb6dg2vR81uoUHnpCUueBZB0ajetr13hA
AAAEA1C1fC0k8MZ5S5vjcg43y2//ikwDvRbOp6GsLLAn/ZzrZbuOq/4isQNvp2Da9HzW6h
QeekJS54FkHRqN62vXeEAAAADnlhc2hAeWFzaC1hc3VzAQIDBAUGBw==
-----END OPENSSH PRIVATE KEY-----

1
keys/gommunicator.pub Normal file
View File

@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILZbuOq/4isQNvp2Da9HzW6hQeekJS54FkHRqN62vXeE yash@yash-asus