Files
QSfera/Server/services/antivirus/pkg/service/service.go
T
Курнат Андрей 2315f25754 Initial QSfera import
2026-06-07 10:20:04 +03:00

349 lines
10 KiB
Go

package service
import (
"bytes"
"context"
"crypto/x509"
"errors"
"fmt"
"io"
"net/http"
"os"
"slices"
"sync"
"sync/atomic"
"time"
"github.com/opencloud-eu/reva/v2/pkg/bytesize"
ctxpkg "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/events/stream"
"github.com/opencloud-eu/reva/v2/pkg/rhttp"
"go.opentelemetry.io/otel/trace"
"github.com/qsfera/server/pkg/generators"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/antivirus/pkg/config"
"github.com/qsfera/server/services/antivirus/pkg/scanners"
)
var (
// ErrFatal is returned when a fatal error occurs, and we want to exit.
ErrFatal = errors.New("fatal error")
// ErrEvent is returned when something went wrong with a specific event.
ErrEvent = errors.New("event error")
)
// Scanner is an abstraction for the actual virus scan
type Scanner interface {
Scan(body scanners.Input) (scanners.Result, error)
}
// NewAntivirus returns a service implementation for Service.
func NewAntivirus(cfg *config.Config, logger log.Logger, tracerProvider trace.TracerProvider) (Antivirus, error) {
var scanner Scanner
var err error
switch cfg.Scanner.Type {
default:
return Antivirus{}, fmt.Errorf("unknown av scanner: '%s'", cfg.Scanner.Type)
case config.ScannerTypeClamAV:
scanner, err = scanners.NewClamAV(cfg.Scanner.ClamAV.Socket, cfg.Scanner.ClamAV.Timeout)
case config.ScannerTypeICap:
scanner, err = scanners.NewICAP(cfg.Scanner.ICAP.URL, cfg.Scanner.ICAP.Service, cfg.Scanner.ICAP.Timeout)
}
if err != nil {
return Antivirus{}, err
}
av := Antivirus{
config: cfg,
log: logger,
tracerProvider: tracerProvider,
scanner: scanner,
client: rhttp.GetHTTPClient(rhttp.Insecure(true)),
stopCh: make(chan struct{}, 1),
stopped: new(atomic.Bool),
}
switch mode := cfg.MaxScanSizeMode; mode {
case config.MaxScanSizeModeSkip, config.MaxScanSizeModePartial:
break
default:
return av, fmt.Errorf("unknown max scan size mode '%s'", cfg.MaxScanSizeMode)
}
switch outcome := events.PostprocessingOutcome(cfg.InfectedFileHandling); outcome {
case events.PPOutcomeContinue, events.PPOutcomeAbort, events.PPOutcomeDelete:
av.outcome = outcome
default:
return av, fmt.Errorf("unknown infected file handling '%s'", outcome)
}
if cfg.MaxScanSize != "" {
b, err := bytesize.Parse(cfg.MaxScanSize)
if err != nil {
return av, err
}
av.maxScanSize = b.Bytes()
}
return av, nil
}
// Antivirus defines implements the business logic for Service.
type Antivirus struct {
config *config.Config
log log.Logger
scanner Scanner
outcome events.PostprocessingOutcome
maxScanSize uint64
tracerProvider trace.TracerProvider
client *http.Client
stopCh chan struct{}
stopped *atomic.Bool
}
// Run runs the service
func (av Antivirus) Run() error {
eventsCfg := av.config.Events
var rootCAPool *x509.CertPool
if av.config.Events.TLSRootCACertificate != "" {
rootCrtFile, err := os.Open(eventsCfg.TLSRootCACertificate)
if err != nil {
return err
}
var certBytes bytes.Buffer
if _, err := io.Copy(&certBytes, rootCrtFile); err != nil {
return err
}
rootCAPool = x509.NewCertPool()
rootCAPool.AppendCertsFromPEM(certBytes.Bytes())
av.config.Events.TLSInsecure = false
}
connName := generators.GenerateConnectionName(av.config.Service.Name, generators.NTypeBus)
natsStream, err := stream.NatsFromConfig(connName, false, stream.NatsConfig(av.config.Events))
if err != nil {
return err
}
ch, err := events.Consume(natsStream, "antivirus", events.StartPostprocessingStep{})
if err != nil {
return err
}
wg := sync.WaitGroup{}
for range av.config.Workers {
wg.Go(func() {
EventLoop:
for {
select {
case e, ok := <-ch:
if !ok {
break EventLoop
}
err := av.processEvent(e, natsStream)
if err != nil {
switch {
case errors.Is(err, ErrFatal):
av.log.Fatal().Err(err).Msg("fatal error - exiting")
case errors.Is(err, ErrEvent):
av.log.Error().Err(err).Msg("continuing")
default:
av.log.Fatal().Err(err).Msg("unknown error - exiting")
}
}
if av.stopped.Load() {
break EventLoop
}
case <-av.stopCh:
break EventLoop
}
}
})
}
wg.Wait()
return nil
}
func (av Antivirus) Close() {
if av.stopped.CompareAndSwap(false, true) {
close(av.stopCh)
}
}
func (av Antivirus) processEvent(e events.Event, s events.Publisher) error {
ctx, span := av.tracerProvider.Tracer("antivirus").Start(e.GetTraceContext(context.Background()), "processEvent")
defer span.End()
av.log.Info().Str("traceID", span.SpanContext().TraceID().String()).Msg("TraceID")
ev := e.Event.(events.StartPostprocessingStep)
if ev.StepToStart != events.PPStepAntivirus {
return nil
}
if av.config.DebugScanOutcome != "" {
av.log.Warn().Str("antivir, clamav", ">>>>>>> ANTIVIRUS_DEBUG_SCAN_OUTCOME IS SET NO ACTUAL VIRUS SCAN IS PERFORMED!").Send()
if err := events.Publish(ctx, s, events.PostprocessingStepFinished{
FinishedStep: events.PPStepAntivirus,
Outcome: events.PostprocessingOutcome(av.config.DebugScanOutcome),
UploadID: ev.UploadID,
ExecutingUser: ev.ExecutingUser,
Filename: ev.Filename,
Result: events.VirusscanResult{
Infected: true,
Description: "DEBUG: forced outcome",
Scandate: time.Now(),
ResourceID: ev.ResourceID,
},
}); err != nil {
av.log.Fatal().Err(err).Str("uploadid", ev.UploadID).Interface("resourceID", ev.ResourceID).Msg("cannot publish events - exiting")
return fmt.Errorf("%w: cannot publish events", ErrFatal)
}
return fmt.Errorf("%w: no actual virus scan performed", ErrEvent)
}
av.log.Debug().Str("uploadid", ev.UploadID).Str("filename", ev.Filename).Msg("Starting virus scan.")
var errmsg string
start := time.Now()
res, err := av.process(ev)
if err != nil {
errmsg = err.Error()
}
duration := time.Since(start)
var outcome events.PostprocessingOutcome
switch {
case res.Infected:
outcome = av.outcome
case !res.Infected && err == nil:
outcome = events.PPOutcomeContinue
case err != nil:
outcome = events.PPOutcomeRetry
default:
// Not sure what this is about. Abort.
outcome = events.PPOutcomeAbort
}
av.log.Info().Str("uploadid", ev.UploadID).Interface("resourceID", ev.ResourceID).Str("virus", res.Description).Str("outcome", string(outcome)).Str("filename", ev.Filename).Str("user", ev.ExecutingUser.GetId().GetOpaqueId()).Bool("infected", res.Infected).Dur("duration", duration).Msg("File scanned")
if err := events.Publish(ctx, s, events.PostprocessingStepFinished{
FinishedStep: events.PPStepAntivirus,
Outcome: outcome,
UploadID: ev.UploadID,
ExecutingUser: ev.ExecutingUser,
Filename: ev.Filename,
Result: events.VirusscanResult{
Infected: res.Infected,
Description: res.Description,
Scandate: time.Now(),
ResourceID: ev.ResourceID,
ErrorMsg: errmsg,
},
}); err != nil {
av.log.Fatal().Err(err).Str("uploadid", ev.UploadID).Interface("resourceID", ev.ResourceID).Msg("cannot publish events - exiting")
return fmt.Errorf("%w: %s", ErrFatal, err)
}
return nil
}
// process the scan
func (av Antivirus) process(ev events.StartPostprocessingStep) (scanners.Result, error) {
if ev.Filesize == 0 {
av.log.Info().Str("uploadid", ev.UploadID).Msg("Skipping file to be virus scanned, file size is 0.")
return scanners.Result{ScanTime: time.Now()}, nil
}
filesize := ev.Filesize
headers := make(map[string]string)
switch {
case av.maxScanSize == 0:
// there is no size limit
break
case av.config.MaxScanSizeMode == config.MaxScanSizeModeSkip && filesize > av.maxScanSize:
// skip the file if it is bigger than the max scan size
av.log.Info().Str("uploadid", ev.UploadID).Uint64("filesize", filesize).
Msg("Skipping file to be virus scanned, file size is bigger than max scan size.")
return scanners.Result{ScanTime: time.Now()}, nil
case av.config.MaxScanSizeMode == config.MaxScanSizeModePartial && filesize > av.maxScanSize:
// set the range header to only download the first maxScanSize bytes
headers["Range"] = fmt.Sprintf("bytes=0-%d", av.maxScanSize-1)
filesize = av.maxScanSize // inform the scanner that we are only scanning part of the file
}
var err error
var rrc io.ReadCloser
switch ev.UploadID {
default:
rrc, err = av.downloadViaToken(ev.URL, headers)
case "":
rrc, err = av.downloadViaReva(ev.URL, ev.Token, ev.RevaToken, headers)
}
if err != nil {
av.log.Error().Err(err).Str("uploadid", ev.UploadID).Msg("error downloading file")
return scanners.Result{}, err
}
defer func() {
_ = rrc.Close()
}()
av.log.Debug().Str("uploadid", ev.UploadID).Msg("Downloaded file successfully, starting virusscan")
res, err := av.scanner.Scan(scanners.Input{Body: rrc, Size: int64(filesize), Url: ev.URL, Name: ev.Filename})
if err != nil {
av.log.Error().Err(err).Str("uploadid", ev.UploadID).Msg("error scanning file")
}
return res, err
}
// download will download the file
func (av Antivirus) downloadViaToken(url string, headers map[string]string) (io.ReadCloser, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
return av.doDownload(req, headers)
}
// download will download the file
func (av Antivirus) downloadViaReva(url string, dltoken string, revatoken string, headers map[string]string) (io.ReadCloser, error) {
req, err := rhttp.NewRequest(ctxpkg.ContextSetToken(context.Background(), revatoken), http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Reva-Transfer", dltoken)
return av.doDownload(req, headers)
}
func (av Antivirus) doDownload(req *http.Request, headers map[string]string) (io.ReadCloser, error) {
for k, v := range headers {
req.Header.Add(k, v)
}
res, err := av.client.Do(req)
if err != nil {
return nil, err
}
if !slices.Contains([]int{http.StatusOK, http.StatusPartialContent}, res.StatusCode) {
_ = res.Body.Close()
return nil, fmt.Errorf("unexpected status code from Download %v", res.StatusCode)
}
return res.Body, nil
}