Initial commit
This commit is contained in:
135
.gitignore
vendored
Normal file
135
.gitignore
vendored
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
/build/
|
||||||
|
develop-eggs/
|
||||||
|
/dist/
|
||||||
|
/downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
/lib/
|
||||||
|
/lib64/
|
||||||
|
/parts/
|
||||||
|
/sdist/
|
||||||
|
/var/
|
||||||
|
/wheels/
|
||||||
|
pip-wheel-metadata/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# ctags output file
|
||||||
|
tags
|
||||||
10
NOTES.txt
Normal file
10
NOTES.txt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Initial install (generated private key on pusher host)
|
||||||
|
cat fullchain privkey > all.pem
|
||||||
|
scp all.pem arachnia:/
|
||||||
|
ssh arachnia "/certificate import name=www_ssl_cert file-name=all.pem no-key-export=yes; /ip service set www-ssl certificate=www_ssl_cert; /ip service set api-ssl certificate=www_ssl_cert"
|
||||||
|
|
||||||
|
# Subsequent install (renewed on pusher host)
|
||||||
|
cat fullchain > renew.pem
|
||||||
|
scp renew.pem arachnia:/
|
||||||
|
ssh arachnia "/certificate import name=www_ssl_cert file-name=renew.pem"
|
||||||
|
#ssh arachnia "/ip service set www-ssl certificate=www_ssl_cert"
|
||||||
116
pkcs12_export.py
Normal file
116
pkcs12_export.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
#!python3
|
||||||
|
# dlitz 2025
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from contextlib import ExitStack, contextmanager
|
||||||
|
|
||||||
|
|
||||||
|
class PKCS12Exporter:
|
||||||
|
|
||||||
|
openssl_prog = "openssl"
|
||||||
|
|
||||||
|
def export_pkcs12(self, privkey_data, cert_data, chain_data, passphrase):
|
||||||
|
assert re.search(r"^-----BEGIN (.*) PRIVATE KEY-----\n", privkey_data, re.M)
|
||||||
|
assert re.search(r"^-----BEGIN CERTIFICATE-----\n", cert_data, re.M)
|
||||||
|
assert "PRIVATE KEY" not in cert_data
|
||||||
|
assert chain_data is None or "PRIVATE KEY" not in chain_data
|
||||||
|
|
||||||
|
fullchain_data = cert_data + "\n" + (chain_data or "") + "\n"
|
||||||
|
|
||||||
|
all_data = privkey_data + "\n" + fullchain_data
|
||||||
|
|
||||||
|
with self.pipe_with_buffered_data(passphrase, text=True) as passphrase_r:
|
||||||
|
cmd = [
|
||||||
|
self.openssl_prog,
|
||||||
|
"pkcs12",
|
||||||
|
"-export",
|
||||||
|
"-passout",
|
||||||
|
f"fd:{passphrase_r.fileno():d}",
|
||||||
|
# "-macalg", "SHA256",
|
||||||
|
# "-keypbe", "AES-256-CBC",
|
||||||
|
# "-certpbe", "NONE",
|
||||||
|
]
|
||||||
|
return subprocess.check_output(
|
||||||
|
cmd, pass_fds=[passphrase_r.fileno()], input=all_data.encode()
|
||||||
|
)
|
||||||
|
|
||||||
|
def open_pipes(self, *, text=False, encoding=None, errors=None):
|
||||||
|
rfd, wfd = os.pipe()
|
||||||
|
rfile = wfile = None
|
||||||
|
try:
|
||||||
|
rfile = open(rfd, "r" if text else "rb", encoding=encoding, errors=errors)
|
||||||
|
wfile = open(wfd, "w" if text else "wb", encoding=encoding, errors=errors)
|
||||||
|
return rfile, wfile
|
||||||
|
except:
|
||||||
|
if rfile is not None:
|
||||||
|
rfile.close()
|
||||||
|
else:
|
||||||
|
os.close(rfd)
|
||||||
|
if wfile is not None:
|
||||||
|
wfile.close()
|
||||||
|
else:
|
||||||
|
os.close(wfd)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def pipe_with_buffered_data(self, data, *, text=False, encoding=None, errors=None):
|
||||||
|
# Open a pipe and place a small string into its buffer, then return a file
|
||||||
|
# open for reading the pipe.
|
||||||
|
rfile, wfile = self.open_pipes(text=text)
|
||||||
|
try:
|
||||||
|
if text:
|
||||||
|
bdata = data.encode(encoding=wfile.encoding, errors=wfile.errors)
|
||||||
|
else:
|
||||||
|
bdata = data
|
||||||
|
pipe_buffer_size = fcntl.fcntl(wfile.fileno(), fcntl.F_GETPIPE_SZ)
|
||||||
|
if pipe_buffer_size < len(bdata):
|
||||||
|
pipe_buffer_size = fcntl.fcntl(
|
||||||
|
wfile.fileno(), fcntl.F_SETPIPE_SZ, len(bdata)
|
||||||
|
)
|
||||||
|
assert pipe_buffer_size >= len(bdata)
|
||||||
|
wfile.write(data)
|
||||||
|
wfile.close()
|
||||||
|
return rfile
|
||||||
|
except:
|
||||||
|
wfile.close()
|
||||||
|
rfile.close()
|
||||||
|
raise
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
from argparse import ArgumentParser
|
||||||
|
from pathlib import Path
|
||||||
|
import getpass
|
||||||
|
import sys
|
||||||
|
|
||||||
|
parser = ArgumentParser(
|
||||||
|
description="push TLS privkey & certificate to MikroTik RouterOS router"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-k", "--privkey", type=Path, required=True, help="private key file"
|
||||||
|
)
|
||||||
|
parser.add_argument("--cert", type=Path, required=True, help="certificate file")
|
||||||
|
parser.add_argument(
|
||||||
|
"--chain", type=Path, help="separate certificate chain file (optional)"
|
||||||
|
)
|
||||||
|
parser.add_argument("-o", "--output", type=Path, help="output file")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
privkey_data = args.privkey.read_text()
|
||||||
|
cert_data = args.cert.read_text()
|
||||||
|
chain_data = args.chain.read_text() if args.chain is not None else None
|
||||||
|
|
||||||
|
key_passphrase = getpass.getpass("set the passphrase:")
|
||||||
|
|
||||||
|
pkcs12_data = PKCS12Exporter().export_pkcs12(
|
||||||
|
privkey_data=privkey_data,
|
||||||
|
cert_data=cert_data,
|
||||||
|
chain_data=chain_data,
|
||||||
|
passphrase=key_passphrase,
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.output:
|
||||||
|
args.output.write_bytes(pkcs12_data)
|
||||||
|
else:
|
||||||
|
sys.stdout.buffer.write(pkcs12_data)
|
||||||
71
pkcs12_export_proc.py
Normal file
71
pkcs12_export_proc.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
#!python3
|
||||||
|
# dlitz 2025
|
||||||
|
|
||||||
|
from contextlib import ExitStack, contextmanager
|
||||||
|
import multiprocessing
|
||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
class PKCS12Exporter:
|
||||||
|
|
||||||
|
openssl_prog = "openssl"
|
||||||
|
|
||||||
|
def export_pkcs12(self, privkey_data, cert_data, chain_data, passphrase):
|
||||||
|
assert re.search(r"^-----BEGIN (.*) PRIVATE KEY-----\n", privkey_data, re.M)
|
||||||
|
assert re.search(r"^-----BEGIN CERTIFICATE-----\n", cert_data, re.M)
|
||||||
|
assert "PRIVATE KEY" not in cert_data
|
||||||
|
assert chain_data is None or "PRIVATE KEY" not in chain_data
|
||||||
|
|
||||||
|
fullchain_data = cert_data + "\n" + (chain_data or "") + "\n"
|
||||||
|
|
||||||
|
all_data = privkey_data + "\n" + fullchain_data
|
||||||
|
|
||||||
|
with ExitStack() as stack:
|
||||||
|
passphrase_r, passphrase_w = stack.enter_context(self.open_pipes(text=True))
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
self.openssl_prog,
|
||||||
|
"pkcs12",
|
||||||
|
"-export",
|
||||||
|
"-passout", f"fd:{passphrase_r.fileno():d}",
|
||||||
|
"-macalg", "SHA256",
|
||||||
|
"-keypbe", "AES-256-CBC",
|
||||||
|
"-certpbe", "NONE",
|
||||||
|
]
|
||||||
|
|
||||||
|
passphrase_proc = multiprocessing.Process(target=self.data_writer, args=(passphrase_w, passphrase))
|
||||||
|
passphrase_proc.start()
|
||||||
|
passphrase_w.close()
|
||||||
|
|
||||||
|
print(cmd)
|
||||||
|
#subprocess.run(["bash", "-x", "-c", "ls -l /dev/fd/; cat /dev/fd/5; cat /dev/fd/3"], pass_fds=(passphrase_r.fileno(), privkey_r.fileno()))
|
||||||
|
#subprocess.run(cmd, pass_fds=(passphrase_r.fileno(), privkey_r.fileno()), input=fullchain_data.encode())
|
||||||
|
subprocess.run(cmd, pass_fds=(passphrase_r.fileno(), privkey_r.fileno()), input=all_data.encode())
|
||||||
|
|
||||||
|
passphrase_proc.terminate()
|
||||||
|
passphrase_proc.
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def open_pipes(self, *, text=False):
|
||||||
|
rfd, wfd = os.pipe()
|
||||||
|
rfile = wfile = None
|
||||||
|
try:
|
||||||
|
rfile = open(rfd, "r" if text else "rb")
|
||||||
|
wfile = open(wfd, "w" if text else "wb")
|
||||||
|
except:
|
||||||
|
if rfile is not None:
|
||||||
|
rfile.close()
|
||||||
|
else:
|
||||||
|
os.close(rfd)
|
||||||
|
if wfile is not None:
|
||||||
|
wfile.close()
|
||||||
|
else:
|
||||||
|
os.close(wfd)
|
||||||
|
with rfile as r, wfile as w:
|
||||||
|
yield r, w
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def data_writer(cls, file, data):
|
||||||
|
file.write(data)
|
||||||
|
file.close()
|
||||||
86
pushcert-mikrotik
Executable file
86
pushcert-mikrotik
Executable file
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# dlitz 2025
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from argparse import ArgumentParser
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pkcs12_export import PKCS12Exporter
|
||||||
|
|
||||||
|
|
||||||
|
def generate_random_passphrase():
|
||||||
|
return os.urandom(64).hex()
|
||||||
|
|
||||||
|
|
||||||
|
def make_arg_parser():
|
||||||
|
parser = ArgumentParser(
|
||||||
|
description="push TLS privkey & certificate to MikroTik RouterOS router"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-k", "--privkey", type=Path, required=True, help="private key file"
|
||||||
|
)
|
||||||
|
parser.add_argument("--cert", type=Path, required=True, help="certificate file")
|
||||||
|
parser.add_argument(
|
||||||
|
"--chain", type=Path, help="separate certificate chain file (optional)"
|
||||||
|
)
|
||||||
|
parser.add_argument("--ssh-host", required=True, help="target ssh host")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
parser = make_arg_parser()
|
||||||
|
args = parser.parse_args()
|
||||||
|
assert ":" not in args.ssh_host
|
||||||
|
return args, parser
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args, parser = parse_args()
|
||||||
|
|
||||||
|
# TODO: Check certificate serial number before attempting to copy cert, and at end.
|
||||||
|
|
||||||
|
privkey_data = args.privkey.read_text()
|
||||||
|
cert_data = args.cert.read_text()
|
||||||
|
chain_data = args.chain.read_text() if args.chain is not None else None
|
||||||
|
|
||||||
|
key_passphrase = generate_random_passphrase()
|
||||||
|
|
||||||
|
pkcs12_data = PKCS12Exporter().export_pkcs12(
|
||||||
|
privkey_data=privkey_data,
|
||||||
|
cert_data=cert_data,
|
||||||
|
chain_data=chain_data,
|
||||||
|
passphrase=key_passphrase,
|
||||||
|
)
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(dir="/dev/shm") as tf:
|
||||||
|
tf.write(pkcs12_data)
|
||||||
|
tf.flush()
|
||||||
|
|
||||||
|
ssh_options = [
|
||||||
|
"-oBatchMode=yes",
|
||||||
|
"-oControlMaster=no",
|
||||||
|
]
|
||||||
|
|
||||||
|
cmd = ["scp", *ssh_options, "-q", tf.name, f"{args.ssh_host}:/cert-pusher-data.p12"]
|
||||||
|
# print("executing:", shlex.join(cmd))
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
|
||||||
|
# ros_command = f'/certificate import name=www_ssl_cert file-name=cert-pusher-data.p12 no-key-export=yes passphrase="{key_passphrase}"'
|
||||||
|
ros_command = f'/certificate import name=www_ssl_cert file-name=cert-pusher-data.p12 no-key-export=yes passphrase="{key_passphrase}"; /file remove [/file find name=cert-pusher-data.p12]'
|
||||||
|
cmd = [
|
||||||
|
"ssh",
|
||||||
|
*ssh_options,
|
||||||
|
args.ssh_host,
|
||||||
|
ros_command,
|
||||||
|
]
|
||||||
|
result = subprocess.check_output(cmd, text=True)
|
||||||
|
assert " files-imported: 1" in result
|
||||||
|
# print(result)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user