Skip to content

Commit

Permalink
Merge branch 'master' into reuse-ssh-connection
Browse files Browse the repository at this point in the history
  • Loading branch information
pasenor committed Mar 1, 2021
2 parents d9c604d + 05c87d8 commit 6392029
Show file tree
Hide file tree
Showing 25 changed files with 521 additions and 158 deletions.
14 changes: 11 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,20 @@ on:

jobs:
linux:

runs-on: ubuntu-latest

strategy:
matrix:
python-version: [3.6, 3.7, 3.8, 3.9]
include:
- python-version: 3.6
os: ubuntu-16.04 # MySQL 5.7.32
- python-version: 3.7
os: ubuntu-18.04 # MySQL 5.7.32
- python-version: 3.8
os: ubuntu-18.04 # MySQL 5.7.32
- python-version: 3.9
os: ubuntu-20.04 # MySQL 8.0.22

runs-on: ${{ matrix.os }}
steps:

- uses: actions/checkout@v2
Expand Down Expand Up @@ -42,6 +49,7 @@ jobs:
- name: Pytest / behave
env:
PYTEST_PASSWORD: root
PYTEST_HOST: 127.0.0.1
run: |
./setup.py test --pytest-args="--cov-report= --cov=mycli"
Expand Down
23 changes: 18 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
# mycli

[![Build Status](https://travis-ci.org/dbcli/mycli.svg?branch=master)](https://travis-ci.org/dbcli/mycli)
[![Build Status](https://github.com/dbcli/mycli/workflows/mycli/badge.svg)](https://github.com/dbcli/mycli/actions?query=workflow%3Amycli)
[![PyPI](https://img.shields.io/pypi/v/mycli.svg?style=plastic)](https://pypi.python.org/pypi/mycli)
[![Join the chat at https://gitter.im/dbcli/mycli](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/dbcli/mycli?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)

A command line client for MySQL that can do auto-completion and syntax highlighting.

Expand Down Expand Up @@ -53,6 +52,7 @@ $ sudo apt-get install mycli # Only on debian or ubuntu
-h, --host TEXT Host address of the database.
-P, --port INTEGER Port number to use for connection. Honors
$MYSQL_TCP_PORT.

-u, --user TEXT User name to connect to the database.
-S, --socket TEXT The socket file to use for connection.
-p, --password TEXT Password to connect to the database.
Expand All @@ -63,8 +63,11 @@ $ sudo apt-get install mycli # Only on debian or ubuntu
--ssh-password TEXT Password to connect to ssh server.
--ssh-key-filename TEXT Private key filename (identify file) for the
ssh connection.

--ssh-config-path TEXT Path to ssh configuration.
--ssh-config-host TEXT Host for ssh server in ssh configurations (requires paramiko).
--ssh-config-host TEXT Host to connect to ssh server reading from ssh
configuration.

--ssl-ca PATH CA file in PEM format.
--ssl-capath TEXT CA directory.
--ssl-cert PATH X509 cert in PEM format.
Expand All @@ -73,33 +76,43 @@ $ sudo apt-get install mycli # Only on debian or ubuntu
--ssl-verify-server-cert Verify server's "Common Name" in its cert
against hostname used when connecting. This
option is disabled by default.

-V, --version Output mycli's version.
-v, --verbose Verbose output.
-D, --database TEXT Database to use.
-d, --dsn TEXT Use DSN configured into the [alias_dsn]
section of myclirc file.

--list-dsn list of DSN configured into the [alias_dsn]
section of myclirc file.
--list-ssh-config list ssh configurations in the ssh config (requires paramiko).

--list-ssh-config list ssh configurations in the ssh config
(requires paramiko).

-R, --prompt TEXT Prompt format (Default: "\t \u@\h:\d> ").
-l, --logfile FILENAME Log every query and its results to a file.
--defaults-group-suffix TEXT Read MySQL config groups with the specified
suffix.

--defaults-file PATH Only read MySQL options from the given file.
--myclirc PATH Location of myclirc file.
--auto-vertical-output Automatically switch to vertical output mode
if the result is wider than the terminal
width.

-t, --table Display batch output in table format.
--csv Display batch output in CSV format.
--warn / --no-warn Warn before running a destructive query.
--local-infile BOOLEAN Enable/disable LOAD DATA LOCAL INFILE.
--login-path TEXT Read this path from the login file.
-g, --login-path TEXT Read this path from the login file.
-e, --execute TEXT Execute command and quit.
--init-command TEXT SQL statement to execute after connecting.
--charset TEXT Character set for MySQL session.
--password-file PATH File or FIFO path containing the password
to connect to the db if not specified otherwise
--help Show this message and exit.


Features
--------

Expand Down
14 changes: 13 additions & 1 deletion changelog.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
TBD
=======
===

Bug Fixes:
----------
* Allow `FileNotFound` exception for SSH config files.
* Fix startup error on MySQL < 5.0.22
* Check error code rather than message for Access Denied error
* Fix login with ~/.my.cnf files

Features:
---------
* Add `-g` shortcut to option `--login-path`.
* Alt-Enter dispatches the command in multi-line mode.
* Allow to pass a file or FIFO path with --password-file when password is not specified or is failing (as suggested in this best-practice https://www.netmeister.org/blog/passing-passwords.html)
* Reuse the same SSH connection for both main thread and completion thread.

Internal:
---------
* Remove unused function is_open_quote()
* Use importlib, instead of file links, to locate resources
* Test various host-port combinations in command line arguments
* Switched from Cryptography to pyaes for decrypting mylogin.cnf


1.23.2
===
Expand Down
1 change: 1 addition & 0 deletions mycli/AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ Contributors:
* xeron
* 0xflotus
* Seamile
* Jerome Provensal

Creator:
--------
Expand Down
1 change: 0 additions & 1 deletion mycli/clibuffer.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from prompt_toolkit.enums import DEFAULT_BUFFER
from prompt_toolkit.filters import Condition
from prompt_toolkit.application import get_app
from .packages.parseutils import is_open_quote
from .packages import special


Expand Down
102 changes: 80 additions & 22 deletions mycli/config.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import io
import shutil
from copy import copy
from io import BytesIO, TextIOWrapper
import logging
import os
from os.path import exists
import struct
import sys
from typing import Union
from typing import Union, IO

from configobj import ConfigObj, ConfigObjError
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
import pyaes

try:
import importlib.resources as resources
except ImportError:
# Python < 3.7
import importlib_resources as resources

try:
basestring
Expand Down Expand Up @@ -49,9 +52,9 @@ def read_config_file(f, list_values=True):
config = ConfigObj(f, interpolation=False, encoding='utf8',
list_values=list_values)
except ConfigObjError as e:
log(logger, logging.ERROR, "Unable to parse line {0} of config file "
log(logger, logging.WARNING, "Unable to parse line {0} of config file "
"'{1}'.".format(e.line_number, f))
log(logger, logging.ERROR, "Using successfully parsed config values.")
log(logger, logging.WARNING, "Using successfully parsed config values.")
return e.config
except (IOError, OSError) as e:
log(logger, logging.WARNING, "You don't have permission to read "
Expand All @@ -61,7 +64,7 @@ def read_config_file(f, list_values=True):
return config


def get_included_configs(config_file: Union[str, io.TextIOWrapper]) -> list:
def get_included_configs(config_file: Union[str, TextIOWrapper]) -> list:
"""Get a list of configuration files that are included into config_path
with !includedir directive.
Expand Down Expand Up @@ -95,7 +98,7 @@ def get_included_configs(config_file: Union[str, io.TextIOWrapper]) -> list:
def read_config_files(files, list_values=True):
"""Read and merge a list of config files."""

config = ConfigObj(list_values=list_values)
config = create_default_config(list_values=list_values)
_files = copy(files)
while _files:
_file = _files.pop(0)
Expand All @@ -112,12 +115,21 @@ def read_config_files(files, list_values=True):
return config


def write_default_config(source, destination, overwrite=False):
def create_default_config(list_values=True):
import mycli
default_config_file = resources.open_text(mycli, 'myclirc')
return read_config_file(default_config_file, list_values=list_values)


def write_default_config(destination, overwrite=False):
import mycli
default_config = resources.read_text(mycli, 'myclirc')
destination = os.path.expanduser(destination)
if not overwrite and exists(destination):
return

shutil.copyfile(source, destination)
with open(destination, 'w') as f:
f.write(default_config)


def get_mylogin_cnf_path():
Expand Down Expand Up @@ -160,6 +172,58 @@ def open_mylogin_cnf(name):
return TextIOWrapper(plaintext)


# TODO reuse code between encryption an decryption
def encrypt_mylogin_cnf(plaintext: IO[str]):
"""Encryption of .mylogin.cnf file, analogous to calling
mysql_config_editor.
Code is based on the python implementation by Kristian Koehntopp
https://github.com/isotopp/mysql-config-coder
"""
def realkey(key):
"""Create the AES key from the login key."""
rkey = bytearray(16)
for i in range(len(key)):
rkey[i % 16] ^= key[i]
return bytes(rkey)

def encode_line(plaintext, real_key, buf_len):
aes = pyaes.AESModeOfOperationECB(real_key)
text_len = len(plaintext)
pad_len = buf_len - text_len
pad_chr = bytes(chr(pad_len), "utf8")
plaintext = plaintext.encode() + pad_chr * pad_len
encrypted_text = b''.join(
[aes.encrypt(plaintext[i: i + 16])
for i in range(0, len(plaintext), 16)]
)
return encrypted_text

LOGIN_KEY_LENGTH = 20
key = os.urandom(LOGIN_KEY_LENGTH)
real_key = realkey(key)

outfile = BytesIO()

outfile.write(struct.pack("i", 0))
outfile.write(key)

while True:
line = plaintext.readline()
if not line:
break
real_len = len(line)
pad_len = (int(real_len / 16) + 1) * 16

outfile.write(struct.pack("i", pad_len))
x = encode_line(line, real_key, pad_len)
outfile.write(x)

outfile.seek(0)
return outfile


def read_and_decrypt_mylogin_cnf(f):
"""Read and decrypt the contents of .mylogin.cnf.
Expand Down Expand Up @@ -201,11 +265,9 @@ def read_and_decrypt_mylogin_cnf(f):
return None
rkey = struct.pack('16B', *rkey)

# Create a decryptor object using the key.
decryptor = _get_decryptor(rkey)

# Create a bytes buffer to hold the plaintext.
plaintext = BytesIO()
aes = pyaes.AESModeOfOperationECB(rkey)

while True:
# Read the length of the ciphertext.
Expand All @@ -216,7 +278,10 @@ def read_and_decrypt_mylogin_cnf(f):

# Read cipher_len bytes from the file and decrypt.
cipher = f.read(cipher_len)
plain = _remove_pad(decryptor.update(cipher))
plain = _remove_pad(
b''.join([aes.decrypt(cipher[i: i + 16])
for i in range(0, cipher_len, 16)])
)
if plain is False:
continue
plaintext.write(plain)
Expand Down Expand Up @@ -260,15 +325,8 @@ def strip_matching_quotes(s):
return s


def _get_decryptor(key):
"""Get the AES decryptor."""
c = Cipher(algorithms.AES(key), modes.ECB(), backend=default_backend())
return c.decryptor()


def _remove_pad(line):
"""Remove the pad from the *line*."""
pad_length = ord(line[-1:])
try:
# Determine pad length.
pad_length = ord(line[-1:])
Expand Down
8 changes: 6 additions & 2 deletions mycli/key_bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,12 @@ def _(event):

@kb.add('escape', 'enter')
def _(event):
"""Introduces a line break regardless of multi-line mode or not."""
"""Introduces a line break in multi-line mode, or dispatches the
command in single-line mode."""
_logger.debug('Detected alt-enter key.')
event.app.current_buffer.insert_text('\n')
if mycli.multi_line:
event.app.current_buffer.validate_and_handle()
else:
event.app.current_buffer.insert_text('\n')

return kb
Loading

0 comments on commit 6392029

Please sign in to comment.