Compare commits

...

4 Commits

Author SHA1 Message Date
dalbodeule
a5047b7e8b debug, identify code added. 2025-10-16 23:00:54 +09:00
dalbodeule
486d18d207 add structured logging with Loki integration 2025-10-16 19:50:41 +09:00
dalbodeule
33ffd13663 discord addded 2025-10-14 21:26:50 +09:00
dalbodeule
bc3cd19b24 Dockerfile and some add/fix config 2025-10-14 21:21:12 +09:00
9 changed files with 2068 additions and 42 deletions

35
Dockerfile Normal file
View File

@@ -0,0 +1,35 @@
# Stage 1: Build the Go application
FROM golang:1.25-alpine AS builder
WORKDIR /app
# Copy go.mod and go.sum first to leverage Docker cache
COPY go.mod go.sum ./
RUN go mod download
# Copy the rest of the application source code
COPY . .
# Build the Go application
# CGO_ENABLED=0 disables CGO, creating a statically linked binary
# -o /app/main specifies the output path and name of the executable
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o /app/main .
# Stage 2: Create a minimal runtime image
FROM alpine:latest
# Install ca-certificates for HTTPS support if needed
RUN apk --no-cache add ca-certificates
WORKDIR /root/
# Copy the built executable from the builder stage
COPY --from=builder /app/main .
# Expose the port your application listens on (e.g., 2222)
EXPOSE 2222
ENV PORT=2222
ENV ROOT_PATH="/app/data"
# Command to run the application when the container starts
CMD ["./main"]

View File

@@ -1,2 +1,6 @@
# sshchat
## community
[![Discord](https://img.shields.io/discord/1250093195870867577)
](https://discord.gg/ABDkUtgzBj)

44
go.mod
View File

@@ -14,14 +14,58 @@ require (
require (
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dennwc/varint v1.0.0 // indirect
github.com/go-kit/kit v0.10.0 // indirect
github.com/go-kit/log v0.2.1 // indirect
github.com/go-logfmt/logfmt v0.5.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/grafana/loki-client-go v0.0.0-20240913122146-e119d400c3a5 // indirect
github.com/grafana/loki/pkg/push v0.0.0-20240912152814-63e84b476a9a // indirect
github.com/grafana/regexp v0.0.0-20220304095617-2e8d9baf4ac2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jpillora/backoff v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.4.3 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.20.4 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.63.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/prometheus/prometheus v0.35.0 // indirect
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
github.com/samber/lo v1.51.0 // indirect
github.com/samber/slog-common v0.19.0 // indirect
github.com/samber/slog-loki/v3 v3.6.0 // indirect
github.com/samber/slog-multi v1.5.0 // indirect
github.com/stretchr/testify v1.10.0 // indirect
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/goleak v1.3.0 // indirect
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect
golang.org/x/net v0.45.0 // indirect
golang.org/x/oauth2 v0.25.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect
google.golang.org/grpc v1.56.3 // indirect
google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
mellium.im/sasl v0.3.2 // indirect
)

1881
go.sum

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,6 @@
PORT=2222
GEOIP_DB=./GeoLite2-City.mmdb
GEOIP_DB=GeoLite2-City.mmdb
DB_DSN="postgrtesql://postgres:password@localhost/postgres"
ROOT_PATH="./"
LOKI_HOST=""
IDENTIFY="localdev"

97
main.go
View File

@@ -3,9 +3,16 @@ package main
import (
"fmt"
"log"
"log/slog"
"net"
"os"
"slices"
"strings"
"github.com/grafana/loki-client-go/loki"
slogloki "github.com/samber/slog-loki/v3"
slogmulti "github.com/samber/slog-multi"
"sshchat/db"
"sshchat/utils"
@@ -16,7 +23,7 @@ import (
var config = utils.GetConfig()
func sessionHandler(s ssh.Session, geoip *geoip2.Reader, pgDb *bun.DB) {
func sessionHandler(s ssh.Session, geoip *geoip2.Reader, pgDb *bun.DB, logger *slog.Logger) {
ptyReq, _, isPty := s.Pty()
if !isPty {
_, _ = fmt.Fprintln(s, "Err: PTY requires. Reconnect with -t option.")
@@ -24,47 +31,92 @@ func sessionHandler(s ssh.Session, geoip *geoip2.Reader, pgDb *bun.DB) {
return
}
remote := s.RemoteAddr().String()
addr := s.RemoteAddr().String()
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
remote := strings.Trim(host, "[]")
username := s.User()
geoStatus := utils.GetIPInfo(remote, geoip)
if geoStatus == nil {
log.Printf("[sshchat] %s connected. %s / UNK [FORCE DISCONNECT]", username, remote)
logger.Info("[sshchat] connected", "user", username, "remote", remote, "country", "UNK", "status", "FORCE DISCONNECT")
_, _ = fmt.Fprintf(s, "[system] Your access country is blacklisted. UNK")
_ = s.Close()
return
} else {
log.Printf("[sshchat] %s connected. %s / %s", username, remote, geoStatus.Country)
logger.Info("[sshchat] connected", "user", username, "remote", remote, "country", geoStatus.Country)
}
if slices.Contains(config.CountryBlacklist, geoStatus.Country) {
log.Printf("[sshchat] %s country blacklisted. %s", username, remote)
logger.Info("[sshchat] country blacklisted", "user", username, "remote", remote)
_, _ = fmt.Fprintf(s, "[system] Your access country is blacklisted. %s\n", geoStatus.Country)
_ = s.Close()
}
if geoStatus.Country == "ZZ" && !(strings.HasPrefix(remote, "127") || strings.HasPrefix(remote, "[::1]")) {
log.Printf("[sshchat] unknown country blacklisted. %s", username)
if geoStatus.Country == "ZZ" {
if strings.HasPrefix(remote, "127") || strings.HasPrefix(remote, "::1") {
logger.Info("[sshchat] localhost whitelisted", "user", username)
} else {
logger.Info("[sshchat] unknown country blacklisted", "user", username)
_, _ = fmt.Fprintf(s, "[system] Unknown country is blacklisted. %s\n", geoStatus.Country)
_ = s.Close()
} else {
log.Printf("[sshchat] %s is localhost whitelisted.", username)
}
}
client := utils.NewClient(s, ptyReq.Window.Height, ptyReq.Window.Width, username, remote)
defer func() {
client.Close()
log.Printf("[sshchat] %s disconnected. %s / %s", username, remote, geoStatus.Country)
logger.Info("[sshchat] disconnected", "user", username, "remote", remote, "country", geoStatus.Country)
}()
client.EventLoop()
}
func main() {
geoip, err := utils.GetDB(config.Geoip)
func getLogger(lokiHost string, identify string) (*slog.Logger, error) {
if lokiHost == "" {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
logger.Info("Loki host is not set. Logging to stdout")
return logger, nil
}
config, _ := loki.NewDefaultConfig(lokiHost)
config.TenantID = "sshchat"
client, err := loki.New(config)
if err != nil {
log.Fatalf("Geoip db is error: %v", err)
slog.Error("Failed to create Loki client", "error", err)
return nil, err
}
logger := slog.New(
slogmulti.Fanout(
slog.NewTextHandler(os.Stdout, nil),
slogloki.Option{Level: slog.LevelDebug, Client: client}.NewLokiHandler(),
),
)
logger = logger.With(
slog.String("app", "sshchat"),
slog.String("identify", identify),
)
logger.Info("Logging to Loki", "host", lokiHost)
return logger, nil
}
func main() {
logger, err := getLogger(config.LokiHost, config.Identify)
if err != nil {
logger.Error("Failed to create logger", "error", err)
return
}
geoip, err := utils.GetDB(config.RootPath + "/" + config.Geoip)
if err != nil {
logger.Error("Geoip db is error", "error", err)
return
}
pgDb, err := db.GetDB(config.PgDsn)
@@ -74,15 +126,16 @@ func main() {
port := config.Port
keys, err := utils.CheckHostKey()
keys, err := utils.CheckHostKey(config.RootPath)
if err != nil {
log.Print("Failed to check SSH keys: generate one.\n", err)
err = utils.GenerateHostKey()
logger.Error("Failed to check SSH keys: generate one", "error", err)
err = utils.GenerateHostKey(config.RootPath)
if err != nil {
log.Fatal(err)
logger.Error("Fatal error", "error", err)
return
}
keys, err = utils.CheckHostKey()
keys, err = utils.CheckHostKey(config.RootPath)
if err != nil {
log.Fatal(err)
}
@@ -91,7 +144,7 @@ func main() {
s := &ssh.Server{
Addr: ":" + port,
Handler: func(s ssh.Session) {
sessionHandler(s, geoip, pgDb)
sessionHandler(s, geoip, pgDb, logger)
},
}
for _, key := range keys {
@@ -102,6 +155,8 @@ func main() {
_ = pgDb.Close()
}()
log.Print("Listening on :" + port)
log.Fatal(s.ListenAndServe())
logger.Info("Starting server", "port", port)
if err := s.ListenAndServe(); err != nil {
logger.Error("Server failed", "error", err)
}
}

View File

@@ -1,7 +1,6 @@
package utils
import (
"log"
"os"
"strings"
@@ -13,23 +12,29 @@ type Config struct {
Geoip string
CountryBlacklist []string
PgDsn string
RootPath string
LokiHost string
Identify string
}
func GetConfig() *Config {
err := godotenv.Load()
if err != nil {
log.Fatal("Error loading .env file")
}
_ = godotenv.Load()
port := os.Getenv("PORT")
geoipDbfile := os.Getenv("GEOIP_DB")
countryBlacklist := os.Getenv("COUNTRY_BLACKLIST")
pgDsn := os.Getenv("DB_DSN")
rootPath := os.Getenv("ROOT_PATH")
lokiHost := os.Getenv("LOKI_HOST")
identify := os.Getenv("IDENTIFY")
return &Config{
Port: port,
Geoip: geoipDbfile,
CountryBlacklist: strings.Split(countryBlacklist, ","),
PgDsn: pgDsn,
RootPath: rootPath,
LokiHost: lokiHost,
Identify: identify,
}
}

View File

@@ -30,10 +30,7 @@ func GetIPInfo(ip string, db *geoip2.Reader) *IpInfo {
country := func(ip net.IP) string {
country, _ := db.Country(parsedIp)
println(country.Country.IsoCode)
if country != nil && country.Country.IsoCode != "" {
println(country.Country.IsoCode)
return country.Country.IsoCode
} else {
return "ZZ"

View File

@@ -15,8 +15,8 @@ import (
// GenerateHostKey는 'keys' 디렉토리를 생성하고, RSA, ECDSA, Ed25519 호스트 개인 키를 생성하여 저장합니다.
// 개인 키는 OpenSSH 형식으로 암호화되어 저장됩니다.
func GenerateHostKey() error {
const keyDir = "./keys"
func GenerateHostKey(rootPath string) error {
keyDir := rootPath + "/keys"
// 1. 키 디렉토리 생성
if err := os.MkdirAll(keyDir, 0700); err != nil {
@@ -103,25 +103,27 @@ func generateAndSaveKey(path string, keyType string) error {
return nil
}
func CheckHostKey() ([]ssh.Signer, error) {
keyFiles := []string{"./keys/id_rsa", "./keys/id_ecdsa", "./keys/id_ed25519"}
func CheckHostKey(rootPath string) ([]ssh.Signer, error) {
keyFiles := []string{"keys/id_rsa", "keys/id_ecdsa", "keys/id_ed25519"}
for _, keyFile := range keyFiles {
if _, err := os.Stat(keyFile); os.IsNotExist(err) {
return nil, fmt.Errorf("key file %s does not exist", keyFile)
tmp := rootPath + "/" + keyFile
if _, err := os.Stat(tmp); os.IsNotExist(err) {
return nil, fmt.Errorf("key file %s does not exist", tmp)
}
}
var keys = make([]ssh.Signer, 0)
for _, keyFile := range keyFiles {
keyBytes, err := os.ReadFile(keyFile)
tmp := rootPath + "/" + keyFile
keyBytes, err := os.ReadFile(tmp)
if err != nil {
return nil, fmt.Errorf("failed to read key file %s: %v", keyFile, err)
return nil, fmt.Errorf("failed to read key file %s: %v", tmp, err)
}
signer, err := ssh.ParsePrivateKey(keyBytes)
if err != nil {
return nil, fmt.Errorf("failed to parse private key %s: %v", keyFile, err)
return nil, fmt.Errorf("failed to parse private key %s: %v", tmp, err)
}
keys = append(keys, signer)