diff --git a/.env.docker.example b/.env.docker.example index ae21059..43c14cf 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -5,6 +5,8 @@ VERSION=1 # See "ports" in docker-compose.yml. SERVER_ADDRESS=0.0.0.0 SERVER_PORT=9503 +# If you use nginx, then provide here name of client header. for example x-real-ip or x-forwarded-for +REAL_IP_HEADER= MEMORY_LIMIT=256M TIMEZONE=UTC @@ -65,11 +67,4 @@ DB_SERIALIZER=serialize # Enable to add cache info about users to database. Disable if you only read data from channels. DB_ENABLE_MIN_DATABASE=0 # Enable file metadata cache -DB_ENABLE_FILE_REFERENCE_DATABASE=0 - -# HEALTHCHECK -# If server stops responding to requests it will be stoped -# Requests made each 30 seconds by default. -HEALTHCHECK_ENABLED=0 -HEALTHCHECK_INTERVAL=30 -HEALTHCHECK_REQUEST_TIMEOUT=10 \ No newline at end of file +DB_ENABLE_FILE_REFERENCE_DATABASE=0 \ No newline at end of file diff --git a/.env.example b/.env.example index 907a67d..2337688 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,8 @@ VERSION=1 # To recieve requests from the Internet change to 0.0.0.0 and add rule to your firewall (THIS WILL MAKE API UNSECURE!) SERVER_ADDRESS=127.0.0.1 SERVER_PORT=9503 +# If you use nginx, then provide here name of client header. for example x-real-ip or x-forwarded-for +REAL_IP_HEADER= MEMORY_LIMIT=256M TIMEZONE=UTC @@ -63,11 +65,4 @@ DB_SERIALIZER=serialize # Enable to add cache info about users to database. Disable if you only read data from channels. DB_ENABLE_MIN_DATABASE=0 # Enable file metadata cache -DB_ENABLE_FILE_REFERENCE_DATABASE=0 - -# HEALTHCHECK -# If server stops responding to requests it will be stoped -# Requests made each 30 seconds by default. -HEALTHCHECK_ENABLED=0 -HEALTHCHECK_INTERVAL=30 -HEALTHCHECK_REQUEST_TIMEOUT=10 \ No newline at end of file +DB_ENABLE_FILE_REFERENCE_DATABASE=0 \ No newline at end of file diff --git a/README.md b/README.md index ab51377..8bc530e 100644 --- a/README.md +++ b/README.md @@ -115,11 +115,8 @@ It's recommended to use http_build_query, when using GET requests. ### Get events/updates Telegram is event driven platform. For example: every time your account receives a message you immediately get an update. There are multiple ways of [getting updates](https://docs.madelineproto.xyz/docs/UPDATES.html) in TelegramApiServer / MadelineProto: - 1. [Websocket](#eventhandler-updates-webhooks) - 2. Long Polling: - send request to getUpdates endpoint - `curl "127.0.0.1:9503/api/getUpdates?data[limit]=3&data[offset]=0&data[timeout]=10.0" -g` - 3. Webhook: + 1. [Websocket](#eventhandler-updates-webhooks) + 2. Webhook: Redirect all updates to your endpoint, just like bot api! `curl "127.0.0.1:9503/api/setWebhook?url=http%3A%2F%2Fexample.com%2Fsome_webhook" -g ` Example uses urlencoded url in query. @@ -199,9 +196,35 @@ curl --location --request POST '127.0.0.1:9503/api/downloadToResponse' \ Also see: https://docs.madelineproto.xyz/docs/FILES.html#downloading-files ### Multiple sessions support -**WARNING: running multiple sessions in one instance is unstable.** +Its recommended to run every session in separate container. + +To add more containers create `docker-compose.override.yml` file. +Docker will [automatically merge](https://docs.docker.com/compose/multiple-compose-files/merge/) it with default docker-compose file. + +Example of `docker-compose.override.yml` with two additional containers/sessions (3 in total): +```yaml +services: + api-2: + extends: + file: docker-compose.base.yml + service: base-api + ports: + - "127.0.0.1:9512:9503" + command: + - "-s=session-2" + api-3: + extends: + file: docker-compose.base.yml + service: base-api + ports: + - "127.0.0.1:9513:9503" + command: + - "-s=session-3" + +``` +### Multiple sessions in one container (deprecated) +**WARNING: running multiple sessions in one instance/container is unstable.** Crash/error in one session will crash all of them. -Correct way: override docker-compose.yml and add containers with different ports and session names for each session. When running multiple sessions, need to define which session to use for request. Each session stored in `sessions/{$session}.madeline`. Nested folders supported. @@ -259,10 +282,10 @@ Each session stored in `sessions/{$session}.madeline`. Nested folders supported. **Examples:** * Session list: `http://127.0.0.1:9503/system/getSessionList` * Adding session: `http://127.0.0.1:9503/system/addSession?session=users/xtrime` -* Removing session (session file will remain): `http://127.0.0.1:9503/system/removeSession?session=users/xtrime` - Due to madelineProto issue its instance still might be in memory and continue working even after the remove. -* Remove session file: `http://127.0.0.1:9503/system/unlinkSessionFile?session=users/xtrime` - Don`t forget to logout and call removeSession first! +* ~~Removing session (session file will remain): `http://127.0.0.1:9503/system/removeSession?session=users/xtrime` + Due to madelineProto issue its instance still might be in memory and continue working even after the remove.~~ +* ~~Remove session file: `http://127.0.0.1:9503/system/unlinkSessionFile?session=users/xtrime` + Don`t forget to logout and call removeSession first!~~ * Close TelegramApiServer (end process): `http://127.0.0.1:9503/system/exit` Full list of system methods available in [SystemApiExtensions class](https://github.com/xtrime-ru/TelegramApiServer/blob/master/src/MadelineProtoExtensions/SystemApiExtensions.php) diff --git a/config.php b/config.php index c3ea2b0..abeb04c 100644 --- a/config.php +++ b/config.php @@ -7,6 +7,7 @@ $settings = [ 'server' => [ 'address' => (string)getenv('SERVER_ADDRESS'), 'port' => (int)getenv('SERVER_PORT'), + 'real_ip_header' => (string)(getenv('REAL_IP_HEADER') ?? ''), ], 'telegram' => [ 'app_info' => [ // obtained in https://my.telegram.org @@ -67,11 +68,6 @@ $settings = [ 'passwords' => (array)json_decode((string)getenv('PASSWORDS'), true), 'bulk_interval' => (float)getenv('REQUESTS_BULK_INTERVAL') ], - 'health_check' => [ - 'enabled' => (bool)filter_var((string)getenv('HEALTHCHECK_ENABLED'), FILTER_VALIDATE_BOOL), - 'interval' => ((int)getenv('HEALTHCHECK_INTERVAL') ?: 30), - 'timeout' => ((int)getenv('HEALTHCHECK_REQUEST_TIMEOUT') ?: 60), - ] ]; if (empty($settings['telegram']['connection']['proxies']['\danog\MadelineProto\Stream\Proxy\SocksProxy'][0]['address'])) { diff --git a/docker-compose.base.yml b/docker-compose.base.yml new file mode 100644 index 0000000..e3450d7 --- /dev/null +++ b/docker-compose.base.yml @@ -0,0 +1,37 @@ +services: + base-api: + image: xtrime/telegram-api-server:latest + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + volumes: + - ./:/app-host-link + working_dir: /app-host-link + depends_on: + - mysql + environment: + WAIT_HOSTS: mysql:3306 + logging: + driver: "json-file" + options: + max-size: "1024k" + max-file: "2" + healthcheck: + test: curl -f http://localhost:9503/system/healthcheck || exit 1 + interval: 60s + timeout: 10s + retries: 2 + start_period: 60s + base-mysql: + image: mariadb:11.1 + restart: unless-stopped + volumes: + - ./.mysql:/var/lib/mysql + environment: + MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 'yes' + MARIADB_AUTO_UPGRADE: 'yes' + command: + - --skip-grant-tables + - --innodb-buffer-pool-size=128M + - --wait_timeout=65 \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 886e13f..abd3547 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,5 +1,8 @@ services: api: + extends: + file: docker-compose.base.yml + service: base-api image: xtrime/telegram-api-server:dev build: context: . @@ -8,4 +11,13 @@ services: - "127.0.0.1:9503:9503" - "9003" environment: - PHP_IDE_CONFIG: "serverName=Docker" \ No newline at end of file + PHP_IDE_CONFIG: "serverName=Docker" + mysql: + extends: + file: docker-compose.base.yml + service: base-mysql + ports: + - "127.0.0.1:9507:3306" +networks: + default: + name: telegram-api-server \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index e4dd4ee..3352a94 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,41 +1,18 @@ services: api: - image: xtrime/telegram-api-server:latest - build: - context: . - dockerfile: Dockerfile - init: true - restart: unless-stopped + extends: + file: docker-compose.base.yml + service: base-api ports: - "127.0.0.1:9503:9503" - volumes: - - ./:/app-host-link - working_dir: /app-host-link - depends_on: - - mysql - environment: - WAIT_HOSTS: mysql:3306 - logging: - driver: "json-file" - options: - max-size: "1024k" - max-file: "2" command: - "-s=session" mysql: - image: mariadb:11.1 - restart: unless-stopped + extends: + file: docker-compose.base.yml + service: base-mysql ports: - "127.0.0.1:9507:3306" - volumes: - - ./.mysql:/var/lib/mysql - environment: - MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 'yes' - MARIADB_AUTO_UPGRADE: 'yes' - command: - - --skip-grant-tables - - --innodb-buffer-pool-size=128M - - --wait_timeout=65 networks: default: name: telegram-api-server \ No newline at end of file diff --git a/server.php b/server.php index 70ddd1c..3f20601 100644 --- a/server.php +++ b/server.php @@ -1,12 +1,7 @@ get('health_check.enabled')) { - if (!defined('SIGINT')) { - throw new RuntimeException('pcintl required for healthcheck. Use docker.'); - } - Fork::run(static function () use ($mainProcessPid) { - HealthCheck::start($mainProcessPid); - }); -} - $sessions = []; foreach ($options['session'] as $session) { $session = trim($session); diff --git a/src/MadelineProtoExtensions/SystemApiExtensions.php b/src/MadelineProtoExtensions/SystemApiExtensions.php index e806398..110229c 100644 --- a/src/MadelineProtoExtensions/SystemApiExtensions.php +++ b/src/MadelineProtoExtensions/SystemApiExtensions.php @@ -22,6 +22,22 @@ class SystemApiExtensions $this->client = $client; } + /** + * @return array + */ + public function healthcheck(): array + { + $results = []; + ['sessions' => $sessions] = $this->getSessionList(); + foreach ($sessions as $sessionKey => $session) { + $instance = $this->client->instances[$sessionKey]; + if ($instance->getAuthorization() === API::LOGGED_IN) { + $results[$sessionKey] = $instance->fullGetSelf(); + } + } + return $results; + } + public function addSession(string $session, array $settings = []): array { if (!empty($settings['app_info']['api_id'])) { diff --git a/src/Server/Authorization.php b/src/Server/Authorization.php index 51203ff..08f0729 100644 --- a/src/Server/Authorization.php +++ b/src/Server/Authorization.php @@ -30,7 +30,11 @@ class Authorization implements Middleware public function handleRequest(Request $request, RequestHandler $requestHandler): Response { - [$host] = explode(':', $request->getClient()->getRemoteAddress()->toString(), 2); + $host = Server::getClientIp($request); + + if ($this->isLocal($host)) { + return $requestHandler->handleRequest($request); + } if ($this->passwords) { $header = (string)$request->getHeader('Authorization'); @@ -54,6 +58,19 @@ class Authorization implements Middleware private function isIpAllowed(string $host): bool { + + + if ($this->ipWhitelist && !in_array($host, $this->ipWhitelist, true)) { + return false; + } + return true; + } + + private function isLocal(string $host): bool { + if ($host === '127.0.0.1' || $host === 'localhost') { + return true; + } + global $options; if ($options['docker']) { $isSameNetwork = abs(ip2long($host) - $this->selfIp) < 256; @@ -61,10 +78,6 @@ class Authorization implements Middleware return true; } } - - if ($this->ipWhitelist && !in_array($host, $this->ipWhitelist, true)) { - return false; - } - return true; + return false; } } \ No newline at end of file diff --git a/src/Server/Fork.php b/src/Server/Fork.php deleted file mode 100644 index 9b46da1..0000000 --- a/src/Server/Fork.php +++ /dev/null @@ -1,21 +0,0 @@ -get('server.address'); - if (static::$host === '0.0.0.0') { - static::$host = '127.0.0.1'; - } - static::$port = (int)Config::getInstance()->get('server.port'); - - static::$checkInterval = (int)Config::getInstance()->get('health_check.interval'); - static::$requestTimeout = (int)Config::getInstance()->get('health_check.timeout'); - - EventLoop::repeat(static::$checkInterval, static function () use ($parentPid) { - try { - Logger::getInstance()->info('Start health check'); - if (!self::isProcessAlive($parentPid)) { - throw new RuntimeException('Parent process died'); - } - $sessions = static::getSessionList(); - $sessionsForCheck = static::getLoggedInSessions($sessions); - $futures = []; - foreach ($sessionsForCheck as $session) { - $futures[] = static::checkSession($session); - } - awaitAll($futures); - - Logger::getInstance()->info('Health check ok. Sessions checked: ' . count($sessionsForCheck)); - } catch (Throwable $e) { - Logger::getInstance()->error($e->getMessage()); - Logger::getInstance()->critical('Health check failed'); - if (self::isProcessAlive($parentPid)) { - Logger::getInstance()->critical('Killing parent process'); - - exec("kill -2 $parentPid"); - if (self::isProcessAlive($parentPid)) { - exec("kill -9 $parentPid"); - } - } - exit(1); - } - }); - - trapSignal([SIGINT, SIGTERM]); - Logger::getInstance()->critical('Health check process exit'); - - } - - private static function getSessionList(): array - { - $url = sprintf("http://%s:%s/system/getSessionList", static::$host, static::$port); - - $response = static::sendRequest($url); - - if ($response === false) { - throw new UnexpectedValueException('No response from /system'); - } - - return json_decode($response, true, 10, JSON_THROW_ON_ERROR)['response']['sessions']; - } - - private static function getLoggedInSessions(array $sessions): array - { - $loggedInSessions = []; - foreach ($sessions as $sessionName => $session) { - if ($session['status'] === 'LOGGED_IN') { - $loggedInSessions[] = $sessionName; - } - } - - return $loggedInSessions; - } - - private static function checkSession(string $sessionName): Future - { - return async(function () use ($sessionName) { - $url = sprintf("http://%s:%s/api/%s/getSelf", static::$host, static::$port, $sessionName); - $response = static::sendRequest($url); - $response = json_decode($response, true, 10, JSON_THROW_ON_ERROR); - if (empty($response['response'])) { - Logger::getInstance()->error('Health check response: ', $response); - throw new RuntimeException("Failed health check: $url"); - } - return $response; - }); - } - - - private static function sendRequest(string $url): string - { - $client = (new HttpClientBuilder)::buildDefault(); - $request = new Request($url); - $request->setInactivityTimeout(static::$requestTimeout); - $request->setTransferTimeout(static::$requestTimeout); - - $response = $client->request($request); - return $response->getBody()->buffer(); - } - - private static function isProcessAlive(int $pid): bool - { - $result = exec("ps -p $pid | grep $pid"); - return !empty($result); - } -} \ No newline at end of file diff --git a/src/Server/Server.php b/src/Server/Server.php index 30ec555..718c9fd 100644 --- a/src/Server/Server.php +++ b/src/Server/Server.php @@ -92,13 +92,25 @@ class Server public static function getClientIp(Request $request): string { - $remote = $request->getClient()->getRemoteAddress()->toString(); - $hostArray = explode(':', $remote); - if (count($hostArray) >= 2) { - $port = (int)array_pop($hostArray); - if ($port > 0 && $port <= 65353) { - $remote = implode(':', $hostArray); + $realIpHeader = Config::getInstance()->get('server.real_ip_header'); + if ($realIpHeader) { + $remote = $request->getHeader($realIpHeader); + if (!$remote) { + GOTO DIRECT; } + $tmp = explode(',', $remote); + $remote = trim(end($tmp)); + } else { + DIRECT: + $remote = $request->getClient()->getRemoteAddress()->toString(); + $hostArray = explode(':', $remote); + if (count($hostArray) >= 2) { + $port = (int)array_pop($hostArray); + if ($port > 0 && $port <= 65353) { + $remote = implode(':', $hostArray); + } + } + } return $remote;