314 lines
7.7 KiB
Go
314 lines
7.7 KiB
Go
package main
|
|
|
|
import (
|
|
"canguidev/shelfy/internal/db"
|
|
"canguidev/shelfy/internal/routes"
|
|
"canguidev/shelfy/internal/utils"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"embed"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/pkg/sftp"
|
|
"golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
// ====== Config ======
|
|
var (
|
|
SFTPBaseDir = "upload" // utilisé seulement pour vérif existence + logs
|
|
BindAddr = "0.0.0.0:2222" // adapte si tu veux binder sur une IP privée
|
|
LoginUser = "cangui2089"
|
|
LoginPass = "GHT30k7!"
|
|
AllowedIPs = []string{"82.65.73.115"} // IPs autorisées (pour pass bypass)
|
|
// Si tu veux fermer le service à toute IP non autorisée AVANT handshake, mets true :
|
|
OnlyAllowListedBeforeHandshake = false
|
|
)
|
|
|
|
// ====== Helpers ======
|
|
func ipAllowed(addr string) bool {
|
|
host, _, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
host = addr
|
|
}
|
|
for _, allow := range AllowedIPs {
|
|
if host == allow {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isBenignHandshakeErr(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
s := err.Error()
|
|
switch {
|
|
case strings.Contains(s, "unmarshal error for field Language of type disconnectMsg"),
|
|
strings.Contains(s, "connection reset by peer"),
|
|
strings.Contains(s, "no common algorithm"),
|
|
strings.Contains(s, "read: connection timed out"),
|
|
strings.Contains(s, "unexpected message"),
|
|
strings.Contains(s, "EOF"):
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func loadOrCreateEd25519(path string) (ssh.Signer, error) {
|
|
if _, err := os.Stat(path); err == nil {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ssh.ParsePrivateKey(b)
|
|
}
|
|
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pkcs8, err := x509.MarshalPKCS8PrivateKey(priv)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8})
|
|
if err := os.WriteFile(path, pemBytes, 0o600); err != nil {
|
|
return nil, err
|
|
}
|
|
return ssh.ParsePrivateKey(pemBytes)
|
|
}
|
|
|
|
func loadOrCreateRSA(path string) (ssh.Signer, error) {
|
|
if _, err := os.Stat(path); err == nil {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ssh.ParsePrivateKey(b)
|
|
}
|
|
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pkcs1 := x509.MarshalPKCS1PrivateKey(priv)
|
|
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: pkcs1})
|
|
if err := os.WriteFile(path, pemBytes, 0o600); err != nil {
|
|
return nil, err
|
|
}
|
|
return ssh.ParsePrivateKey(pemBytes)
|
|
}
|
|
|
|
// ====== Rate limit très simple ======
|
|
type bucket struct {
|
|
tokens int
|
|
last time.Time
|
|
}
|
|
var rl = struct {
|
|
m map[string]*bucket
|
|
sync.Mutex
|
|
}{m: map[string]*bucket{}}
|
|
|
|
func allow(ip string, ratePerMin, burst int) bool {
|
|
rl.Lock()
|
|
defer rl.Unlock()
|
|
now := time.Now()
|
|
b := rl.m[ip]
|
|
if b == nil {
|
|
b = &bucket{tokens: burst, last: now}
|
|
rl.m[ip] = b
|
|
}
|
|
elapsed := now.Sub(b.last).Minutes()
|
|
add := int(elapsed * float64(ratePerMin))
|
|
if add > 0 {
|
|
if b.tokens+add > burst {
|
|
b.tokens = burst
|
|
} else {
|
|
b.tokens += add
|
|
}
|
|
b.last = now
|
|
}
|
|
if b.tokens <= 0 {
|
|
return false
|
|
}
|
|
b.tokens--
|
|
return true
|
|
}
|
|
|
|
// ====== SFTP Server (sans Chdir global) ======
|
|
func startSFTPServer(baseDir, bindAddr string) {
|
|
absBase, _ := filepath.Abs(baseDir)
|
|
if fi, err := os.Stat(absBase); err != nil || !fi.IsDir() {
|
|
log.Fatalf("[SFTP] Le dossier %s est manquant ou invalide: %v", absBase, err)
|
|
}
|
|
|
|
signerED, err := loadOrCreateEd25519(filepath.Join(absBase, "sftp_host_ed25519.pem"))
|
|
if err != nil {
|
|
log.Fatalf("[SFTP] Host key ed25519 error: %v", err)
|
|
}
|
|
signerRSA, err := loadOrCreateRSA(filepath.Join(absBase, "sftp_host_rsa.pem"))
|
|
if err != nil {
|
|
log.Fatalf("[SFTP] Host key RSA error: %v", err)
|
|
}
|
|
|
|
cfg := &ssh.ServerConfig{
|
|
MaxAuthTries: 2, // réduire le bruteforce
|
|
PasswordCallback: func(meta ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) {
|
|
remote := meta.RemoteAddr().String()
|
|
user := meta.User()
|
|
if ipAllowed(remote) {
|
|
log.Printf("[SFTP] Auth IP-allowlist OK from=%s user=%s (mdp vide accepté)", remote, user)
|
|
return nil, nil
|
|
}
|
|
if user == LoginUser && string(pass) == LoginPass {
|
|
log.Printf("[SFTP] Auth OK from=%s user=%s", remote, user)
|
|
return nil, nil
|
|
}
|
|
log.Printf("[SFTP] Auth FAIL from=%s user=%s", remote, user)
|
|
return nil, fmt.Errorf("auth failed")
|
|
},
|
|
}
|
|
cfg.AddHostKey(signerED)
|
|
cfg.AddHostKey(signerRSA)
|
|
|
|
ln, err := net.Listen("tcp", bindAddr)
|
|
if err != nil {
|
|
log.Fatalf("[SFTP] Listen(%s): %v", bindAddr, err)
|
|
}
|
|
log.Printf("[SFTP] Écoute sur sftp://%s@%s (base=%s)", LoginUser, bindAddr, absBase)
|
|
log.Printf("[SFTP] IP sans mot de passe autorisées: %v | OnlyAllowListedBeforeHandshake=%v", AllowedIPs, OnlyAllowListedBeforeHandshake)
|
|
|
|
for {
|
|
nConn, err := ln.Accept()
|
|
if err != nil {
|
|
log.Printf("[SFTP] Accept err: %v", err)
|
|
continue
|
|
}
|
|
|
|
remoteIP, _, _ := net.SplitHostPort(nConn.RemoteAddr().String())
|
|
|
|
// Optionnel : fermer immédiatement toute IP non allowlist
|
|
if OnlyAllowListedBeforeHandshake && !ipAllowed(nConn.RemoteAddr().String()) {
|
|
_ = nConn.Close()
|
|
continue
|
|
}
|
|
|
|
// Rate limit anti-bruit
|
|
if !allow(remoteIP, 6, 12) { // ~6 requêtes/min, rafale 12
|
|
_ = nConn.Close()
|
|
continue
|
|
}
|
|
|
|
if tc, ok := nConn.(*net.TCPConn); ok {
|
|
_ = tc.SetKeepAlive(true)
|
|
_ = tc.SetKeepAlivePeriod(15 * time.Second)
|
|
}
|
|
|
|
go func(conn net.Conn) {
|
|
sshConn, chans, reqs, err := ssh.NewServerConn(conn, cfg)
|
|
if err != nil {
|
|
if !isBenignHandshakeErr(err) {
|
|
log.Printf("[SFTP] Handshake warn: %v", err)
|
|
}
|
|
_ = conn.Close()
|
|
return
|
|
}
|
|
log.Printf("[SFTP] Client connecté: %s (%s)", sshConn.RemoteAddr(), sshConn.ClientVersion())
|
|
go ssh.DiscardRequests(reqs)
|
|
|
|
for newCh := range chans {
|
|
if newCh.ChannelType() != "session" {
|
|
newCh.Reject(ssh.UnknownChannelType, "only session channels are allowed")
|
|
continue
|
|
}
|
|
ch, reqs, err := newCh.Accept()
|
|
if err != nil {
|
|
log.Printf("[SFTP] Accept channel: %v", err)
|
|
continue
|
|
}
|
|
|
|
go func(in <-chan *ssh.Request) {
|
|
for req := range in {
|
|
switch req.Type {
|
|
case "subsystem":
|
|
// payload = uint32(len) + "sftp"
|
|
if len(req.Payload) >= 4 && string(req.Payload[4:]) == "sftp" {
|
|
// NB: ceci ne "jaille" pas dans absBase ; pas de Chdir global non plus.
|
|
server, err := sftp.NewServer(ch)
|
|
if err != nil {
|
|
log.Printf("[SFTP] init error: %v", err)
|
|
_, _ = ch.Stderr().Write([]byte("sftp init error\n"))
|
|
_ = ch.Close()
|
|
return
|
|
}
|
|
_ = req.Reply(true, nil)
|
|
if err := server.Serve(); err == io.EOF {
|
|
_ = server.Close()
|
|
} else if err != nil {
|
|
log.Printf("[SFTP] serve error: %v", err)
|
|
}
|
|
return
|
|
}
|
|
_ = req.Reply(false, nil)
|
|
default:
|
|
_ = req.Reply(false, nil)
|
|
}
|
|
}
|
|
}(reqs)
|
|
}
|
|
}(nConn)
|
|
}
|
|
}
|
|
|
|
|
|
// ---------- HTTP (Gin) ----------
|
|
|
|
//go:embed web/* web/**/*
|
|
// toutes les ressources front embarquées
|
|
var webFS embed.FS
|
|
|
|
func startHTTP() {
|
|
bd := db.InitDB()
|
|
app := gin.Default()
|
|
|
|
api := app.Group("/api/v1")
|
|
routes.AddRoutes(api, bd)
|
|
utils.CreateDefaultFolder(bd)
|
|
|
|
// Sous-FS pointé sur "web"
|
|
|
|
|
|
// /static -> ressources front
|
|
app.Static("/static", "./web")
|
|
app.GET("/", func(c *gin.Context) { c.File("./web/index.html") })
|
|
app.NoRoute(func(c *gin.Context) {
|
|
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
|
|
c.JSON(404, gin.H{"error":"Not found"}); return
|
|
}
|
|
c.File("./web/index.html")
|
|
})
|
|
log.Println("[HTTP] Serveur Gin sur http://0.0.0.0:8080")
|
|
_ = app.Run(":8080")
|
|
}
|
|
|
|
|
|
func main() {
|
|
// SFTP sur 2222 (root = ./upload)
|
|
go startSFTPServer(SFTPBaseDir, BindAddr)
|
|
// HTTP normal
|
|
startHTTP()
|
|
|
|
|
|
}
|