Langage inspiré de SQL pour filtrer les flux

Vincent Bernat

Akvorado collecte les flux réseau à l’aide d’IPFIX ou de sFlow. Il les stocke dans une base de données ClickHouse. Une console web permet à l’utilisateur de faire des requêtes sur les données pour obtenir des graphiques. Un aspect intéressant de cette console est la possibilité de filtrer les flux avec un langage inspiré de SQL :

Filter editor in Akvorado console

Souvent, les interfaces web exposent un constructeur de requêtes pour concevoir de tels filtres. À la place, j’ai choisi de combiner un langage similaire à SQL avec un éditeur prenant en charge la complétion, la coloration syntaxique et la vérification syntaxique1.

L’analyseur syntaxique du langage est construit avec pigeon (Go) à partir d’une grammaire d’expression d’analyse syntaxique (parsing expression grammar ou PEG). Le composant à la base de l’éditeur est CodeMirror (TypeScript).

Analyseur syntaxique#

Les grammaires PEG sont relativement récentes2 et sont une alternative aux grammaires contextuelles. Elles sont plus faciles à écrire et peuvent générer de meilleurs messages d’erreur. Par exemple, Python a basculé d’un analyseur de type LL(1) à un analyseur basé sur une grammaire PEG depuis Python 3.9.

pigeon génère un analyseur pour Go. Une grammaire est un ensemble de règles. Chaque règle est un identifiant, avec optionnellement une étiquette utilisée pour les messages d’erreur, une expression et une action en Go à exécuter. Vous pouvez trouver la grammaire complète dans parser.peg. Voici une règle simplifiée :

ConditionIPExpr "condition on IP" 
  column:("ExporterAddress"i { return "ExporterAddress", nil }
        / "SrcAddr"i { return "SrcAddr", nil }
        / "DstAddr"i { return "DstAddr", nil }) _ 
  operator:("=" / "!=") _ 
  ip:IP {
    return fmt.Sprintf("%s %s IPv6StringToNum(%s)",
      toString(column), toString(operator), quote(ip)), nil
  }

L’identifiant de la règle est ConditionIPExpr. Elle attend soit ExporterAddress, soit SrcAddr, soit DstAddr, sans distinction de la case. L’action pour chaque cas renvoie le nom de la colonne correspondante. C’est ce qui est stocké dans la variable column. Ensuite, elle attend un des deux opérateurs possibles. Comme il n’y a pas de bloc de code, l’opérateur est stocké dans la variable operator. Ensuite, elle attend une chaîne validée par la règle IP qui est définie ailleurs dans la grammaire. Si c’est le cas, elle stocke le résultat dans la variable ip et exécute l’action finale. L’action transforme la colonne, l’opérateur et l’adresse IP en une expression SQL pour ClickHouse. Par exemple, si nous avons ExporterAddress = 203.0.113.15, nous obtenons ExporterAddress = IPv6StringToNum('203.0.113.15').

La règle IP utilise une expression régulière rudimentaire mais vérifie si l’adresse correspondante est correcte dans le bloc d’action, grâce à netip.ParseAddr():

IP "IP address"  [0-9A-Fa-f:.]+ {
  ip, err := netip.ParseAddr(string(c.text))
  if err != nil {
    return "", errors.New("expecting an IP address")
  }
  return ip.String(), nil
}

Cet analyseur transforme de manière sécurisée un filtre en une clause WHERE acceptée par ClickHouse3 :

WHERE InIfBoundary = 'external' 
AND ExporterRegion = 'france' 
AND InIfConnectivity = 'transit' 
AND SrcAS = 15169 
AND DstAddr BETWEEN toIPv6('2a01:e0f:ffff::') 
                AND toIPv6('2a01:e0f:ffff:ffff:ffff:ffff:ffff:ffff')

Intégration dans CodeMirror#

CodeMirror est un éditeur de code polyvalent qui peut être facilement intégré dans les projets JavaScript. Dans Akvorado, le composant Vue.js InputFilter utilise CodeMirror et tire parti de fonctionnalités telles que la coloration syntaxique, la vérification syntaxique et la complétion. Le code source de ces fonctionnalités se trouve dans le répertoire codemirror/lang-filter/.

Coloration syntaxique#

La grammaire PEG pour Go ne peut pas être utilisé directement4 et les exigences pour les analyseurs syntaxiques utilisés dans les éditeurs sont différentes : ils doivent être tolérants aux erreurs et fonctionner de manière incrémentielle, car le code est généralement mis à jour caractère par caractère. CodeMirror propose une solution via son propre générateur d’analyseur, Lezer.

Nous n’avons pas besoin que cet analyseur supplémentaire comprenne pleinement le langage des filtres. Seule la structure est nécessaire : les noms de colonnes, les opérateurs de comparaison et de logique, les valeurs entre guillemets ou non. La grammaire est donc assez courte et n’a pas besoin d’être mise à jour souvent :

@top Filter {
  expression
}

expression {
 Not expression |
 "(" expression ")" |
 "(" expression ")" And expression |
 "(" expression ")" Or expression |
 comparisonExpression And expression |
 comparisonExpression Or expression |
 comparisonExpression
}
comparisonExpression {
 Column Operator Value
}

Value {
  String | Literal | ValueLParen ListOfValues ValueRParen
}
ListOfValues {
  ListOfValues ValueComma (String | Literal) |
  String | Literal
}

// […]
@tokens {
  // […]
  Column { std.asciiLetter (std.asciiLetter|std.digit)* }
  Operator { $[a-zA-Z!=><]+ }

  String {
    '"' (![\\\n"] | "\\" _)* '"'? |
    "'" (![\\\n'] | "\\" _)* "'"?
  }
  Literal { (std.digit | std.asciiLetter | $[.:/])+ }
  // […]
}

L’expression SrcAS = 12322 AND (DstAS = 1299 OR SrcAS = 29447) est analysée ainsi :

Filter(Column, Operator, Value(Literal),
  And, Column, Operator, Value(Literal),
  Or, Column, Operator, Value(Literal))

La dernière étape est d’indiquer à CodeMirror la correspondance entre chaque symbole et sa catégorie pour la coloration syntaxique :

export const FilterLanguage = LRLanguage.define({
  parser: parser.configure({
    props: [
      styleTags({
        Column: t.propertyName,
        String: t.string,
        Literal: t.literal,
        LineComment: t.lineComment,
        BlockComment: t.blockComment,
        Or: t.logicOperator,
        And: t.logicOperator,
        Not: t.logicOperator,
        Operator: t.compareOperator,
        "( )": t.paren,
      }),
    ],
  }),
});

Vérification syntaxique#

La vérification syntaxique est déléguée à l’analyseur syntaxique en Go. Le point d’accès /api/v0/console/filter/validate accepte un filtre et retourne une structure JSON avec les éventuelles erreurs:

{
  "message": "at line 1, position 12: string literal not terminated",
  "errors": [{
    "line":    1,
    "column":  12,
    "offset":  11,
    "message": "string literal not terminated",
  }]
}

Le greffon pour CodeMirror interroge cette API et transforme chaque erreur en un diagnostic.

Complétion#

Le système de complétion adopte une approche hybride. Il répartit le travail entre le frontend et le backend pour offrir ses suggestions.

Le frontend utilise l’analyseur construit avec Lezer pour déterminer le contexte de la complétion : s’agit-il compléter un nom de colonne, un opérateur ou une valeur ? Il extrait également le nom de la colonne si nous complétons autre chose. Il transfère le résultat au backend via le point de terminaison /api/v0/console/filter/complete. Parcourir l’arbre syntaxique n’a pas été aussi facile que je le pensais, mais les tests unitaires ont beaucoup aidé.

Le backend utilise l’analyseur généré par pigeon pour compléter les noms de colonnes et les opérateurs de comparaison. Pour les valeurs, les complétions sont statiques out extraites de la base de données ClickHouse. Un utilisateur peut compléter un numéro AS à partir d’un nom d’organisation grâce au code suivant :

results := []struct {
  Label  string `ch:"label"`
  Detail string `ch:"detail"`
}{}
columnName := "DstAS"
sqlQuery := fmt.Sprintf(`
 SELECT concat('AS', toString(%s)) AS label, dictGet('asns', 'name', %s) AS detail
 FROM flows
 WHERE TimeReceived > date_sub(minute, 1, now())
 AND detail != ''
 AND positionCaseInsensitive(detail, $1) >= 1
 GROUP BY label, detail
 ORDER BY COUNT(*) DESC
 LIMIT 20
`, columnName, columnName)
if err := conn.Select(ctx, &results, sqlQuery, input.Prefix); err != nil {
  c.r.Err(err).Msg("unable to query database")
  break
}
for _, result := range results {
  completions = append(completions, filterCompletion{
    Label:  result.Label,
    Detail: result.Detail,
    Quoted: false,
  })
}

À mon avis, ce système de complétion est un élément important qui fait de l’éditeur un moyen efficace de sélectionner des flux. Alors qu’un constructeur de requêtes aurait pu être plus convivial pour les débutants, la facilité d’utilisation et les fonctionnalités du système de complétion le rendent plus agréable à utiliser une fois que l’utilisateur familiarisé.


  1. De plus, créer un constructeur de requêtes me semblait une tâche assez rébarbative. ↩︎

  2. Elles ont été introduites en 2004 dans « Parsing Expression Grammars: A Recognition-Based Syntactic Foundation ». Les analyseurs LR ont été introduits en 1965, les analyseurs LALR en 1969 et les analyseurs LL dans les années 1970. Yacc, un générateur d’analyseurs populaire, a été écrit en 1975. ↩︎

  3. L’analyseur retourne une chaîne. Il ne génère pas un arbre syntaxique intermédiaire. Cela le rend plus simple et suffit à nos besoins. ↩︎

  4. Elle pourrait être manuellement traduite en JavaScript avec Peggy↩︎