mirror of
https://github.com/dalbodeule/sshchat.git
synced 2025-12-07 22:55:44 +09:00
chat and debugs.
This commit is contained in:
28
main.go
28
main.go
@@ -1,7 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
@@ -11,6 +11,28 @@ import (
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func sessionHandler(s ssh.Session) {
|
||||
ptyReq, _, isPty := s.Pty()
|
||||
if !isPty {
|
||||
_, _ = fmt.Fprintln(s, "Err: PTY requires. Reconnect with -t option.")
|
||||
_ = s.Exit(1)
|
||||
return
|
||||
}
|
||||
|
||||
remote := s.RemoteAddr().String()
|
||||
username := s.User()
|
||||
|
||||
log.Printf("[sshchat] %s connected. %s", username, remote)
|
||||
client := utils.NewClient(s, ptyReq.Window.Height, ptyReq.Window.Width, username, remote)
|
||||
|
||||
defer func() {
|
||||
client.Close()
|
||||
log.Printf("[sshchat] %s disconnected. %s", username, remote)
|
||||
}()
|
||||
|
||||
client.EventLoop()
|
||||
}
|
||||
|
||||
func main() {
|
||||
err := godotenv.Load()
|
||||
if err != nil {
|
||||
@@ -33,10 +55,6 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
sessionHandler := func(s ssh.Session) {
|
||||
_, _ = io.WriteString(s, "Hello World\n")
|
||||
}
|
||||
|
||||
s := &ssh.Server{
|
||||
Addr: ":" + port,
|
||||
Handler: sessionHandler,
|
||||
|
||||
410
utils/client.go
Normal file
410
utils/client.go
Normal file
@@ -0,0 +1,410 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gliderlabs/ssh"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
Timestamp time.Time
|
||||
Username string
|
||||
Content string
|
||||
}
|
||||
|
||||
type Input struct {
|
||||
Buffer []rune
|
||||
MaxLen int
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
session ssh.Session
|
||||
|
||||
mu sync.Mutex
|
||||
width int
|
||||
height int
|
||||
input Input
|
||||
messages []Message
|
||||
|
||||
wg sync.WaitGroup
|
||||
username string
|
||||
ip string
|
||||
|
||||
// Event channels
|
||||
RenderCh chan struct{}
|
||||
EnterCh chan struct{}
|
||||
WinSizeChangedCh chan struct{}
|
||||
CloseCh chan struct{}
|
||||
|
||||
// Debounce
|
||||
renderDebounceTimer *time.Timer
|
||||
renderDebounceDur time.Duration
|
||||
|
||||
// internals
|
||||
cancel context.CancelFunc
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// NewClient creates a Client bound to an ssh.Session and initial state.
|
||||
// It starts background goroutines to watch input, window-size changes, and session close.
|
||||
func NewClient(s ssh.Session, w int, h int, username string, ip string) *Client {
|
||||
input := Input{
|
||||
Buffer: make([]rune, 0, 128),
|
||||
MaxLen: 128,
|
||||
}
|
||||
c := &Client{
|
||||
session: s,
|
||||
width: w,
|
||||
height: h,
|
||||
username: username,
|
||||
ip: ip,
|
||||
input: input,
|
||||
messages: make([]Message, 0),
|
||||
RenderCh: make(chan struct{}, 1),
|
||||
EnterCh: make(chan struct{}, 1),
|
||||
WinSizeChangedCh: make(chan struct{}, 1),
|
||||
CloseCh: make(chan struct{}, 1),
|
||||
renderDebounceDur: 50 * time.Millisecond,
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
c.cancel = cancel
|
||||
|
||||
// Input watcher (Enter, Ctrl+C, Ctrl+D) and render trigger
|
||||
c.wg.Add(1)
|
||||
go func() {
|
||||
defer c.wg.Done()
|
||||
reader := bufio.NewReader(s)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
r, size, err := reader.ReadRune()
|
||||
if err != nil {
|
||||
if err == io.EOF || isSessionClosedErr(err) {
|
||||
c.emitClose()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if size == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
switch r {
|
||||
case '\r', '\n': // **[수정] \r과 \n을 함께 처리**
|
||||
if len(c.input.Buffer) > 0 {
|
||||
c.messages = append(c.messages, Message{
|
||||
Timestamp: time.Now(),
|
||||
Username: c.username,
|
||||
Content: string(c.input.Buffer),
|
||||
})
|
||||
c.input.Buffer = c.input.Buffer[:0]
|
||||
}
|
||||
c.mu.Unlock()
|
||||
c.trySend(c.EnterCh)
|
||||
c.TrySendRender()
|
||||
case 0x03: // Ctrl+C
|
||||
c.mu.Unlock()
|
||||
c.emitClose()
|
||||
return
|
||||
case 0x04: // Ctrl+D
|
||||
c.mu.Unlock()
|
||||
c.emitClose()
|
||||
return
|
||||
case '\b', 0x7f: // Backspace (0x08) 또는 Delete (0x7f)
|
||||
if len(c.input.Buffer) > 0 {
|
||||
c.input.Buffer = c.input.Buffer[:len(c.input.Buffer)-1]
|
||||
c.mu.Unlock()
|
||||
c.TrySendRender()
|
||||
} else {
|
||||
c.mu.Unlock()
|
||||
}
|
||||
default:
|
||||
// 출력 불가능한 문자 (제어 문자 등)는 무시
|
||||
if r < 0x20 && r != '\t' {
|
||||
c.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
if len(c.input.Buffer) < c.input.MaxLen {
|
||||
// 룬(rune)을 []rune 슬라이스에 추가
|
||||
c.input.Buffer = append(c.input.Buffer, r)
|
||||
c.mu.Unlock()
|
||||
c.TrySendRender()
|
||||
} else {
|
||||
// 버퍼가 꽉 찬 경우 렌더링 요청을 보내지 않습니다.
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Window size change watcher
|
||||
c.wg.Add(1)
|
||||
go func() {
|
||||
defer c.wg.Done()
|
||||
_, winCh, _ := s.Pty()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case win, ok := <-winCh:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.width = win.Width
|
||||
c.height = win.Height
|
||||
c.mu.Unlock()
|
||||
c.trySend(c.WinSizeChangedCh)
|
||||
c.TrySendRender()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Session close watcher (fallback)
|
||||
c.wg.Add(1)
|
||||
go func() {
|
||||
defer c.wg.Done()
|
||||
<-s.Context().Done()
|
||||
c.emitClose()
|
||||
}()
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// Word wrap을 위한 헬퍼 함수
|
||||
// 반환되는 각 문자열은 한 줄의 내용이며, 줄 바꿈 문자는 포함하지 않습니다.
|
||||
func calculateMessageLines(header string, content []rune, w int) []string {
|
||||
lines := make([]string, 0)
|
||||
currentLine := ""
|
||||
lineWidth := w
|
||||
|
||||
// 1. 헤더 처리
|
||||
if utf8.RuneCountInString(header) > 0 {
|
||||
currentLine = header
|
||||
lineWidth -= utf8.RuneCountInString(header)
|
||||
}
|
||||
|
||||
// 2. 내용 처리
|
||||
contentIdx := 0
|
||||
for contentIdx < len(content) {
|
||||
remainingContent := content[contentIdx:]
|
||||
spaceLeft := lineWidth - utf8.RuneCountInString(currentLine)
|
||||
|
||||
if len(remainingContent) <= spaceLeft {
|
||||
// 남은 내용이 현재 줄에 모두 들어갈 경우
|
||||
currentLine += string(remainingContent)
|
||||
contentIdx = len(content)
|
||||
} else if spaceLeft > 0 {
|
||||
// 현재 줄에 일부만 넣을 경우
|
||||
toAdd := remainingContent[:spaceLeft]
|
||||
currentLine += string(toAdd)
|
||||
contentIdx += len(toAdd)
|
||||
|
||||
// 줄이 꽉 찼으므로 다음 줄로 넘깁니다.
|
||||
lines = append(lines, currentLine)
|
||||
currentLine = ""
|
||||
lineWidth = w // 다음 줄은 전체 너비
|
||||
} else {
|
||||
// 현재 줄이 꽉 찼거나 (spaceLeft <= 0), 헤더만 있는 경우
|
||||
// 현재 줄을 저장하고 다음 줄로 넘깁니다.
|
||||
lines = append(lines, currentLine)
|
||||
currentLine = ""
|
||||
lineWidth = w // 다음 줄은 전체 너비
|
||||
}
|
||||
}
|
||||
|
||||
// 마지막 줄이 비어있지 않으면 추가
|
||||
if currentLine != "" {
|
||||
lines = append(lines, currentLine)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// handleRender는 화면 렌더링을 처리합니다.
|
||||
func (c *Client) handleRender() {
|
||||
w, h := c.Size()
|
||||
s := c.Session()
|
||||
|
||||
// 1. 화면을 지우고 커서를 맨 위로 이동 (화면을 새로 그릴 준비)
|
||||
fmt.Fprint(s, "\x1b[2J\x1b[H")
|
||||
|
||||
// **[수정] 2. 입력 프롬프트 렌더링 영역 계산**
|
||||
promptLine := h // 프롬프트가 위치할 맨 아래 행
|
||||
|
||||
// **[수정] 3. 메시지 렌더링 (아래에서 위로 스크롤)**
|
||||
maxMessageHeight := promptLine - 1 // 메시지가 출력될 수 있는 최대 행
|
||||
|
||||
messages := c.messages
|
||||
currentY := maxMessageHeight // 현재 출력할 행 (bottom-up)
|
||||
|
||||
// 메시지 인덱스를 역순으로 순회 (최신 메시지가 화면 아래에 위치)
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
msg := messages[i]
|
||||
|
||||
header := fmt.Sprintf("[%s %s] ", msg.Timestamp.Format("2006-01-02 15:04:05"), msg.Username)
|
||||
content := []rune(msg.Content)
|
||||
|
||||
lines := calculateMessageLines(header, content, w)
|
||||
numLines := len(lines)
|
||||
|
||||
// 현재 행이 출력될 공간이 부족하면 중단
|
||||
if currentY-numLines < 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// 커서를 메시지가 시작될 행으로 이동 (currentY + 1은 1-based index)
|
||||
currentY -= numLines
|
||||
fmt.Fprintf(s, "\x1b[%dH", currentY+1)
|
||||
|
||||
// 메시지 출력 (줄 바꿈 포함)
|
||||
for _, line := range lines {
|
||||
fmt.Fprintln(s, line)
|
||||
}
|
||||
if currentY < 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 입력 프롬프트 렌더링 (화면 맨 아래 행에 출력)
|
||||
fmt.Fprintf(s, "\x1b[%dH", promptLine)
|
||||
promptRunes := append([]rune("> "), c.input.Buffer...)
|
||||
|
||||
// 프롬프트를 화면 너비 w에 맞게 출력 (줄 바꿈은 고려하지 않음)
|
||||
if len(promptRunes) > w {
|
||||
fmt.Fprint(s, string(promptRunes[:w]))
|
||||
} else {
|
||||
fmt.Fprint(s, string(promptRunes))
|
||||
}
|
||||
|
||||
// 5. 마지막으로 커서를 입력 위치로 재배치 (promptLine 행, 프롬프트 문자열 끝)
|
||||
cursorX := utf8.RuneCountInString("> ") + len(c.input.Buffer) + 1
|
||||
if cursorX > w {
|
||||
cursorX = w
|
||||
}
|
||||
fmt.Fprintf(s, "\x1b[%d;%dH", promptLine, cursorX)
|
||||
}
|
||||
|
||||
func (c *Client) handleClose() {
|
||||
c.emitClose()
|
||||
}
|
||||
|
||||
// Accessors
|
||||
|
||||
func (c *Client) Session() ssh.Session { return c.session }
|
||||
|
||||
func (c *Client) Username() string { return c.username }
|
||||
|
||||
func (c *Client) IP() string { return c.ip }
|
||||
|
||||
func (c *Client) Size() (int, int) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.width, c.height
|
||||
}
|
||||
|
||||
// Close shuts down watchers and closes event channels.
|
||||
func (c *Client) Close() {
|
||||
c.emitClose()
|
||||
c.wg.Wait()
|
||||
c.once.Do(func() {
|
||||
close(c.RenderCh)
|
||||
close(c.EnterCh)
|
||||
close(c.WinSizeChangedCh)
|
||||
close(c.CloseCh)
|
||||
})
|
||||
}
|
||||
|
||||
// Internal utilities
|
||||
func (c *Client) emitClose() {
|
||||
c.once.Do(func() {
|
||||
// **[추가]** SSH 세션 자체를 닫습니다.
|
||||
if c.session != nil {
|
||||
_ = c.session.Close() // 오류 처리는 간단히 무시합니다.
|
||||
}
|
||||
|
||||
// drain cancel after a short delay to allow pending events
|
||||
go func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
}()
|
||||
c.trySend(c.CloseCh)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) trySend(ch chan struct{}) {
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) TrySendRender() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.renderDebounceTimer != nil {
|
||||
c.renderDebounceTimer.Reset(c.renderDebounceDur)
|
||||
return
|
||||
}
|
||||
|
||||
c.renderDebounceTimer = time.AfterFunc(c.renderDebounceDur, func() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.trySend(c.RenderCh)
|
||||
|
||||
c.renderDebounceTimer.Stop()
|
||||
c.renderDebounceTimer = nil
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) HandleRender() {
|
||||
c.handleRender()
|
||||
}
|
||||
|
||||
func (c *Client) EventLoop() {
|
||||
for {
|
||||
select {
|
||||
case <-c.RenderCh:
|
||||
c.HandleRender()
|
||||
|
||||
case <-c.EnterCh:
|
||||
// Input watcher가 메시지 버퍼를 변경하고 EnterCh를 보냈습니다.
|
||||
// 상태가 변경되었으므로 렌더링을 요청합니다.
|
||||
c.HandleRender()
|
||||
|
||||
case <-c.WinSizeChangedCh:
|
||||
// WinSize watcher가 이미 c.width, c.height를 업데이트했습니다.
|
||||
c.HandleRender()
|
||||
|
||||
case <-c.CloseCh:
|
||||
c.handleClose()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isSessionClosedErr(err error) bool {
|
||||
// Conservative check; specific implementations may vary.
|
||||
return err == io.EOF
|
||||
}
|
||||
|
||||
// handleEnter 함수가 더 이상 EventLoop에서 필요하지 않으므로,
|
||||
// Client 구조체에서 관련 메서드인 handleEnter는 삭제하는 것이 깔끔하지만,
|
||||
// 현재 코드를 유지하기 위해 body를 비워둡니다.
|
||||
Reference in New Issue
Block a user