confgen is done. messy, but done.
This commit is contained in:
parent
f4f131890d
commit
1d9b40a597
@ -625,7 +625,7 @@ class ConfGenerator(object):
|
|||||||
'created that can be used to serve iPXE)'),
|
'created that can be used to serve iPXE)'),
|
||||||
'tftp': ('the TFTP directory (where a TFTP/'
|
'tftp': ('the TFTP directory (where a TFTP/'
|
||||||
'traditional PXE root is created)'),
|
'traditional PXE root is created)'),
|
||||||
'ssl': ('the SSL/TLS PKI directory (where we store '
|
'pki': ('the SSL/TLS PKI directory (where we store '
|
||||||
'the PKI structure we use/re-use - MAKE SURE '
|
'the PKI structure we use/re-use - MAKE SURE '
|
||||||
'it is in a path that is well-protected!)')}
|
'it is in a path that is well-protected!)')}
|
||||||
has_paths = False
|
has_paths = False
|
||||||
@ -840,7 +840,7 @@ class ConfGenerator(object):
|
|||||||
'then trying the built-in ~/.gnupg directory).'
|
'then trying the built-in ~/.gnupg directory).'
|
||||||
'\nGPG Home Directory: '))
|
'\nGPG Home Directory: '))
|
||||||
if _gpghome.strip() != '':
|
if _gpghome.strip() != '':
|
||||||
gpg.attrib['gnupghome'] == _gpghome
|
gpg.attrib['gnupghome'] = _gpghome
|
||||||
else:
|
else:
|
||||||
_gpghome = 'none'
|
_gpghome = 'none'
|
||||||
print('\n++ GPG || KEYSERVER PUSHING ++')
|
print('\n++ GPG || KEYSERVER PUSHING ++')
|
||||||
@ -935,12 +935,12 @@ class ConfGenerator(object):
|
|||||||
'\n\t'.join(_choices)
|
'\n\t'.join(_choices)
|
||||||
))).strip().lower()
|
))).strip().lower()
|
||||||
if _export_type.startswith('a'):
|
if _export_type.startswith('a'):
|
||||||
_export_type == 'asc'
|
_export_type = 'asc'
|
||||||
elif _export_type.startswith('b'):
|
elif _export_type.startswith('b'):
|
||||||
_export_type == 'bin'
|
_export_type = 'bin'
|
||||||
else:
|
else:
|
||||||
print('Using the default.')
|
print('Using the default.')
|
||||||
_export_type == 'asc'
|
_export_type = 'asc'
|
||||||
elem.attrib['format'] = _export_type
|
elem.attrib['format'] = _export_type
|
||||||
_path = None
|
_path = None
|
||||||
while not _path:
|
while not _path:
|
||||||
|
116
bdisk/prompt_strings.py
Normal file
116
bdisk/prompt_strings.py
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
# These are *key* ciphers, for encrypting exported keys.
|
||||||
|
openssl_ciphers = ['aes128', 'aes192', 'aes256', 'bf', 'blowfish',
|
||||||
|
'camellia128', 'camellia192', 'camellia256', 'cast', 'des',
|
||||||
|
'des3', 'idea', 'rc2', 'seed']
|
||||||
|
# These are *hash algorithms* for cert digests.
|
||||||
|
openssl_digests = ['blake2b512', 'blake2s256', 'gost', 'md4', 'md5', 'mdc2',
|
||||||
|
'rmd160', 'sha1', 'sha224', 'sha256', 'sha384', 'sha512']
|
||||||
|
|
||||||
|
class PromptStrings(object):
|
||||||
|
gpg = {
|
||||||
|
'attribs': {
|
||||||
|
'algo': {
|
||||||
|
'text': 'the subkey\'s encryption type/algorithm',
|
||||||
|
'choices': ['rsa', 'dsa'],
|
||||||
|
'default': 'rsa'
|
||||||
|
},
|
||||||
|
'keysize': {
|
||||||
|
'text': 'the subkey\'s key size (in bits)',
|
||||||
|
'choices': {
|
||||||
|
'rsa': ['1024', '2048', '4096'],
|
||||||
|
'dsa': ['768', '2048', '3072']
|
||||||
|
},
|
||||||
|
'default': {
|
||||||
|
'rsa': '4096',
|
||||||
|
'dsa': '3072'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'params': ['name', 'email', 'comment']
|
||||||
|
}
|
||||||
|
ssl = {
|
||||||
|
'attribs': {
|
||||||
|
'cert': {
|
||||||
|
'hash_algo': {
|
||||||
|
'text': ('What hashing algorithm do you want to use? '
|
||||||
|
'(Default is sha512.)'),
|
||||||
|
'prompt': 'Hashing algorithm: ',
|
||||||
|
'options': openssl_digests,
|
||||||
|
'default': 'aes256'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'key': {
|
||||||
|
'cipher': {
|
||||||
|
'text': ('What encryption algorithm/cipher do you want to '
|
||||||
|
'use? (Default is aes256.) Use "none" to specify '
|
||||||
|
'a key without a passphrase.'),
|
||||||
|
'prompt': 'Cipher: ',
|
||||||
|
'options': openssl_ciphers + ['none'],
|
||||||
|
'default': 'aes256'
|
||||||
|
},
|
||||||
|
'keysize': {
|
||||||
|
'text': ('What keysize/length (in bits) do you want the '
|
||||||
|
'key to be? (Default is 4096; much higher values '
|
||||||
|
'are possible but are untested and thus not '
|
||||||
|
'supported by this tool; feel free to edit the '
|
||||||
|
'generated configuration by hand.) (If the key '
|
||||||
|
'cipher is "none", this is ignored.)'),
|
||||||
|
'prompt': 'Keysize: ',
|
||||||
|
# TODO: do all openssl_ciphers support these sizes?
|
||||||
|
'options': ['1024', '2048', '4096'],
|
||||||
|
'default': 'aes256'
|
||||||
|
},
|
||||||
|
'passphrase': {
|
||||||
|
'text': ('What passphrase do you want to use for the key? '
|
||||||
|
'If you specified the cipher as "none", this is '
|
||||||
|
'ignored (you can just hit enter).'),
|
||||||
|
'prompt': 'Passphrase (will not echo back): ',
|
||||||
|
'options': None,
|
||||||
|
'default': ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'paths': {
|
||||||
|
'cert': '(or read from) the certificate',
|
||||||
|
'key': '(or read from) the key',
|
||||||
|
'csr': ('(or read from) the certificate signing request (if '
|
||||||
|
'blank, we won\'t write to disk - the operation will '
|
||||||
|
'occur entirely in memory assuming we need to generate/'
|
||||||
|
'sign)')
|
||||||
|
},
|
||||||
|
'paths_ca': {
|
||||||
|
'index': ('(or read from) the CA (Certificate Authority) Database '
|
||||||
|
'index file (if left blank, one will not be used)'),
|
||||||
|
'serial': ('(or read from) the CA (Certificate Authority) '
|
||||||
|
'Database serial file (if left blank, one will not be '
|
||||||
|
'used)'),
|
||||||
|
},
|
||||||
|
'subject': {
|
||||||
|
'countryName': {
|
||||||
|
'text': ('the 2-letter country abbreviation (must conform to '
|
||||||
|
'ISO3166 ALPHA-2)?\n'
|
||||||
|
'Country code: ')
|
||||||
|
},
|
||||||
|
'localityName': {
|
||||||
|
'text': ('the city/town/borough/locality name?\n'
|
||||||
|
'Locality: ')
|
||||||
|
},
|
||||||
|
'stateOrProvinceName': {
|
||||||
|
'text': ('the state/region name (full string)?\n'
|
||||||
|
'Region: ')
|
||||||
|
},
|
||||||
|
'organization': {
|
||||||
|
'text': ('your organization\'s name?\n'
|
||||||
|
'Organization: ')
|
||||||
|
},
|
||||||
|
'organizationalUnitName': {
|
||||||
|
'text': ('your department/role/team/department name?\n'
|
||||||
|
'Organizational Unit: ')
|
||||||
|
},
|
||||||
|
'emailAddress': {
|
||||||
|
'text': ('the email address to be associated with this '
|
||||||
|
'certificate/PKI object?\n'
|
||||||
|
'Email: ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
221
bdisk/utils.py
221
bdisk/utils.py
@ -1,11 +1,14 @@
|
|||||||
import _io
|
import _io
|
||||||
|
import copy
|
||||||
import crypt
|
import crypt
|
||||||
import GPG
|
import GPG
|
||||||
|
import getpass
|
||||||
import hashid
|
import hashid
|
||||||
import hashlib
|
import hashlib
|
||||||
import iso3166
|
import iso3166
|
||||||
import os
|
import os
|
||||||
import pprint
|
import pprint
|
||||||
|
import prompt_strings
|
||||||
import re
|
import re
|
||||||
import string
|
import string
|
||||||
import uuid
|
import uuid
|
||||||
@ -33,12 +36,7 @@ crypt_map = {'sha512': crypt.METHOD_SHA512,
|
|||||||
'md5': crypt.METHOD_MD5,
|
'md5': crypt.METHOD_MD5,
|
||||||
'des': crypt.METHOD_CRYPT}
|
'des': crypt.METHOD_CRYPT}
|
||||||
|
|
||||||
# These are *key* ciphers, for encrypting exported keys.
|
|
||||||
openssl_ciphers = ['aes128', 'aes192', 'aes256', 'bf', 'blowfish',
|
|
||||||
'camellia128', 'camellia192', 'camellia256', 'cast', 'des',
|
|
||||||
'des3', 'idea', 'rc2', 'seed']
|
|
||||||
openssl_digests = ['blake2b512', 'blake2s256', 'gost', 'md4', 'md5', 'mdc2',
|
|
||||||
'rmd160', 'sha1', 'sha224', 'sha256', 'sha384', 'sha512']
|
|
||||||
|
|
||||||
class XPathFmt(string.Formatter):
|
class XPathFmt(string.Formatter):
|
||||||
def get_field(self, field_name, args, kwargs):
|
def get_field(self, field_name, args, kwargs):
|
||||||
@ -162,7 +160,10 @@ class generate(object):
|
|||||||
|
|
||||||
class prompts(object):
|
class prompts(object):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
pass
|
self.promptstr = prompt_strings.PromptStrings()
|
||||||
|
|
||||||
|
# TODO: these strings should be indexed in a separate module and
|
||||||
|
# sourced/imported. we should generally just find a cleaner way to do this.
|
||||||
|
|
||||||
def confirm_or_no(self, prompt = '', invert = False,
|
def confirm_or_no(self, prompt = '', invert = False,
|
||||||
usage = '{0} to confirm, otherwise {1}...\n'):
|
usage = '{0} to confirm, otherwise {1}...\n'):
|
||||||
@ -193,6 +194,62 @@ class prompts(object):
|
|||||||
return(False)
|
return(False)
|
||||||
return(True)
|
return(True)
|
||||||
|
|
||||||
|
def gpg_keygen_attribs(self):
|
||||||
|
_strs = self.promptstr.gpg
|
||||||
|
gpg_vals = {'attribs': {},
|
||||||
|
'params': {}}
|
||||||
|
_checks = {
|
||||||
|
'params': {
|
||||||
|
'name': {'error': 'name cannot be empty',
|
||||||
|
'check': valid().nonempty_str},
|
||||||
|
'email': {'error': 'not a valid email address',
|
||||||
|
'check': valid().email}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for a in _strs['attribs']:
|
||||||
|
_a = None
|
||||||
|
while not _a:
|
||||||
|
if 'algo' in gpg_vals['attribs'] and a == 'keysize':
|
||||||
|
_algo = gpg_vals['attribs']['algo']
|
||||||
|
_choices = _strs['attribs']['keysize']['choices'][_algo]
|
||||||
|
_dflt = _strs['attribs']['keysize']['default'][_algo]
|
||||||
|
else:
|
||||||
|
_choices = _strs['attribs'][a]['choices']
|
||||||
|
_dflt = _strs['attribs'][a]['default']
|
||||||
|
_a = (input(
|
||||||
|
('\nWhat should be {0}? (Default is {1}.)\nChoices:\n'
|
||||||
|
'\n\t{2}\n\n{3}: ').format(
|
||||||
|
_strs['attribs'][a]['text'],
|
||||||
|
_dflt,
|
||||||
|
'\n\t'.join(_choices),
|
||||||
|
a.title()
|
||||||
|
)
|
||||||
|
)).strip().lower()
|
||||||
|
if _a == '':
|
||||||
|
_a = _dflt
|
||||||
|
elif _a not in _choices:
|
||||||
|
print(
|
||||||
|
('Invalid selection; choosing default '
|
||||||
|
'({0})').format(_dflt)
|
||||||
|
)
|
||||||
|
_a = _dflt
|
||||||
|
gpg_vals['attribs'][a] = _a
|
||||||
|
for p in _strs['params']:
|
||||||
|
_p = input(
|
||||||
|
('\nWhat is the {0} for the subkey?\n'
|
||||||
|
'{1}: ').format(p, p.title())
|
||||||
|
)
|
||||||
|
if p in _checks['params']:
|
||||||
|
while not _checks['params'][p]['check'](_p):
|
||||||
|
print(
|
||||||
|
('Invalid entry ({0}). Please retry.').format(
|
||||||
|
_checks['params'][p]['error']
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_p = input('{0}: '.format(_p.title()))
|
||||||
|
gpg_vals['params'][p] = _p
|
||||||
|
return(gpg_vals)
|
||||||
|
|
||||||
def hash_select(self, prompt = '',
|
def hash_select(self, prompt = '',
|
||||||
hash_types = generate().hashlib_names()):
|
hash_types = generate().hashlib_names()):
|
||||||
_hash_types = hash_types
|
_hash_types = hash_types
|
||||||
@ -240,116 +297,81 @@ class prompts(object):
|
|||||||
ssl_vals = {'paths': {},
|
ssl_vals = {'paths': {},
|
||||||
'attribs': {},
|
'attribs': {},
|
||||||
'subject': {}}
|
'subject': {}}
|
||||||
|
_checks = {
|
||||||
|
'subject': {
|
||||||
|
'countryName': valid().country_abbrev,
|
||||||
|
'emailAddress': valid().email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_strs = copy.deepcopy(self.promptstr.ssl)
|
||||||
# pki_role should be 'ca' or 'client'
|
# pki_role should be 'ca' or 'client'
|
||||||
if pki_role not in ('ca', 'client'):
|
if pki_role not in ('ca', 'client'):
|
||||||
raise ValueError('pki_role must be either "ca" or "client"')
|
raise ValueError('pki_role must be either "ca" or "client"')
|
||||||
_attribs = {'cert': {'hash_algo': {'text': ('What hashing algorithm '
|
# NOTE: need to validate US and email
|
||||||
'do you want to use? (Default is sha512.)'),
|
|
||||||
'prompt': 'Hashing algorithm: ',
|
|
||||||
'options': openssl_digests,
|
|
||||||
'default': 'sha512'}},
|
|
||||||
'key': {'cipher': {'text': ('What encryption algorithm/'
|
|
||||||
'cipher do you want to use? (Default is '
|
|
||||||
'aes256.)'),
|
|
||||||
'prompt': 'Cipher: ',
|
|
||||||
'options': openssl_ciphers,
|
|
||||||
'default': 'aes256'},
|
|
||||||
# This can actually theoretically be anywhere from
|
|
||||||
# 512 to... who knows how high. I couldn't find the
|
|
||||||
# upper bound. So we just set it to sensible
|
|
||||||
# defaults. If they want something higher, they can
|
|
||||||
# edit the XML when they're done.
|
|
||||||
'keysize': {'text': ('What keysize/length (in '
|
|
||||||
'bits) do you want the key to be? (Default is '
|
|
||||||
'4096; much higher values are possible but '
|
|
||||||
'are untested and thus not supported by this '
|
|
||||||
'tool; feel free to edit the generated '
|
|
||||||
'configuration by hand.)'),
|
|
||||||
'prompt': 'Keysize: ',
|
|
||||||
'options': ['1024', '2048', '4096'],
|
|
||||||
'default': '4096'}}}
|
|
||||||
_paths = {'cert': '(or read from) the certificate',
|
|
||||||
'key': '(or read from) the key',
|
|
||||||
'csr': ('(or read from) the certificate signing request (if '
|
|
||||||
'blank, we won\'t write to disk - the operation '
|
|
||||||
'will occur entirely in memory assuming we need to '
|
|
||||||
'generate/sign)')}
|
|
||||||
if pki_role == 'ca':
|
if pki_role == 'ca':
|
||||||
_paths['index'] = ('(or read from) the CA DB index file (if left '
|
# this is getting triggered for clients?
|
||||||
'blank, one will not be used)')
|
_strs['paths'].update(_strs['paths_ca'])
|
||||||
_paths['serial'] = ('(or read from) the CA DB serial file (if '
|
for a in _strs['attribs']:
|
||||||
'left blank, one will not be used)')
|
|
||||||
for a in _attribs:
|
|
||||||
ssl_vals['attribs'][a] = {}
|
ssl_vals['attribs'][a] = {}
|
||||||
for x in _attribs[a]:
|
for x in _strs['attribs'][a]:
|
||||||
ssl_vals['attribs'][a][x] = None
|
ssl_vals['attribs'][a][x] = None
|
||||||
for p in _paths:
|
for p in _strs['paths']:
|
||||||
if p == 'csr':
|
if p == 'csr':
|
||||||
_allow_empty = True
|
_allow_empty = True
|
||||||
else:
|
else:
|
||||||
_allow_empty = False
|
_allow_empty = False
|
||||||
ssl_vals['paths'][p] = self.path(_paths[p],
|
ssl_vals['paths'][p] = self.path(_strs['paths'][p],
|
||||||
empty_passthru = _allow_empty)
|
empty_passthru = _allow_empty)
|
||||||
print()
|
print()
|
||||||
if ssl_vals['paths'][p] == '':
|
if ssl_vals['paths'][p] == '':
|
||||||
ssl_vals['paths'][p] = None
|
ssl_vals['paths'][p] = None
|
||||||
if p in _attribs:
|
if p in _strs['attribs']:
|
||||||
for x in _attribs[p]:
|
for x in _strs['attribs'][p]:
|
||||||
while not ssl_vals['attribs'][p][x]:
|
while not ssl_vals['attribs'][p][x]:
|
||||||
ssl_vals['attribs'][p][x] = (input(
|
# cipher attrib is prompted for before this.
|
||||||
('\n{0}\n\n\t{1}\n\n{2}').format(
|
if p == 'key' and x == 'passphrase':
|
||||||
_attribs[p][x]['text'],
|
if ssl_vals['attribs']['key']['cipher'] == 'none':
|
||||||
'\n\t'.join(_attribs[p][x]['options']),
|
ssl_vals['attribs'][p][x] = 'none'
|
||||||
_attribs[p][x]['prompt'])
|
continue
|
||||||
)).strip().lower()
|
ssl_vals['attribs'][p][x] = getpass.getpass(
|
||||||
if ssl_vals['attribs'][p][x] not in \
|
('{0}\n{1}').format(
|
||||||
_attribs[p][x]['options']:
|
_strs['attribs'][p][x]['text'],
|
||||||
print(('\nInvalid selection; setting default '
|
_strs['attribs'][p][x]['prompt'])
|
||||||
'({0}).').format(_attribs[p][x]['default']))
|
)
|
||||||
ssl_vals['attribs'][p][x] = \
|
if ssl_vals['attribs'][p][x] == '':
|
||||||
_attribs[p][x]['default']
|
ssl_vals['attribs'][p][x] = 'none'
|
||||||
_subject = {'countryName': {'text': ('the 2-letter country '
|
else:
|
||||||
'abbreviation (must conform to '
|
ssl_vals['attribs'][p][x] = (input(
|
||||||
'ISO3166 ALPHA-2)?\nCountry '
|
('\n{0}\n\n\t{1}\n\n{2}').format(
|
||||||
'code: '),
|
_strs['attribs'][p][x]['text'],
|
||||||
'check': 'func',
|
'\n\t'.join(
|
||||||
'func': valid().country_abbrev},
|
_strs['attribs'][p][x]['options']),
|
||||||
'localityName': {'text': ('the city/town/borough/locality '
|
_strs['attribs'][p][x]['prompt']))
|
||||||
'name?\nLocality: '),
|
).strip().lower()
|
||||||
'check': None},
|
if ssl_vals['attribs'][p][x] not in \
|
||||||
'stateOrProvinceName': {'text': ('the state/region '
|
_strs['attribs'][p][x]['options']:
|
||||||
'name (full string)?'
|
print(
|
||||||
'\nRegion: '),
|
('\nInvalid selection; setting default '
|
||||||
'check': None},
|
'({0}).').format(
|
||||||
'organization': {'text': ('your organization\'s name?'
|
_strs['attribs'][p][x]['default']
|
||||||
'\nOrganization: '),
|
)
|
||||||
'check': None},
|
)
|
||||||
'organizationalUnitName': {'text': ('your department/role/'
|
ssl_vals['attribs'][p][x] = \
|
||||||
'team/department name?'
|
_strs['attribs'][p][x]['default']
|
||||||
'\nOrganizational '
|
for s in _strs['subject']:
|
||||||
'Unit: '),
|
|
||||||
'check': None},
|
|
||||||
'emailAddress': {'text': ('the email address to be '
|
|
||||||
'associated with this '
|
|
||||||
'certificate/PKI object?\n'
|
|
||||||
'Email: '),
|
|
||||||
'check': 'func',
|
|
||||||
'func': valid().email}}
|
|
||||||
for s in _subject:
|
|
||||||
ssl_vals['subject'][s] = None
|
ssl_vals['subject'][s] = None
|
||||||
for s in _subject:
|
for s in _strs['subject']:
|
||||||
while not ssl_vals['subject'][s]:
|
while not ssl_vals['subject'][s]:
|
||||||
_input = (input(
|
_input = (input(
|
||||||
('\nWhat is {0}').format(_subject[s]['text'])
|
('\nWhat is {0}').format(
|
||||||
|
_strs['subject'][s]['text'])
|
||||||
)).strip()
|
)).strip()
|
||||||
_chk = _subject[s]['check']
|
|
||||||
if _chk:
|
|
||||||
if _chk == 'func':
|
|
||||||
_chk = _subject[s]['func'](_input)
|
|
||||||
if not _chk:
|
|
||||||
print('Invalid value; retrying.')
|
|
||||||
continue
|
|
||||||
print()
|
print()
|
||||||
|
if s in _checks['subject']:
|
||||||
|
if not _checks['subject'][s](_input):
|
||||||
|
print('Invalid entry; try again.')
|
||||||
|
ssl_vals['subject'][s] = None
|
||||||
|
continue
|
||||||
ssl_vals['subject'][s] = _input
|
ssl_vals['subject'][s] = _input
|
||||||
_url = transform().url_to_dict(cn_url, no_None = True)
|
_url = transform().url_to_dict(cn_url, no_None = True)
|
||||||
ssl_vals['subject']['commonName'] = _url['host']
|
ssl_vals['subject']['commonName'] = _url['host']
|
||||||
@ -594,6 +616,11 @@ class valid(object):
|
|||||||
return(False)
|
return(False)
|
||||||
return()
|
return()
|
||||||
|
|
||||||
|
def nonempty_str(self, str_in):
|
||||||
|
if str_in.strip() == '':
|
||||||
|
return(False)
|
||||||
|
return(True)
|
||||||
|
|
||||||
def password(self, passwd):
|
def password(self, passwd):
|
||||||
# https://en.wikipedia.org/wiki/ASCII#Printable_characters
|
# https://en.wikipedia.org/wiki/ASCII#Printable_characters
|
||||||
# https://serverfault.com/a/513243/103116
|
# https://serverfault.com/a/513243/103116
|
||||||
|
@ -156,7 +156,7 @@
|
|||||||
publish="no"
|
publish="no"
|
||||||
prompt_passphrase="no">
|
prompt_passphrase="no">
|
||||||
<!-- The below is only used if we are generating a key (i.e. keyid="none"). -->
|
<!-- The below is only used if we are generating a key (i.e. keyid="none"). -->
|
||||||
<key type="rsa" keysize="4096" expire="0">
|
<key algo="rsa" keysize="4096" expire="0">
|
||||||
<name>{xpath%../../../../meta/dev/author/text()}</name>
|
<name>{xpath%../../../../meta/dev/author/text()}</name>
|
||||||
<email>{xpath%../../../../meta/dev/email/text()}</email>
|
<email>{xpath%../../../../meta/dev/email/text()}</email>
|
||||||
<comment>for {xpath%../../../../meta/names/pname/text()} [autogenerated] | {xpath%../../../../meta/uri/text()} | {xpath%../../../../meta/desc/text()}</comment>
|
<comment>for {xpath%../../../../meta/names/pname/text()} [autogenerated] | {xpath%../../../../meta/uri/text()} | {xpath%../../../../meta/desc/text()}</comment>
|
||||||
|
Loading…
Reference in New Issue
Block a user