feat(mail): ING-06 TLS/STARTTLS-Absicherung für IMAP/POP3/SMTP
Neues Paket mail/internal/tlscert: hot-reloadbarer Zertifikat-Store (Store.GetCertificate wird bei jedem neuen TLS-Handshake aufgerufen, Replace tauscht atomar aus — bestehende Verbindungen bleiben mit ihrem ausgehandelten Zertifikat unberührt, Akzeptanzkriterium 3) sowie eine gehärtete tls.Config (MinVersion TLS 1.2, ausschließlich AEAD-Suiten für TLS 1.2, Akzeptanzkriterium 2). UpgradeServer führt den STARTTLS-Handschlag durch, gemeinsam genutzt von allen drei Protokollen. IMAP bekommt STARTTLS (RFC 3501), POP3 STLS (RFC 2595), SMTP STARTTLS (RFC 3207) — jeweils nur vor der Anmeldung erlaubt, Reader/Writer nach dem Handschlag neu aufgesetzt (Schutz vor Command-Injection durch vor dem Handshake gepufferte Klartextdaten). LOGIN (IMAP) und PASS (POP3) werden zurückgewiesen, solange der Server TLS anbietet, die Verbindung aber weder implizit noch per STARTTLS verschlüsselt ist (Akzeptanzkriterium 1). Implizites TLS (Port 993/995/465) braucht keine Codeänderung — Server.Serve nimmt jeden net.Listener entgegen, ein tls.NewListener-gewrapptes Listener liefert bereits *tls.Conn, von der Session per Typ-Assertion erkannt. Ohne TLS-Konfiguration bleibt das bisherige Klartextverhalten unverändert (Rückwärtskompatibilität zu ING-01/ING-02/ING-03). Alle drei Pflichtprüfungen mit echten Nachweisen durchgeführt: echter openssl-s_client-Scan gegen den laufenden SMTP-Server (TLS 1.3, starke AEAD-Suite bei normaler Verbindung; kein Cipher ausgehandelt bei erzwungenen CBC-Suiten) ergänzt um automatisierte crypto/tls-Negativtests (veraltete Version, schwache Suite — openssl 3.5.6 auf diesem Host verweigert das Erzwingen von Legacy-TLS clientseitig, im Prüfprotokoll begründet); Login-ohne-TLS wird in IMAP und POP3 nachweislich verweigert, nach STARTTLS/STLS nachweislich akzeptiert; Zertifikatsrotation im laufenden Betrieb in allen drei Protokollen ohne Unterbrechung bestehender Sessions, neue Verbindungen bekommen sofort das neue Zertifikat. go build/go vet/golangci-lint clean, gesamtes Mail-Modul (~28 Pakete) regressionsfrei getestet.
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/tlscert"
|
||||
)
|
||||
|
||||
func (s *Session) handleHelo(verb, arg string) bool {
|
||||
@@ -17,11 +20,48 @@ func (s *Session) handleHelo(verb, arg string) bool {
|
||||
s.state = Ready
|
||||
|
||||
if verb == "EHLO" {
|
||||
return s.replyMultiline(250, []string{"nexarch-mail greets " + arg, "8BITMIME"}) == nil
|
||||
lines := []string{"nexarch-mail greets " + arg, "8BITMIME"}
|
||||
if s.tlsConfig != nil && !s.tlsActive {
|
||||
lines = append(lines, "STARTTLS")
|
||||
}
|
||||
return s.replyMultiline(250, lines) == nil
|
||||
}
|
||||
return s.reply(250, "nexarch-mail greets "+arg) == nil
|
||||
}
|
||||
|
||||
// handleStartTLS implementiert STARTTLS (RFC 3207): TLS-Aufwertung der
|
||||
// bestehenden Klartextverbindung.
|
||||
func (s *Session) handleStartTLS(ctx context.Context) bool {
|
||||
if s.tlsConfig == nil {
|
||||
return s.reply(454, "TLS not available") == nil
|
||||
}
|
||||
if s.tlsActive {
|
||||
return s.reply(503, "already using TLS") == nil
|
||||
}
|
||||
if err := s.reply(220, "Ready to start TLS"); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
tlsConn, err := tlscert.UpgradeServer(ctx, s.conn, s.tlsConfig)
|
||||
if err != nil {
|
||||
// Handshake fehlgeschlagen -> Verbindung beenden, kein Rückfall
|
||||
// auf Klartext.
|
||||
return false
|
||||
}
|
||||
s.conn = tlsConn
|
||||
// Reader/Writer NEU aufsetzen: verhindert, dass vor dem Handshake
|
||||
// gepufferte Klartextdaten nach dem Wechsel noch als Kommandos
|
||||
// verarbeitet werden (Command-Injection-Schutz).
|
||||
s.reader = bufio.NewReaderSize(tlsConn, maxCommandLineBytes)
|
||||
s.writer = bufio.NewWriter(tlsConn)
|
||||
s.tlsActive = true
|
||||
// Envelope nach STARTTLS zurücksetzen (RFC 3207 §4.2).
|
||||
s.from = ""
|
||||
s.to = nil
|
||||
s.state = Ready
|
||||
return true
|
||||
}
|
||||
|
||||
// handleMailFrom ist Teil des Envelope-Aufbaus (Akzeptanzkriterium 1):
|
||||
// die Absenderadresse wird vor der Annahme validiert.
|
||||
func (s *Session) handleMailFrom(arg string) bool {
|
||||
|
||||
@@ -2,6 +2,7 @@ package smtp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
@@ -13,12 +14,13 @@ const defaultMaxMessageBytes = 25 * 1024 * 1024 // 25 MiB
|
||||
|
||||
// Server nimmt SMTP-Verbindungen an und bedient jede in einer eigenen
|
||||
// Goroutine — gleiches Muster wie mail/internal/imap.Server und
|
||||
// mail/internal/pop3.Server. TLS/STARTTLS ist Sache von ING-06,
|
||||
// Rate-Limiting Sache von ING-09, Protokoll-Logging Sache von ING-08 —
|
||||
// keine dieser Kacheln.
|
||||
// mail/internal/pop3.Server. tlsConfig ist optional (ING-06): nil
|
||||
// bedeutet, STARTTLS ist nicht verfügbar. Rate-Limiting ist Sache von
|
||||
// ING-09, Protokoll-Logging Sache von ING-08 — keine dieser Kacheln.
|
||||
type Server struct {
|
||||
sink MessageSink
|
||||
maxMessageBytes int64
|
||||
tlsConfig *tls.Config
|
||||
}
|
||||
|
||||
func NewServer(sink MessageSink) *Server {
|
||||
@@ -31,6 +33,13 @@ func NewServerWithMaxMessageBytes(sink MessageSink, maxMessageBytes int64) *Serv
|
||||
return &Server{sink: sink, maxMessageBytes: maxMessageBytes}
|
||||
}
|
||||
|
||||
// NewServerWithMaxMessageBytesAndTLSConfig erlaubt zusätzlich, STARTTLS
|
||||
// zu aktivieren (ING-06). tlsConfig sollte über tlscert.Store.Config()
|
||||
// erzeugt werden.
|
||||
func NewServerWithMaxMessageBytesAndTLSConfig(sink MessageSink, maxMessageBytes int64, tlsConfig *tls.Config) *Server {
|
||||
return &Server{sink: sink, maxMessageBytes: maxMessageBytes, tlsConfig: tlsConfig}
|
||||
}
|
||||
|
||||
// Serve nimmt Verbindungen auf listener an, bis ctx beendet wird.
|
||||
func (srv *Server) Serve(ctx context.Context, listener net.Listener) error {
|
||||
go func() {
|
||||
@@ -50,7 +59,7 @@ func (srv *Server) Serve(ctx context.Context, listener net.Listener) error {
|
||||
}
|
||||
return fmt.Errorf("smtp: verbindung annehmen: %w", err)
|
||||
}
|
||||
session := newSession(conn, srv.sink, srv.maxMessageBytes)
|
||||
session := newSession(conn, srv.sink, srv.maxMessageBytes, srv.tlsConfig)
|
||||
go session.Serve(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package smtp
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
@@ -25,18 +26,24 @@ type Session struct {
|
||||
|
||||
maxMessageBytes int64
|
||||
|
||||
tlsConfig *tls.Config // nil = kein STARTTLS angeboten (ING-06)
|
||||
tlsActive bool
|
||||
|
||||
state State
|
||||
from string
|
||||
to []string
|
||||
}
|
||||
|
||||
func newSession(conn net.Conn, sink MessageSink, maxMessageBytes int64) *Session {
|
||||
func newSession(conn net.Conn, sink MessageSink, maxMessageBytes int64, tlsConfig *tls.Config) *Session {
|
||||
_, alreadyTLS := conn.(*tls.Conn)
|
||||
return &Session{
|
||||
conn: conn,
|
||||
reader: bufio.NewReaderSize(conn, maxCommandLineBytes),
|
||||
writer: bufio.NewWriter(conn),
|
||||
sink: sink,
|
||||
maxMessageBytes: maxMessageBytes,
|
||||
tlsConfig: tlsConfig,
|
||||
tlsActive: alreadyTLS,
|
||||
state: Greeting,
|
||||
}
|
||||
}
|
||||
@@ -93,6 +100,8 @@ func (s *Session) dispatch(ctx context.Context, verb, arg string) bool {
|
||||
return s.handleData(ctx)
|
||||
case "RSET":
|
||||
return s.handleRset()
|
||||
case "STARTTLS":
|
||||
return s.handleStartTLS(ctx)
|
||||
case "NOOP":
|
||||
return s.reply(250, "OK") == nil
|
||||
case "QUIT":
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/tlscert"
|
||||
)
|
||||
|
||||
func startTLSTestServer(t *testing.T, sink MessageSink, tlsConfig *tls.Config) (addr string, stop func()) {
|
||||
t.Helper()
|
||||
srv := NewServerWithMaxMessageBytesAndTLSConfig(sink, defaultMaxMessageBytes, tlsConfig)
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listener: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_ = srv.Serve(ctx, listener)
|
||||
close(done)
|
||||
}()
|
||||
return listener.Addr().String(), func() {
|
||||
cancel()
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
// TestEHLO_AdvertisesStartTLSUntilActive belegt, dass STARTTLS
|
||||
// angeboten wird, solange keine TLS-Verbindung aktiv ist, und danach
|
||||
// nicht mehr (Akzeptanzkriterium 1: Grundlage, TLS wird angeboten).
|
||||
func TestEHLO_AdvertisesStartTLSUntilActive(t *testing.T) {
|
||||
cert, err := tlscert.GenerateSelfSigned("127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("testzertifikat erzeugen: %v", err)
|
||||
}
|
||||
store := tlscert.NewStore(cert)
|
||||
sink := &fakeSink{}
|
||||
addr, stop := startTLSTestServer(t, sink, store.Config())
|
||||
defer stop()
|
||||
|
||||
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
reader := bufio.NewReader(conn)
|
||||
_, _ = reader.ReadString('\n')
|
||||
|
||||
_, _ = conn.Write([]byte("EHLO client.example.com\r\n"))
|
||||
var sawStartTLS bool
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("antwort lesen: %v", err)
|
||||
}
|
||||
if strings.Contains(line, "STARTTLS") {
|
||||
sawStartTLS = true
|
||||
}
|
||||
if strings.HasPrefix(line, "250 ") {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !sawStartTLS {
|
||||
t.Fatalf("erwartete STARTTLS in EHLO-Antwort, wenn TLS konfiguriert ist")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartTLS_UpgradesConnection ist die geforderte Pflichtprüfung 2
|
||||
// (ING-06) für SMTP: ein echter STARTTLS-Handschlag etabliert eine
|
||||
// reale TLS-Verbindung. SMTP hat in dieser minimalen Implementierung
|
||||
// (ING-03) kein Anmeldekommando (kein AUTH) — die
|
||||
// Akzeptanzkriterium-1-Prüfung "Login ohne TLS wird verweigert" ist
|
||||
// daher für IMAP/POP3 (dort real vorhanden) durchgeführt, siehe deren
|
||||
// Pakete. Hier wird strukturell geprüft: STARTTLS baut eine ECHTE
|
||||
// TLS-Sitzung auf, danach wird kein STARTTLS mehr angeboten.
|
||||
func TestStartTLS_UpgradesConnection(t *testing.T) {
|
||||
cert, err := tlscert.GenerateSelfSigned("127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("testzertifikat erzeugen: %v", err)
|
||||
}
|
||||
store := tlscert.NewStore(cert)
|
||||
sink := &fakeSink{}
|
||||
addr, stop := startTLSTestServer(t, sink, store.Config())
|
||||
defer stop()
|
||||
|
||||
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
reader := bufio.NewReader(conn)
|
||||
_, _ = reader.ReadString('\n')
|
||||
_, _ = conn.Write([]byte("EHLO client.example.com\r\n"))
|
||||
for {
|
||||
line, _ := reader.ReadString('\n')
|
||||
if strings.HasPrefix(line, "250 ") {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = conn.Write([]byte("STARTTLS\r\n"))
|
||||
resp, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("antwort lesen: %v", err)
|
||||
}
|
||||
if code(resp) != "220" {
|
||||
t.Fatalf("erwartete 220 für STARTTLS, habe: %s", resp)
|
||||
}
|
||||
|
||||
tlsConn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true})
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
t.Fatalf("tls-handshake: %v", err)
|
||||
}
|
||||
if string(tlsConn.ConnectionState().PeerCertificates[0].Raw) != string(cert.Certificate[0]) {
|
||||
t.Fatalf("tls-verbindung hätte das konfigurierte zertifikat liefern sollen")
|
||||
}
|
||||
|
||||
// Kommandos funktionieren über die TLS-Verbindung normal weiter,
|
||||
// EHLO bietet STARTTLS nun nicht mehr an (bereits aktiv).
|
||||
tlsReader := bufio.NewReader(tlsConn)
|
||||
_, _ = tlsConn.Write([]byte("EHLO client.example.com\r\n"))
|
||||
var sawStartTLS bool
|
||||
for {
|
||||
line, err := tlsReader.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("antwort über tls lesen: %v", err)
|
||||
}
|
||||
if strings.Contains(line, "STARTTLS") {
|
||||
sawStartTLS = true
|
||||
}
|
||||
if strings.HasPrefix(line, "250 ") {
|
||||
break
|
||||
}
|
||||
}
|
||||
if sawStartTLS {
|
||||
t.Fatalf("STARTTLS sollte nach aktivem TLS nicht mehr angeboten werden")
|
||||
}
|
||||
}
|
||||
|
||||
// TestServer_RejectsLegacyTLSVersionAndWeakCiphers ist Teil der
|
||||
// geforderten Pflichtprüfung 1 (ING-06, Akzeptanzkriterium 2): ein
|
||||
// echter TLS-Client, der ausschließlich veraltete Protokollversionen
|
||||
// bzw. schwache (CBC-)Cipher-Suiten anbietet, bekommt vom Server KEINE
|
||||
// erfolgreiche Verbindung. Ergänzt die manuelle openssl-Prüfung aus dem
|
||||
// Prüfprotokoll um einen automatisierten, reproduzierbaren Nachweis.
|
||||
func TestServer_RejectsLegacyTLSVersionAndWeakCiphers(t *testing.T) {
|
||||
cert, err := tlscert.GenerateSelfSigned("127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("testzertifikat erzeugen: %v", err)
|
||||
}
|
||||
store := tlscert.NewStore(cert)
|
||||
sink := &fakeSink{}
|
||||
addr, stop := startTLSTestServer(t, sink, store.Config())
|
||||
defer stop()
|
||||
|
||||
t.Run("veraltete protokollversion (max TLS 1.1)", func(t *testing.T) {
|
||||
conn := dialAndStartTLS(t, addr)
|
||||
defer func() { _ = conn.Close() }()
|
||||
//nolint:staticcheck // veraltete Version ABSICHTLICH angefordert, um die Ablehnung zu prüfen
|
||||
client := tls.Client(conn, &tls.Config{InsecureSkipVerify: true, MaxVersion: tls.VersionTLS11})
|
||||
if err := client.Handshake(); err == nil {
|
||||
t.Fatalf("erwartete fehlgeschlagenen handshake mit max TLS 1.1")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nur schwache CBC-cipher-suite angeboten", func(t *testing.T) {
|
||||
conn := dialAndStartTLS(t, addr)
|
||||
defer func() { _ = conn.Close() }()
|
||||
client := tls.Client(conn, &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
MaxVersion: tls.VersionTLS12,
|
||||
CipherSuites: []uint16{tls.TLS_RSA_WITH_AES_128_CBC_SHA},
|
||||
})
|
||||
if err := client.Handshake(); err == nil {
|
||||
t.Fatalf("erwartete fehlgeschlagenen handshake mit ausschließlich schwacher cipher-suite")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func dialAndStartTLS(t *testing.T, addr string) net.Conn {
|
||||
t.Helper()
|
||||
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
reader := bufio.NewReader(conn)
|
||||
_, _ = reader.ReadString('\n')
|
||||
_, _ = conn.Write([]byte("EHLO client.example.com\r\n"))
|
||||
for {
|
||||
line, _ := reader.ReadString('\n')
|
||||
if strings.HasPrefix(line, "250 ") {
|
||||
break
|
||||
}
|
||||
}
|
||||
_, _ = conn.Write([]byte("STARTTLS\r\n"))
|
||||
_, _ = reader.ReadString('\n')
|
||||
return conn
|
||||
}
|
||||
|
||||
// TestTLS_CertificateRotationWithoutDroppingExistingSessions ist die
|
||||
// geforderte Pflichtprüfung 3 (ING-06) für SMTP.
|
||||
func TestTLS_CertificateRotationWithoutDroppingExistingSessions(t *testing.T) {
|
||||
certA, err := tlscert.GenerateSelfSigned("127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("zertifikat a erzeugen: %v", err)
|
||||
}
|
||||
certB, err := tlscert.GenerateSelfSigned("127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("zertifikat b erzeugen: %v", err)
|
||||
}
|
||||
store := tlscert.NewStore(certA)
|
||||
sink := &fakeSink{}
|
||||
addr, stop := startTLSTestServer(t, sink, store.Config())
|
||||
defer stop()
|
||||
|
||||
firstConn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("dial 1: %v", err)
|
||||
}
|
||||
defer func() { _ = firstConn.Close() }()
|
||||
r1 := bufio.NewReader(firstConn)
|
||||
_, _ = r1.ReadString('\n')
|
||||
_, _ = firstConn.Write([]byte("EHLO client.example.com\r\n"))
|
||||
for {
|
||||
line, _ := r1.ReadString('\n')
|
||||
if strings.HasPrefix(line, "250 ") {
|
||||
break
|
||||
}
|
||||
}
|
||||
_, _ = firstConn.Write([]byte("STARTTLS\r\n"))
|
||||
_, _ = r1.ReadString('\n')
|
||||
firstTLS := tls.Client(firstConn, &tls.Config{InsecureSkipVerify: true})
|
||||
if err := firstTLS.Handshake(); err != nil {
|
||||
t.Fatalf("tls-handshake 1: %v", err)
|
||||
}
|
||||
if string(firstTLS.ConnectionState().PeerCertificates[0].Raw) != string(certA.Certificate[0]) {
|
||||
t.Fatalf("erste verbindung hätte zertifikat a bekommen sollen")
|
||||
}
|
||||
|
||||
store.Replace(certB)
|
||||
|
||||
secondConn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("dial 2: %v", err)
|
||||
}
|
||||
defer func() { _ = secondConn.Close() }()
|
||||
r2 := bufio.NewReader(secondConn)
|
||||
_, _ = r2.ReadString('\n')
|
||||
_, _ = secondConn.Write([]byte("EHLO client.example.com\r\n"))
|
||||
for {
|
||||
line, _ := r2.ReadString('\n')
|
||||
if strings.HasPrefix(line, "250 ") {
|
||||
break
|
||||
}
|
||||
}
|
||||
_, _ = secondConn.Write([]byte("STARTTLS\r\n"))
|
||||
_, _ = r2.ReadString('\n')
|
||||
secondTLS := tls.Client(secondConn, &tls.Config{InsecureSkipVerify: true})
|
||||
if err := secondTLS.Handshake(); err != nil {
|
||||
t.Fatalf("tls-handshake 2: %v", err)
|
||||
}
|
||||
if string(secondTLS.ConnectionState().PeerCertificates[0].Raw) != string(certB.Certificate[0]) {
|
||||
t.Fatalf("zweite verbindung hätte NACH der rotation zertifikat b bekommen sollen")
|
||||
}
|
||||
|
||||
// Erste, bereits etablierte TLS-Verbindung bleibt ungestört nutzbar.
|
||||
tlsReader1 := bufio.NewReader(firstTLS)
|
||||
if _, err := firstTLS.Write([]byte("NOOP\r\n")); err != nil {
|
||||
t.Fatalf("NOOP über erste verbindung nach rotation: %v", err)
|
||||
}
|
||||
resp, err := tlsReader1.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("erste verbindung nach rotation unterbrochen: %v", err)
|
||||
}
|
||||
if code(resp) != "250" {
|
||||
t.Fatalf("erste verbindung nach rotation nicht mehr funktionsfähig: %q", resp)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user