#!/usr/bin/env python

from __future__ import print_function

#
# Rev: 2026071001 - II: fix SSL error when running with Python 3.14
# Rev: 2026070801 - II: fix O(n^2) response reading in __get()
# Rev: 2026032401 - II: fix for FOREACH command (support empty responses, like in FOREACH GROUP)
# Rev: 2026011301 - II: add support for retrying the command for specific transient error cases
# Rev: 2026011203 - II: use a more efficient way to execute FOREACH DOMAIN
# Rev: 2026011202 - II: fix for processing LIST STORAGE output
# Rev: 2026011201 - II: add support for pipe character (to be encoded as %7C%)
# Rev: 2024100901 - II: change regex string to raw
# Rev: 2024100201 - II: enable logging to stdout only after successful authentication
# Rev: 2024092601 - II: add support for updating WM and WA filters
# Rev: 2024092501 - II: add support for reading from stdin (use - for cmds)
# Rev: 2024030701 - II: add support for removing items from FOREACH command (via REMOVE-ITEM)
# Rev: 2021112501 - II: add support for changing FOREACH default action (back)
# Rev: 2020061801 - II: add support for python 3
# Rev: 2020041601 - II: add support for environment variable CLIPASS
# Rev: 2020022503 - II: add support for SHOW and SHOW ATTR
# Rev: 2020022502 - II: add information about FOREACH object name (using 'info')
# Rev: 2020022501 - II: fix for FOREACH command (add a back command)
# Rev: 2019052903 - II: add support FOREACH virtual command
# Rev: 2019052902 - II: change CLIRESPONSE values (1=True=default|0=False|2=minimal)
# Rev: 2019052901 - II: add support for SSL
# Rev: 2019052701 - II: do not depend on cli2.py anymore
# Rev: 2018103103 - II: change the output when CLI command fails
# Rev: 2018103102 - II: add support for environment variable CLIRESPONSE (True=default|False)
# Rev: 2018103101 - II: add support for environment variables CLIHOST and CLIPORT
# Rev: 2017090901 - II: initial release
#

"""
Run CLI commands separated by pipe
"""
"""
Copyright (c) since 2006, Axigen Messaging. All rights reserved.
For feedback and/or bugs in this script, please send an e-mail to:
  "AXIGEN Team" <team@axigen.com>
"""

### defaults ###
CLIHOST='127.0.0.1'
CLIPORT=7000
CLIUSER='admin'
CLIPASS='admin-password'
CLICONN='RAW'
################


############################# from cli2.py #############################
import socket, sys
from sys import stdout as STDOUT
from sys import stdin  as STDIN
import re
import os
import ssl

CRLF='\r\n'

def getEntries(sText):
  sArray = sText.split(CRLF)
  if len(sArray) >= 5:
    if sArray[1]:
      if sArray[1][-1] == ":":
        if sArray[4]:
          if sArray[4][0] == "-":
            # Normal list output (like LIST ACCOUNTS)
            return sArray[5:-3]
        # Output like LIST ALIASES
        return sArray[2:-3]
    else:
      # Output like LIST STORAGES
      return sArray[2:-3]
  if sArray[0]:
    if sArray[0][0] == " ":
      # No header (like LIST USERMAP CACHE)
      return sArray[0:-2]
    else:
      # Remove header (like LIST CLUSTERADDRESSES)
      return sArray[1:-2]

class CLIBase:
  """
  AXIGEN CLI base class
  This class defines methods for (almost) all AXIGEN CLI's commands.
  """
  debug=0
  context=None
  contexts=[]
  prompt=None
  V={
    'version': None,
    'fullversion': None,
    'platform': None,
    'os': None,
    'datadir': None
  }
  __c=re.compile(r'((((\+OK)|(-ERR)):?.*)|(Backing up done))\r\n\<([A-Za-z0-9\-]*#?)\> ')
  __noauth=True

  def __init__(self, host='localhost', port=7000, user=None, passwd=None, conn='raw'):
    """
    Initialize a new CLI class instance.

    The class constructor creates a socket based on the 'host' and 'port'
    parameters and, optionally, authenticates as the 'admin' user with a
    specified password by calling the auth() method
    """
    self.host=host
    self.port=port
    self.sockBase=None
    self.sock=None
    self.conn=conn
    self.user=user
    self.passwd=passwd
    self.esetStart=False
    try:
      self.sockBase=socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    except socket.error:
      if self.sockBase:
        self.sockBase=None
      self.sockBase=None
    if not self.sockBase:
      raise socket.error("Connect error")

    if self.conn.upper() == 'RAW':
      self.sock = self.sockBase
    elif self.conn.upper() == 'SSL':
      if hasattr(ssl, 'wrap_socket'):
        # Legacy path: still available on some older interpreters (removed in Python 3.12+)
        self.sock = ssl.wrap_socket(self.sockBase)
      else:
        # Modern path: ssl.wrap_socket() removed in Python 3.12; SSLContext used instead
        if hasattr(ssl, 'PROTOCOL_TLS_CLIENT'):
          proto = ssl.PROTOCOL_TLS_CLIENT
        elif hasattr(ssl, 'PROTOCOL_TLS'):
          proto = ssl.PROTOCOL_TLS
        else:
          proto = ssl.PROTOCOL_SSLv23
        ctx = ssl.SSLContext(proto)
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
        self.sock = ctx.wrap_socket(self.sockBase)
    else:
      raise Exception("Connection '"+self.conn+"' type not supported.")

    self.sock.connect((host, port))
    self.__get()
    if user!=None and passwd!=None:
      self.auth(passwd,user)

  def __del__(self):
    """
    Class instance destructor.
    Closes the connection, if opened
    """
    if self.sock:
      self.sock.close()
    if self.sockBase:
      self.sockBase.close()

  def __log(self, s):
    """
    Private method for logging the output of the socket communication to stdout
    """
    if self.debug == 1:
        if not self.__noauth:
          STDOUT.write(s.replace(CRLF,"\n"))
    else:
      STDOUT.write('.')
    STDOUT.flush()


  def __get(self):
    """
    Private method used by the response() method

    Perf note (see issue 9): the previous implementation rebuilt
    'respl' via string concatenation and re-split the ENTIRE accumulated
    buffer on every recv() iteration, purely to check whether the last
    line looked like a CLI prompt. For large responses (e.g. LIST USERMAP
    CACHE with hundreds of thousands of lines), both operations scale with
    the size of the response received so far, making the total cost of the
    loop O(n^2) in the number of recv() chunks / response size. This showed
    up as multi-hour runtimes on large usermap caches.
 
    Fix: accumulate raw chunks in a list (O(1) append) and only join them
    once, after the loop exits. The prompt-detection check during the loop
    now looks only at a small bounded rolling "tail window" of recent
    bytes, rather than re-splitting the whole buffer -- this keeps each
    iteration O(1) regardless of total response size so far, so the whole
    loop is O(n) overall instead of O(n^2).
    """
    chunks=[]
    tail=''
    # Prompts are short (a handful of characters); keep more than enough
    # trailing context to survive a prompt split across two recv() calls,
    # while staying a small, constant size regardless of response length.
    TAIL_WINDOW=256

    while True:
      resp=self.sock.recv(65536)
      if sys.version_info[0] > 2:
        resp=resp.decode('utf-8')
      if self.debug >= 1:
        self.__log(resp)
      chunks.append(resp)
      tail=(tail+resp)[-TAIL_WINDOW:]
      if len(resp)>0:
        if self.__noauth:
          tl=tail.split(CRLF)
          if tl[-1][-2:]=='> ' and tl[-1][0]=='<':
            break
        else:
          if self.__c.search(tail):
            break
      else:
        break
    respl=''.join(chunks)
    l=respl.split(CRLF)
    self.prompt=l[len(l)-1]
    self.context=l[len(l)-1][1:-2]
    if len(self.context)>0:
      if self.context[len(self.context)-1]=='#':
        self.context=self.context[:-1]
      if self.context=='':
        self.contexts=[]
      else:
        self.contexts=self.context.split('-')
    return respl

  def __put(self, cmd, localdebug=None):
    """
    Private method used by the cmd() method.
    """
    cmdcrlf = cmd+CRLF
    if sys.version_info[0] < 3:
      self.sock.send(cmdcrlf)
    else:
      self.sock.send(str.encode(cmdcrlf))
    if localdebug!=None:
      if localdebug==1:
        self.__log(cmd+CRLF)
      return
    if self.debug==1:
      self.__log(cmd+CRLF)

  def cmd(self, s):
    """
    Sends a command to the CLI server.

    NOTE: The CRLF pair is added automatically to the command.
    """
    self.__put(s)

  def response(self):
    """
    Reads responses from the socket.
    If the debug=1, it outputs the response to the stdout.
    Also, it sets the current context (context and contexts variables).
    Returns a string containing the socket response.
    """
    return self.__get()

  def parse_resp(self, response):
    """
    Parses a response text (usually returned by the cmdr() or response() methods) and returns the tuple:
    code - integer with the value of 0 (successful +OK response) or 1 (error +ERR response)
    text - the exact response in addition to the +ERR or +OK text (server error reason string)
    response - contains the response, exactly the one given as argument
    """
    if not response:
      return (0, '', response)
    llines=response.split(CRLF)
    lastline=llines[len(llines)-2]
    firstspace=lastline.find(' ')
    if lastline[0]=='+':
      text=lastline[max(0,firstspace):].rstrip()
      code=1
    elif lastline[0]=='-':
      text=lastline[max(0,firstspace):].rstrip()
      code=0
    else:
      text=lastline.rstrip()
      code=0
    return (code, text, response)

  def auth(self, passwd, user='admin'):
    """
    Authenticates the CLI session. If either 'user' or 'passwd' are incorrect,
    it raises an exception
    """
    (code, text, _r)=self.parse_resp(self.cmdr('USER '+user))
    (code, text, _r)=self.parse_resp(self.cmdr(passwd))
    if not code:
      raise Exception('Authentication failed. Server response:' + text)
      return False
    self.__noauth=False
    return True

  def cmdr(self, s):
    """
    Wrapper to send a command and read its response in one call.
    Includes a retry mechanism for specific transient server errors.
    """
    attempts = 0
    max_retries = 3

    while True:
      self.cmd(s)
      resp = self.response()
      (code, text, _r) = self.parse_resp(resp)

      # code == 0 means -ERR was detected
      if code == 0 and "there were some errors during command execution" in text.lower():
        attempts += 1
        if attempts <= max_retries:
          # This only runs during an actual failure scenario
          self.__log("!!! Transient error detected. Retry %d/%d for: %s%s" % (attempts, max_retries, s, CRLF))
          time.sleep(1)
          continue

      return resp

  def runCli(self, s, info=''):
    my_i = -1
    my_sep = '|'
    my_cmds = s.split(my_sep)
    my_loop = True
    for my_s in my_cmds:
      my_i=my_i+1
      s = my_s.strip().replace('%7C%', '|')
      if not self.esetStart:
        tstart = datetime.datetime.now()
      if ( s.upper().startswith('ESET ')  or
           s.upper() == 'UPDATE WMFILTER' or
           s.upper() == 'UPDATE WAFILTER' ) and not self.esetStart:
        self.sock.send(str.encode(s + CRLF))
        my_eset = s
        while True:
          resp = self.sock.recv(4096)
          break
        self.esetStart = True
      elif self.esetStart:
        if my_s:
          self.sock.send(str.encode(my_s + CRLF))
        else:
          self.sock.send(str.encode(CRLF + '.' + CRLF))
          while True:
            resp = self.sock.recv(4096)
            break
          self.esetStart = False
          s = my_eset
      else:
        listEntries=None
        showEntries=None
        isList=False
        if s.upper().startswith('FOREACH '):
          # my_loop = False
          obj = s.split()[1].upper()
          action = s.split()[-1].upper()
          if (action == obj):
            action = ''
          if obj.upper() == 'DOMAIN':
            # Switch to a more eficient way to obtain the domain list
            (code, text, resp)=self.parse_resp(self.cmdr('LIST ALLDOMAINS'))
            # Remove disabled domains
            resp = CRLF.join(line for line in resp.split(CRLF) if not line.endswith(' disabled'))
          else:
            (code, text, resp)=self.parse_resp(self.cmdr('LIST ' + obj + 'S'))
          if code:
            isList = True
            listEntries = getEntries(resp)

            # Nothing to do if no entries
            if len(listEntries) == 0:
              my_loop = False

            for l in listEntries:
              my_l = l.split()[0]
              if action == 'REMOVE-ITEM':
                (code, text, resp)=self.parse_resp(self.cmdr('REMOVE ' + obj + ' ' + my_l))
                continue
              else:
                (code, text, resp)=self.parse_resp(self.cmdr('UPDATE ' + obj + ' ' + my_l))
              my_loop = False            
              self.runCli(my_sep.join(my_cmds[my_i+1:]), info = info + ':' + my_l)
              if action:
                (code, text, resp)=self.parse_resp(self.cmdr(action))
              else:
                (code, text, resp)=self.parse_resp(self.cmdr('BACK'))
        else:
          (code, text, resp)=self.parse_resp(self.cmdr(s))
          if code:
            if s.upper().startswith('LIST '):
              listEntries = getEntries(resp)
            elif s.upper().startswith('SHOW ATTR '):
              showEntries=resp.split(CRLF)[0]
            elif s.upper().startswith('SHOW'):
              showEntries='\t'.join(resp.split(CRLF)[2:-3])
      tend = datetime.datetime.now()
      try:
        context = resp.split('\n')[-1].strip()
      except:
        context = '<none>'
      if CLIBase.debug >= 1:
        print
      if not code:
        print("%-60s %-20s (%s) %-20s %s" % (info + ':' + s, 'command failure', str(tend - tstart), context, text))
        sys.exit(1)
      else:
        if not self.esetStart:
          if isList:
            print("%-60s %-20s (%s) %-20s %i objects" % (info + ':' + s, text, str(tend - tstart), context, len(listEntries)))
          elif showEntries:
            print("%-60s %-20s (%s) %-20s result: %s" % (info+':'+s, text, str(tend - tstart), context, showEntries))
          else:
            print("%-60s %-20s (%s) %-20s" % (info + ':' + s, text, str(tend - tstart), context))
      if not my_loop:
        break




#############################

if __name__=='__main__':
  import sys, os, datetime, time

  CLIRESPONSE=os.getenv("CLIRESPONSE","1").lower()
  if CLIRESPONSE == "true":
    CLIRESPONSE=1
  if CLIRESPONSE == "false":
    CLIRESPONSE=0
  CLIBase.debug=int(CLIRESPONSE)

  PARAMS=['cmds']
  PARAMSV={'cmds': None}

  if len(sys.argv)<len(PARAMS)+1:
    sys.stderr.write('Usage: %s ' % sys.argv[0])
    for p in PARAMS:
      sys.stderr.write('<%s> ' % p)
    sys.stderr.write('[admin-passwd [cli-host[:port[:conn-type]]]]\n\n')
    sys.stderr.write('where: conn-type could be raw (default) or ssl\n')
    sys.exit(255)
  for i in range(1, len(PARAMS)+1):
    PARAMSV[PARAMS[i-1]]=sys.argv[i]
  if len(sys.argv)>=len(PARAMS)+2:
    if sys.argv[len(PARAMS)+1]:
      CLIPASS=sys.argv[len(PARAMS)+1]
  if len(sys.argv)>=len(PARAMS)+3:
    CLIHOST=sys.argv[len(PARAMS)+2]
  if ':' in CLIHOST:
    try:
      CLIPORT=int(CLIHOST.split(':')[1])
    except ValueError:
      print('Error: Non-numeric CLI port passed as parameter', file=sys.stderr)
      sys.exit(1)
    try:
      CLICONN=CLIHOST.split(':')[2]
    except:
      pass
    CLIHOST=CLIHOST.split(':')[0]

  CLIHOST=os.getenv("CLIHOST",CLIHOST)
  CLIPORT=os.getenv("CLIPORT",CLIPORT)
  CLICONN=os.getenv("CLICONN",CLICONN)
  CLIPASS=os.getenv("CLIPASS",CLIPASS)

  if not CLIPASS:
    import getpass
    while not CLIPASS:
      CLIPASS=getpass.getpass('Enter CLI Admin password:')
      if not CLIPASS:
        print('Empty passwords are not allowed!', file=sys.stderr)

  c=CLIBase(CLIHOST, int(CLIPORT), CLIUSER, CLIPASS, CLICONN)

  if PARAMSV['cmds'] == '-':
    cliCommands = STDIN.read()
  else:
    cliCommands = PARAMSV['cmds']

  c.runCli(cliCommands)
