v26.10.1 正式版
修复部分问题,优化性能,进一步推进类型系统
This commit is contained in:
1 parent
48eec484b7
commit
8ce4239452
89 files changed
+5808
-884
No files matched your search
@@ -0,0 +1,179 @@
|
||||
#ifdef _WIN32
|
||||
#define _WIN32_WINNT 0x0600
|
||||
#include <windows.h>
|
||||
#include <sddl.h>
|
||||
#include <stdio.h>
|
||||
#else
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <sys/stat.h>
|
||||
#include <errno.h>
|
||||
#include <stdio.h>
|
||||
#endif
|
||||
#include <string.h>
|
||||
|
||||
/* 创建、读取和清理仅本机提升身份可访问的认证凭据。 */
|
||||
|
||||
#ifdef _WIN32
|
||||
/* 检查当前提升令牌的管理员成员资格。 */
|
||||
int chusql_sudo_privileged(void) {
|
||||
SID_IDENTIFIER_AUTHORITY authority = SECURITY_NT_AUTHORITY;
|
||||
PSID administrators = NULL;
|
||||
BOOL member = FALSE;
|
||||
if (!AllocateAndInitializeSid(&authority, 2, SECURITY_BUILTIN_DOMAIN_RID,
|
||||
DOMAIN_ALIAS_RID_ADMINS, 0, 0, 0, 0, 0, 0, &administrators)) return 0;
|
||||
if (!CheckTokenMembership(NULL, administrators, &member)) member = FALSE;
|
||||
FreeSid(administrators);
|
||||
return member != FALSE;
|
||||
}
|
||||
|
||||
/* 生成系统保护目录中的端口专属路径。 */
|
||||
static int credential_path(int port, wchar_t *directory, wchar_t *file) {
|
||||
wchar_t base[MAX_PATH];
|
||||
UINT length = GetSystemDirectoryW(base, MAX_PATH);
|
||||
if (!length || length >= MAX_PATH - 80 || port < 1 || port > 65535) return ERROR_INVALID_PARAMETER;
|
||||
swprintf(directory, MAX_PATH, L"%ls\\config\\ChuSQL-Sudo-%d", base, port);
|
||||
swprintf(file, MAX_PATH, L"%ls\\credential", directory);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* 排除重解析目录与凭据文件。 */
|
||||
static int plain_path(const wchar_t *path, int directory) {
|
||||
DWORD attributes = GetFileAttributesW(path);
|
||||
return attributes != INVALID_FILE_ATTRIBUTES && !(attributes & FILE_ATTRIBUTE_REPARSE_POINT)
|
||||
&& (!!(attributes & FILE_ATTRIBUTE_DIRECTORY) == directory);
|
||||
}
|
||||
|
||||
/* 用管理员与系统专属访问列表创建凭据。 */
|
||||
int chusql_sudo_create(int port, const char *secret) {
|
||||
wchar_t directory[MAX_PATH], file[MAX_PATH];
|
||||
PSECURITY_DESCRIPTOR descriptor = NULL;
|
||||
SECURITY_ATTRIBUTES attributes = {sizeof(SECURITY_ATTRIBUTES), NULL, FALSE};
|
||||
DWORD written = 0, error;
|
||||
HANDLE handle;
|
||||
if (!chusql_sudo_privileged()) return ERROR_ACCESS_DENIED;
|
||||
error = credential_path(port, directory, file);
|
||||
if (error) return (int)error;
|
||||
if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
L"D:P(A;OICI;FA;;;BA)(A;OICI;FA;;;SY)", SDDL_REVISION_1, &descriptor, NULL)) return (int)GetLastError();
|
||||
attributes.lpSecurityDescriptor = descriptor;
|
||||
if (!CreateDirectoryW(directory, &attributes)) {
|
||||
error = GetLastError(); LocalFree(descriptor); return (int)error;
|
||||
}
|
||||
handle = CreateFileW(file, GENERIC_WRITE, FILE_SHARE_READ, &attributes, CREATE_NEW,
|
||||
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, NULL);
|
||||
LocalFree(descriptor);
|
||||
if (handle == INVALID_HANDLE_VALUE) {
|
||||
error = GetLastError(); RemoveDirectoryW(directory); return (int)error;
|
||||
}
|
||||
error = 0;
|
||||
if (!WriteFile(handle, secret, 64, &written, NULL) || written != 64 || !FlushFileBuffers(handle))
|
||||
error = GetLastError() ? GetLastError() : ERROR_WRITE_FAULT;
|
||||
if (!CloseHandle(handle) && !error) error = GetLastError();
|
||||
if (error) { DeleteFileW(file); RemoveDirectoryW(directory); }
|
||||
return (int)error;
|
||||
}
|
||||
|
||||
/* 从系统保护目录读取本机凭据。 */
|
||||
int chusql_sudo_read(int port, char *secret) {
|
||||
wchar_t directory[MAX_PATH], file[MAX_PATH];
|
||||
DWORD received = 0, error;
|
||||
LARGE_INTEGER size;
|
||||
HANDLE handle;
|
||||
if (!chusql_sudo_privileged()) return ERROR_ACCESS_DENIED;
|
||||
error = credential_path(port, directory, file);
|
||||
if (error) return (int)error;
|
||||
if (!plain_path(directory, 1) || !plain_path(file, 0)) return ERROR_ACCESS_DENIED;
|
||||
handle = CreateFileW(file, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING,
|
||||
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, NULL);
|
||||
if (handle == INVALID_HANDLE_VALUE) return (int)GetLastError();
|
||||
error = 0;
|
||||
if (!GetFileSizeEx(handle, &size) || size.QuadPart != 64
|
||||
|| !ReadFile(handle, secret, 64, &received, NULL) || received != 64) error = ERROR_INVALID_DATA;
|
||||
if (!CloseHandle(handle) && !error) error = GetLastError();
|
||||
return (int)error;
|
||||
}
|
||||
|
||||
/* 删除本次服务启动创建的凭据目录。 */
|
||||
int chusql_sudo_remove(int port) {
|
||||
wchar_t directory[MAX_PATH], file[MAX_PATH];
|
||||
int error = credential_path(port, directory, file);
|
||||
if (error) return error;
|
||||
if (!chusql_sudo_privileged()) return ERROR_ACCESS_DENIED;
|
||||
if (!DeleteFileW(file)) return (int)GetLastError();
|
||||
return RemoveDirectoryW(directory) ? 0 : (int)GetLastError();
|
||||
}
|
||||
#else
|
||||
/* 检查当前进程的有效 root 身份。 */
|
||||
int chusql_sudo_privileged(void) { return geteuid() == 0; }
|
||||
|
||||
/* 打开 root 专属目录并验证所有权与权限。 */
|
||||
static int credential_directory(int port) {
|
||||
char path[96];
|
||||
struct stat status;
|
||||
int fd;
|
||||
if (port < 1 || port > 65535) { errno = EINVAL; return -1; }
|
||||
snprintf(path, sizeof(path), "/var/run/chusql-sudo-%d", port);
|
||||
fd = open(path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
if (fd < 0) return -1;
|
||||
if (fstat(fd, &status) || status.st_uid != 0 || (status.st_mode & 0777) != 0700) {
|
||||
close(fd); errno = EACCES; return -1;
|
||||
}
|
||||
return fd;
|
||||
}
|
||||
|
||||
/* 在 root 专属目录中独占创建凭据。 */
|
||||
int chusql_sudo_create(int port, const char *secret) {
|
||||
char path[96];
|
||||
int dir, fd, error = 0;
|
||||
if (!chusql_sudo_privileged()) return EACCES;
|
||||
if (port < 1 || port > 65535) return EINVAL;
|
||||
snprintf(path, sizeof(path), "/var/run/chusql-sudo-%d", port);
|
||||
if (mkdir(path, 0700)) return errno;
|
||||
dir = credential_directory(port);
|
||||
if (dir < 0) { error = errno; rmdir(path); return error; }
|
||||
fd = openat(dir, "credential", O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600);
|
||||
if (fd < 0) error = errno;
|
||||
else {
|
||||
if (write(fd, secret, 64) != 64 || fsync(fd)) error = errno ? errno : EIO;
|
||||
if (close(fd) && !error) error = errno;
|
||||
}
|
||||
if (error) unlinkat(dir, "credential", 0);
|
||||
if (close(dir) && !error) error = errno;
|
||||
if (error) rmdir(path);
|
||||
return error;
|
||||
}
|
||||
|
||||
/* 验证 root 文件所有权后读取凭据。 */
|
||||
int chusql_sudo_read(int port, char *secret) {
|
||||
struct stat status;
|
||||
int dir, fd, error = 0;
|
||||
if (!chusql_sudo_privileged()) return EACCES;
|
||||
dir = credential_directory(port);
|
||||
if (dir < 0) return errno;
|
||||
fd = openat(dir, "credential", O_RDONLY | O_NOFOLLOW | O_CLOEXEC);
|
||||
if (fd < 0) error = errno;
|
||||
else {
|
||||
if (fstat(fd, &status) || !S_ISREG(status.st_mode) || status.st_uid != 0
|
||||
|| (status.st_mode & 0777) != 0600 || status.st_size != 64 || status.st_nlink != 1) error = EACCES;
|
||||
else if (read(fd, secret, 64) != 64) error = errno ? errno : EIO;
|
||||
if (close(fd) && !error) error = errno;
|
||||
}
|
||||
if (close(dir) && !error) error = errno;
|
||||
return error;
|
||||
}
|
||||
|
||||
/* 删除本次服务启动创建的凭据目录。 */
|
||||
int chusql_sudo_remove(int port) {
|
||||
char path[96];
|
||||
int dir, error = 0;
|
||||
if (!chusql_sudo_privileged()) return EACCES;
|
||||
dir = credential_directory(port);
|
||||
if (dir < 0) return errno;
|
||||
if (unlinkat(dir, "credential", 0)) error = errno;
|
||||
if (close(dir) && !error) error = errno;
|
||||
snprintf(path, sizeof(path), "/var/run/chusql-sudo-%d", port);
|
||||
if (!error && rmdir(path)) error = errno;
|
||||
return error;
|
||||
}
|
||||
#endif
|
||||
@@ -21,6 +21,7 @@ library
|
||||
ChuSQL.Interface.Protocol
|
||||
ChuSQL.Interface.RateLimit
|
||||
ChuSQL.Interface.Session
|
||||
ChuSQL.Interface.Sudo
|
||||
ChuSQL.Interface.Settings
|
||||
ChuSQL.Interface.TOML
|
||||
build-depends: base >= 4.14 && < 5
|
||||
@@ -36,5 +37,8 @@ library
|
||||
, scientific
|
||||
, text
|
||||
, time
|
||||
c-sources: cbits/sudo_credential.c
|
||||
if os(windows)
|
||||
extra-libraries: advapi32
|
||||
default-language: Haskell2010
|
||||
ghc-options: -Wall
|
||||
@@ -34,6 +34,9 @@ module ChuSQL.Interface.Actions (
|
||||
|
||||
import ChuSQL.Core.Model (Column (..), ColumnType (..), Value (..), integerType, numericType, parseColumnType, plainColumn, typeName)
|
||||
import qualified ChuSQL.Core.Model as M
|
||||
import ChuSQL.Core.Runtime
|
||||
import ChuSQL.Core.Protocol (valueFromJSON)
|
||||
import Data.Aeson.Types (parseEither)
|
||||
import qualified Data.Aeson as A
|
||||
import Data.Char (isAsciiLower, isAsciiUpper, isDigit)
|
||||
import Data.List (intercalate)
|
||||
@@ -88,24 +91,50 @@ columnTypeOf = fmap plainColumn . parseColumnType . T.unpack . T.strip
|
||||
|
||||
-- | 本地类型转线上名字
|
||||
columnTypeName :: Column -> Text
|
||||
columnTypeName = T.pack . typeName . columnType
|
||||
columnTypeName column = T.pack $ case columnType column of
|
||||
CRuntime tid -> renderTypeExpr (typeExpression tid)
|
||||
ty -> typeName ty
|
||||
|
||||
-- | 字符串字面量转义:单引号翻倍(引擎的词法器就是这么折回去的)
|
||||
escapeStringLiteral :: String -> String
|
||||
escapeStringLiteral = concatMap (\c -> if c == '\'' then "''" else [c])
|
||||
|
||||
-- | 值转 SQL 字面量
|
||||
sqlLiteral :: Value -> String
|
||||
sqlLiteral VNull = "NULL"
|
||||
sqlLiteral (VInt n) = show n
|
||||
sqlLiteral (VFloat d) = show d
|
||||
sqlLiteral (VStr s) = "'" ++ escapeStringLiteral s ++ "'"
|
||||
sqlLiteral (VBool True) = "TRUE"
|
||||
sqlLiteral (VBool False) = "FALSE"
|
||||
sqlLiteral :: Value -> Either String String
|
||||
sqlLiteral VNull = Right "NULL"
|
||||
sqlLiteral (VInt n) = Right (show n)
|
||||
sqlLiteral (VFloat d)
|
||||
| isNaN d || isInfinite d = Left "SQL floating literal must be finite"
|
||||
| otherwise = Right (show d)
|
||||
sqlLiteral (VStr s) = Right ("'" ++ escapeStringLiteral s ++ "'")
|
||||
sqlLiteral (VBool True) = Right "TRUE"
|
||||
sqlLiteral (VBool False) = Right "FALSE"
|
||||
sqlLiteral (VRuntime tid value) = do
|
||||
validateValue builtinTypes tid value
|
||||
runtimeLiteral (typeExpression tid) value
|
||||
|
||||
-- | 渲染类型化构造器的 SQL 字面量
|
||||
runtimeLiteral :: TypeExpr -> RuntimeValue -> Either String String
|
||||
runtimeLiteral _ (RInt n) = Right (show n)
|
||||
runtimeLiteral _ (RDouble n) = Right (show n)
|
||||
runtimeLiteral _ (RBool b) = Right (if b then "TRUE" else "FALSE")
|
||||
runtimeLiteral _ (RString text) = Right ("'" ++ escapeStringLiteral text ++ "'")
|
||||
runtimeLiteral (TypeApply (TypeConstructorId 5) [child]) (RList values) = do
|
||||
items <- mapM (runtimeLiteral child) values
|
||||
Right ("LIST<" ++ renderTypeExpr child ++ ">(" ++ intercalate ", " items ++ ")")
|
||||
runtimeLiteral (TypeApply (TypeConstructorId 6) [child]) (RMaybe value) = do
|
||||
item <- maybe (Right "") (runtimeLiteral child) value
|
||||
Right ("MAYBE<" ++ renderTypeExpr child ++ ">(" ++ item ++ ")")
|
||||
runtimeLiteral (TypeApply (TypeConstructorId 7) fields) (RTuple values)
|
||||
| length fields == length values = do
|
||||
items <- sequence (zipWith runtimeLiteral fields values)
|
||||
Right ("TUPLE<" ++ intercalate ", " (map renderTypeExpr fields) ++ ">(" ++ intercalate ", " items ++ ")")
|
||||
runtimeLiteral _ _ = Left "runtime value does not match its type expression"
|
||||
|
||||
-- | JSON 值按列类型转成引擎的值;类型对不上就报错
|
||||
coerceValue :: Column -> A.Value -> Either String Value
|
||||
coerceValue col v = case v of
|
||||
A.Object _ -> parseEither valueFromJSON v >>= M.coerceValue t
|
||||
A.Null -> Right VNull
|
||||
A.Bool b -> M.coerceValue t (VBool b)
|
||||
A.Number n -> case floatingOrInteger n :: Either Double Integer of
|
||||
@@ -186,13 +215,14 @@ insertRowSql table row = do
|
||||
tbl <- checkIdent "table" table
|
||||
cols <- requireNonEmpty "values" row
|
||||
names <- mapM (checkIdent "column" . T.pack . fst) cols
|
||||
literals <- mapM (sqlLiteral . snd) cols
|
||||
pure
|
||||
( "INSERT INTO "
|
||||
++ T.unpack tbl
|
||||
++ " ("
|
||||
++ intercalate ", " (map T.unpack names)
|
||||
++ ") VALUES ("
|
||||
++ intercalate ", " (map (sqlLiteral . snd) cols)
|
||||
++ intercalate ", " literals
|
||||
++ ")"
|
||||
)
|
||||
|
||||
@@ -208,14 +238,15 @@ insertRowsSql table rows = do
|
||||
let widths = map length rs
|
||||
if any (/= length first) widths
|
||||
then Left "all rows must have the same columns"
|
||||
else
|
||||
else do
|
||||
literals <- mapM (mapM (sqlLiteral . snd)) rs
|
||||
pure
|
||||
( "INSERT INTO "
|
||||
++ T.unpack tbl
|
||||
++ " ("
|
||||
++ intercalate ", " (map T.unpack names)
|
||||
++ ") VALUES "
|
||||
++ intercalate ", " ["(" ++ intercalate ", " (map (sqlLiteral . snd) r) ++ ")" | r <- rs]
|
||||
++ intercalate ", " ["(" ++ intercalate ", " values ++ ")" | values <- literals]
|
||||
)
|
||||
|
||||
-- | 构造按 id 改行的 UPDATE 语句
|
||||
@@ -223,8 +254,14 @@ updateRowSql :: Text -> Int -> [(String, Value)] -> Either String String
|
||||
updateRowSql table key assigns = do
|
||||
tbl <- checkIdent "table" table
|
||||
cols <- requireNonEmpty "values" assigns
|
||||
sets <- mapM (\(n, v) -> (\c -> T.unpack c ++ " = " ++ sqlLiteral v) <$> checkIdent "column" (T.pack n)) cols
|
||||
sets <- mapM assignment cols
|
||||
pure ("UPDATE " ++ T.unpack tbl ++ " SET " ++ intercalate ", " sets ++ " WHERE id = " ++ show key)
|
||||
where
|
||||
-- | 校验列名并编码赋值
|
||||
assignment (name, value) = do
|
||||
column <- checkIdent "column" (T.pack name)
|
||||
literal <- sqlLiteral value
|
||||
Right (T.unpack column ++ " = " ++ literal)
|
||||
|
||||
-- | 按 id 删一行,存储层原地删
|
||||
deleteRowSql :: Text -> Int -> Either String String
|
||||
@@ -251,7 +288,8 @@ whereClause filters = case filters of
|
||||
-- | 一个等值条件
|
||||
term (column, value) = do
|
||||
col <- checkIdent "column" column
|
||||
pure (T.unpack col ++ " = " ++ sqlLiteral value)
|
||||
literal <- sqlLiteral value
|
||||
pure (T.unpack col ++ " = " ++ literal)
|
||||
|
||||
-- | 拼 ORDER BY 子句,无排序给空串
|
||||
orderClause :: Maybe (Text, Bool) -> Either String String
|
||||
@@ -309,6 +347,8 @@ roleName name
|
||||
-- | 授权对象:`*`,或长度不超过 128 的裸表名/`库.表`
|
||||
grantObject :: Text -> Either String Text
|
||||
grantObject object
|
||||
| "database:" `T.isPrefixOf` T.toLower object = ("DATABASE " <>) <$> checkIdent "database" (T.drop 9 object)
|
||||
| ".*" `T.isSuffixOf` object = (<> ".*") <$> checkIdent "database" (T.dropEnd 2 object)
|
||||
| T.strip object == "*" = Right "*"
|
||||
| T.null object || T.length object > 128 = Left "object name is empty or too long"
|
||||
| not (T.all allowed object) = Left ("object name may only contain ASCII letters, digits, dot, dash or underscore: " ++ T.unpack object)
|
||||
|
||||
@@ -186,12 +186,13 @@ data ServerConfig = ServerConfig
|
||||
, scPort :: Int
|
||||
, scMaxMessage :: Int
|
||||
, scMaxRows :: Int
|
||||
, scSudoUser :: Text
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- | 默认本机 7777,行 1 MiB,最多 1000 行
|
||||
defaultServerConfig :: ServerConfig
|
||||
defaultServerConfig = ServerConfig "127.0.0.1" 7777 (1024 * 1024) 1000
|
||||
defaultServerConfig = ServerConfig "127.0.0.1" 7777 (1024 * 1024) 1000 ""
|
||||
|
||||
-- | 读 [server] 段键,缺的用默认
|
||||
loadServerConfigAt :: FilePath -> IO ServerConfig
|
||||
@@ -211,4 +212,5 @@ loadServerConfigAt path = do
|
||||
, scPort = intOf "port" (scPort defaultServerConfig)
|
||||
, scMaxMessage = intOf "max_message" (scMaxMessage defaultServerConfig)
|
||||
, scMaxRows = intOf "max_rows" (scMaxRows defaultServerConfig)
|
||||
, scSudoUser = T.toLower (T.pack (textOf "sudo_auth_user" ""))
|
||||
}
|
||||
@@ -5,6 +5,7 @@ module ChuSQL.Interface.Link (
|
||||
connectClient,
|
||||
closeClient,
|
||||
clientLogin,
|
||||
clientSudoLogin,
|
||||
clientQuery,
|
||||
clientPing,
|
||||
clientCatalog,
|
||||
@@ -15,6 +16,7 @@ module ChuSQL.Interface.Link (
|
||||
clientReloadPolicy,
|
||||
) where
|
||||
|
||||
import ChuSQL.Interface.Sudo (SudoCredential, sudoProof, isLocalPeer)
|
||||
import ChuSQL.Core.Protocol (Account, QueryResult (..), TableInfo (..))
|
||||
import ChuSQL.Interface.Protocol (
|
||||
ClientRequest (..),
|
||||
@@ -40,6 +42,7 @@ import Network.Socket (
|
||||
defaultHints,
|
||||
defaultProtocol,
|
||||
getAddrInfo,
|
||||
getPeerName,
|
||||
socket,
|
||||
socketToHandle,
|
||||
)
|
||||
@@ -52,17 +55,18 @@ import System.IO.Error (isEOFError)
|
||||
data Client = Client
|
||||
{ clHandle :: Handle
|
||||
, clLock :: MVar ()
|
||||
, clLocal :: Bool
|
||||
}
|
||||
|
||||
-- | 连上 server 并对协议版本,失败给人话错误
|
||||
connectClient :: Text -> Int -> IO (Either Text Client)
|
||||
connectClient host port = do
|
||||
opened <- try (openConnection host port) :: IO (Either IOException Handle)
|
||||
opened <- try (openConnection host port) :: IO (Either IOException (Handle, Bool))
|
||||
case opened of
|
||||
Left err -> pure (Left ("cannot reach the server at " <> T.pack (address host port) <> ": " <> T.pack (describe err)))
|
||||
Right handle -> do
|
||||
Right (handle, local) -> do
|
||||
lock <- newMVar ()
|
||||
let client = Client handle lock
|
||||
let client = Client handle lock local
|
||||
greeted <- request client (ReqHello protocolVersion)
|
||||
case greeted of
|
||||
Right (RespHello version)
|
||||
@@ -117,6 +121,26 @@ clientLogin client user password = do
|
||||
Right (RespLogin _ admin) -> pure (Right admin)
|
||||
Right other -> pure (failed "login" other)
|
||||
|
||||
-- 验证一次性挑战并用本机身份登录。
|
||||
clientSudoLogin :: Client -> SudoCredential -> Text -> IO (Either Text Bool)
|
||||
clientSudoLogin client _ _ | not (clLocal client) = pure (Left "sudo authentication requires a local server")
|
||||
clientSudoLogin client credential user = withMVar (clLock client) $ \_ -> do
|
||||
outcome <- try authenticate :: IO (Either IOException (Either Text Bool))
|
||||
pure $ either (Left . T.pack . describe) id outcome
|
||||
where
|
||||
-- 在同一连接连续交换挑战与证明。
|
||||
authenticate = do
|
||||
challenge <- exchange (clHandle client) ReqSudoChallenge
|
||||
case challenge of
|
||||
Right (RespSudoChallenge nonce) | T.length nonce == 64 -> do
|
||||
answer <- exchange (clHandle client) (ReqSudoLogin user (sudoProof credential nonce user))
|
||||
pure $ case answer of
|
||||
Left message -> Left message
|
||||
Right (RespLogin _ admin) -> Right admin
|
||||
Right other -> failed "sudo login" other
|
||||
Right other -> pure (failed "sudo challenge" other)
|
||||
Left message -> pure (Left message)
|
||||
|
||||
-- | 跑一条语句,拿回结果集
|
||||
clientQuery :: Client -> Text -> IO (Either Text QueryResult)
|
||||
clientQuery client sql = do
|
||||
@@ -196,7 +220,7 @@ failed what response = case response of
|
||||
_ -> Left ("unexpected response to " <> what)
|
||||
|
||||
-- | 建连接(套接字转成文本模式的行缓冲句柄)
|
||||
openConnection :: Text -> Int -> IO Handle
|
||||
openConnection :: Text -> Int -> IO (Handle, Bool)
|
||||
openConnection host port = do
|
||||
let hints = defaultHints{addrSocketType = Stream}
|
||||
infos <- getAddrInfo (Just hints) (Just (T.unpack host)) (Just (show port))
|
||||
@@ -205,10 +229,11 @@ openConnection host port = do
|
||||
(info : _) -> do
|
||||
sock <- socket (addrFamily info) Stream defaultProtocol
|
||||
connect sock (addrAddress info)
|
||||
peer <- getPeerName sock
|
||||
handle <- socketToHandle sock ReadWriteMode
|
||||
hSetBuffering handle LineBuffering
|
||||
hSetNewlineMode handle noNewlineTranslation
|
||||
pure handle
|
||||
pure (handle, isLocalPeer peer)
|
||||
|
||||
-- | 把主机端口拼成地址字样
|
||||
address :: Text -> Int -> String
|
||||
|
||||
@@ -91,6 +91,8 @@ serverName = "chusql-server"
|
||||
data ClientRequest
|
||||
= ReqHello Int
|
||||
| ReqLogin Text Text
|
||||
| ReqSudoChallenge
|
||||
| ReqSudoLogin Text Text
|
||||
| ReqQuery Text
|
||||
| ReqPing
|
||||
| ReqQuit
|
||||
@@ -109,6 +111,7 @@ data WireRequest = WireRequest
|
||||
, wrProtocol :: Maybe Int
|
||||
, wrUser :: Maybe Text
|
||||
, wrPassword :: Maybe Text
|
||||
, wrProof :: Maybe Text
|
||||
, wrSql :: Maybe Text
|
||||
, wrRequest :: Maybe Value
|
||||
}
|
||||
@@ -121,6 +124,7 @@ instance A.FromJSON WireRequest where
|
||||
<*> o A..:? "protocol"
|
||||
<*> o A..:? "user"
|
||||
<*> o A..:? "password"
|
||||
<*> o A..:? "proof"
|
||||
<*> o A..:? "sql"
|
||||
<*> o A..:? "request"
|
||||
|
||||
@@ -133,6 +137,10 @@ decodeRequest raw = case A.eitherDecodeStrict raw of
|
||||
"login" -> case (wrUser wire, wrPassword wire) of
|
||||
(Just user, Just password) -> Right (ReqLogin user password)
|
||||
_ -> Left "login needs both user and password"
|
||||
"sudo_challenge" -> Right ReqSudoChallenge
|
||||
"sudo_login" -> case (wrUser wire, wrProof wire) of
|
||||
(Just user, Just proof) -> Right (ReqSudoLogin user proof)
|
||||
_ -> Left "sudo_login needs both user and proof"
|
||||
"query" -> maybe (Left "query needs sql") (Right . ReqQuery) (wrSql wire)
|
||||
"storage" -> maybe (Left "storage needs request") (Right . ReqStorage) (wrRequest wire)
|
||||
"catalog" -> Right ReqCatalog
|
||||
@@ -154,6 +162,8 @@ requestJson :: ClientRequest -> Value
|
||||
requestJson request = case request of
|
||||
ReqHello version -> object ["method" .= ("hello" :: Text), "protocol" .= version]
|
||||
ReqLogin user password -> object ["method" .= ("login" :: Text), "user" .= user, "password" .= password]
|
||||
ReqSudoChallenge -> object ["method" .= ("sudo_challenge" :: Text)]
|
||||
ReqSudoLogin user proof -> object ["method" .= ("sudo_login" :: Text), "user" .= user, "proof" .= proof]
|
||||
ReqQuery sql -> object ["method" .= ("query" :: Text), "sql" .= sql]
|
||||
ReqPing -> object ["method" .= ("ping" :: Text)]
|
||||
ReqQuit -> object ["method" .= ("quit" :: Text)]
|
||||
@@ -169,6 +179,7 @@ requestJson request = case request of
|
||||
data ServerResponse
|
||||
= RespHello Int
|
||||
| RespLogin Text Bool
|
||||
| RespSudoChallenge Text
|
||||
| RespResult QueryResult
|
||||
| RespPong
|
||||
| RespBye
|
||||
@@ -190,6 +201,7 @@ responseJson :: ServerResponse -> Value
|
||||
responseJson response = case response of
|
||||
RespHello version -> object ["status" .= ("hello" :: Text), "protocol" .= version, "server" .= serverName]
|
||||
RespLogin user admin -> object ["status" .= ("ok" :: Text), "user" .= user, "admin" .= admin]
|
||||
RespSudoChallenge challenge -> object ["status" .= ("sudo_challenge" :: Text), "challenge" .= challenge]
|
||||
RespPong -> object ["status" .= ("pong" :: Text)]
|
||||
RespBye -> object ["status" .= ("bye" :: Text)]
|
||||
RespError code message -> object ["status" .= ("error" :: Text), "code" .= code, "message" .= message]
|
||||
@@ -216,6 +228,7 @@ decodeResponse raw = case A.eitherDecode raw of
|
||||
decodeStatus status fields = case status of
|
||||
"hello" -> Right (RespHello (intOf "protocol" fields))
|
||||
"ok" -> Right (RespLogin (textOf "user" fields) (boolOf "admin" fields))
|
||||
"sudo_challenge" -> fromField "challenge" RespSudoChallenge fields
|
||||
"pong" -> Right RespPong
|
||||
"bye" -> Right RespBye
|
||||
"error" -> Right (RespError (textOf "code" fields) (textOf "message" fields))
|
||||
|
||||
@@ -7,6 +7,7 @@ module ChuSQL.Interface.Session (
|
||||
sessionDatabase,
|
||||
isPlainIdentifier,
|
||||
authenticateSession,
|
||||
authenticateSudoSession,
|
||||
switchDatabase,
|
||||
runStatement,
|
||||
catalog,
|
||||
@@ -22,9 +23,11 @@ import ChuSQL.Interface.Link (
|
||||
clientCatalog,
|
||||
clientDatabases,
|
||||
clientLogin,
|
||||
clientSudoLogin,
|
||||
clientQuery,
|
||||
clientRoles,
|
||||
)
|
||||
import ChuSQL.Interface.Sudo (SudoCredential)
|
||||
import ChuSQL.Interface.Protocol (RoleView)
|
||||
import Data.Char (isAlpha, isAlphaNum)
|
||||
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
|
||||
@@ -68,6 +71,14 @@ authenticateSession session user password = do
|
||||
Left message -> pure (Left message)
|
||||
Right admin -> writeIORef (ssAdmin session) admin >> pure (Right ())
|
||||
|
||||
-- 用受保护本机凭据登录并记录身份。
|
||||
authenticateSudoSession :: Session -> SudoCredential -> Text -> IO (Either Text ())
|
||||
authenticateSudoSession session credential user = do
|
||||
answer <- clientSudoLogin (ssClient session) credential user
|
||||
case answer of
|
||||
Left message -> pure (Left message)
|
||||
Right admin -> writeIORef (ssAdmin session) admin >> pure (Right ())
|
||||
|
||||
-- | 换库:名字先按解析器规矩校验,由 server 执行 USE
|
||||
switchDatabase :: Session -> Text -> IO (Either Text ())
|
||||
switchDatabase session rawName
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
{-# LANGUAGE ForeignFunctionInterface #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
|
||||
module ChuSQL.Interface.Sudo (
|
||||
SudoCredential, createSudoCredential, removeSudoCredential,
|
||||
readSudoCredential, sudoProof, verifySudoProof, sudoChallenge, sudoPrivileged,
|
||||
newSudoCredential, isLocalPeer, validSudoChallenge,
|
||||
) where
|
||||
|
||||
import Crypto.Hash (SHA256)
|
||||
import Crypto.MAC.HMAC (HMAC, hmac)
|
||||
import Crypto.Random (getRandomBytes)
|
||||
import qualified Data.ByteArray as BA
|
||||
import Data.ByteArray.Encoding (Base (Base16), convertToBase)
|
||||
import qualified Data.ByteString as BS
|
||||
import Data.Char (isHexDigit)
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import qualified Data.Text.Encoding as TE
|
||||
import Data.Time.Clock (UTCTime, diffUTCTime)
|
||||
import Foreign.C.String (CString)
|
||||
import Foreign.C.Types (CInt (..))
|
||||
import Foreign.Marshal.Alloc (allocaBytes)
|
||||
import Network.Socket (SockAddr (..), hostAddressToTuple, hostAddress6ToTuple)
|
||||
|
||||
-- 用系统保护凭据与一次性挑战验证本机提升身份。
|
||||
|
||||
newtype SudoCredential = SudoCredential BS.ByteString
|
||||
|
||||
-- 隐藏本机凭据的诊断内容。
|
||||
instance Show SudoCredential where
|
||||
show _ = "SudoCredential <redacted>"
|
||||
|
||||
-- 检查当前进程的操作系统提升身份。
|
||||
foreign import ccall unsafe "chusql_sudo_privileged" nativePrivileged :: IO CInt
|
||||
-- 创建操作系统保护的本机凭据。
|
||||
foreign import ccall safe "chusql_sudo_create" nativeCreate :: CInt -> CString -> IO CInt
|
||||
-- 读取操作系统保护的本机凭据。
|
||||
foreign import ccall safe "chusql_sudo_read" nativeRead :: CInt -> CString -> IO CInt
|
||||
-- 清理本次服务启动的凭据。
|
||||
foreign import ccall safe "chusql_sudo_remove" nativeRemove :: CInt -> IO CInt
|
||||
|
||||
-- 判断当前进程是否以提升身份运行。
|
||||
sudoPrivileged :: IO Bool
|
||||
sudoPrivileged = (/= 0) <$> nativePrivileged
|
||||
|
||||
-- 生成随机挑战和服务实例凭据。
|
||||
sudoChallenge :: IO Text
|
||||
sudoChallenge = TE.decodeUtf8 . convertToBase Base16 <$> (getRandomBytes 32 :: IO BS.ByteString)
|
||||
|
||||
-- 创建本次服务实例的受保护凭据。
|
||||
createSudoCredential :: Int -> IO SudoCredential
|
||||
createSudoCredential port = do
|
||||
credential@(SudoCredential secret) <- newSudoCredential
|
||||
status <- BS.useAsCString secret (nativeCreate (fromIntegral port))
|
||||
if status == 0 then pure credential
|
||||
else ioError (userError (T.unpack (nativeError "create" status)))
|
||||
|
||||
-- 生成独立服务实例的随机凭据。
|
||||
newSudoCredential :: IO SudoCredential
|
||||
newSudoCredential = SudoCredential . TE.encodeUtf8 <$> sudoChallenge
|
||||
|
||||
-- 判断内核报告的连接地址是否为回环。
|
||||
isLocalPeer :: SockAddr -> Bool
|
||||
isLocalPeer (SockAddrInet _ host) = let (first, _, _, _) = hostAddressToTuple host in first == 127
|
||||
isLocalPeer (SockAddrInet6 _ _ host _) = case hostAddress6ToTuple host of
|
||||
(0, 0, 0, 0, 0, 0, 0, 1) -> True
|
||||
(0, 0, 0, 0, 0, 65535, high, _) -> high >= 32512 && high <= 32767
|
||||
_ -> False
|
||||
isLocalPeer _ = False
|
||||
|
||||
-- 校验挑战有效期及其账号绑定证明。
|
||||
validSudoChallenge :: SudoCredential -> UTCTime -> Maybe (Text, UTCTime) -> Text -> Text -> Bool
|
||||
validSudoChallenge credential now challenge user proof = case challenge of
|
||||
Just (nonce, issued) -> diffUTCTime now issued >= 0 && diffUTCTime now issued <= 60
|
||||
&& verifySudoProof credential nonce user proof
|
||||
Nothing -> False
|
||||
|
||||
-- 删除本次服务实例的受保护凭据。
|
||||
removeSudoCredential :: Int -> IO ()
|
||||
removeSudoCredential port = do
|
||||
status <- nativeRemove (fromIntegral port)
|
||||
if status == 0 then pure () else ioError (userError (T.unpack (nativeError "remove" status)))
|
||||
|
||||
-- 验证操作系统身份并读取本机凭据。
|
||||
readSudoCredential :: Int -> IO (Either Text SudoCredential)
|
||||
readSudoCredential port = allocaBytes 64 $ \buffer -> do
|
||||
status <- nativeRead (fromIntegral port) buffer
|
||||
if status /= 0 then pure (Left (nativeError "read" status)) else do
|
||||
secret <- BS.packCStringLen (buffer, 64)
|
||||
pure $ if BS.all (isHexDigit . toEnum . fromIntegral) secret
|
||||
then Right (SudoCredential secret) else Left "invalid sudo credential"
|
||||
|
||||
-- 计算绑定挑战与目标账号的认证证明。
|
||||
sudoProof :: SudoCredential -> Text -> Text -> Text
|
||||
sudoProof (SudoCredential secret) challenge user = TE.decodeUtf8 (convertToBase Base16 digest)
|
||||
where
|
||||
digest = hmac secret (TE.encodeUtf8 ("chusql-sudo-v1:" <> challenge <> ":" <> T.toLower (T.strip user))) :: HMAC SHA256
|
||||
|
||||
-- 按固定长度常量时间比对认证证明。
|
||||
verifySudoProof :: SudoCredential -> Text -> Text -> Text -> Bool
|
||||
verifySudoProof credential challenge user proof = T.length proof == 64
|
||||
&& BA.constEq (TE.encodeUtf8 (sudoProof credential challenge user)) (TE.encodeUtf8 proof)
|
||||
|
||||
-- 将原生错误转换为明确诊断。
|
||||
nativeError :: Text -> CInt -> Text
|
||||
nativeError operation status = "sudo credential " <> operation <> " failed (OS error "
|
||||
<> T.pack (show status) <> "); requires root or an elevated administrator; stale credential directories must be removed after stopping the server"
|
||||
@@ -0,0 +1,60 @@
|
||||
import ctypes
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# 验证 Unix 本机凭据的所有权、权限及符号链接拒绝。
|
||||
|
||||
# 执行本机凭据安全回归。
|
||||
def main():
|
||||
library = ctypes.CDLL(sys.argv[1])
|
||||
port = int(sys.argv[2])
|
||||
library.chusql_sudo_create.argtypes = [ctypes.c_int, ctypes.c_char_p]
|
||||
library.chusql_sudo_read.argtypes = [ctypes.c_int, ctypes.c_void_p]
|
||||
secret = b'a' * 64
|
||||
buffer = ctypes.create_string_buffer(64)
|
||||
if len(sys.argv) > 3:
|
||||
assert library.chusql_sudo_privileged() == 0
|
||||
assert library.chusql_sudo_create(port, secret) != 0
|
||||
assert library.chusql_sudo_read(port, buffer) != 0
|
||||
print('non-root creation and reading refused')
|
||||
return
|
||||
assert library.chusql_sudo_privileged() == 1
|
||||
assert library.chusql_sudo_create(port, secret) == 0
|
||||
directory = Path(f'/var/run/chusql-sudo-{port}')
|
||||
file = directory / 'credential'
|
||||
try:
|
||||
assert directory.stat().st_uid == 0
|
||||
assert directory.stat().st_mode & 0o777 == 0o700
|
||||
assert file.stat().st_mode & 0o777 == 0o600
|
||||
assert library.chusql_sudo_read(port, buffer) == 0
|
||||
assert buffer.raw == secret
|
||||
assert library.chusql_sudo_create(port, secret) != 0
|
||||
subprocess.run([sys.executable, __file__, sys.argv[1], str(port), '--deny'],
|
||||
user=65534, group=65534, check=True)
|
||||
file.chmod(0o644)
|
||||
assert library.chusql_sudo_read(port, buffer) != 0
|
||||
file.chmod(0o600)
|
||||
file.rename(directory / 'original')
|
||||
file.symlink_to(directory / 'original')
|
||||
assert library.chusql_sudo_read(port, buffer) != 0
|
||||
file.unlink()
|
||||
(directory / 'original').rename(file)
|
||||
file.write_bytes(secret + b'extra')
|
||||
assert library.chusql_sudo_read(port, buffer) != 0
|
||||
file.write_bytes(secret)
|
||||
os.chown(file, 65534, 65534)
|
||||
assert library.chusql_sudo_read(port, buffer) != 0
|
||||
os.chown(file, 0, 0)
|
||||
directory.chmod(0o755)
|
||||
assert library.chusql_sudo_read(port, buffer) != 0
|
||||
directory.chmod(0o700)
|
||||
finally:
|
||||
assert library.chusql_sudo_remove(port) == 0
|
||||
assert not directory.exists()
|
||||
print('root round trip, duplicate, permissions, ownership, symlink and cleanup passed')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in new issue
Block a user