diff --git a/Makefile b/Makefile index cd4191820..6d7294685 100644 --- a/Makefile +++ b/Makefile @@ -202,6 +202,7 @@ include tool/hash/hash.mk include tool/net/net.mk include tool/viz/viz.mk include tool/tool.mk +include net/turfwar/turfwar.mk include test/libc/tinymath/test.mk include test/libc/intrin/test.mk include test/libc/mem/test.mk diff --git a/libc/calls/chdir.c b/libc/calls/chdir.c index 78c43a239..925a503ee 100644 --- a/libc/calls/chdir.c +++ b/libc/calls/chdir.c @@ -16,11 +16,12 @@ │ TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR │ │ PERFORMANCE OF THIS SOFTWARE. │ ╚─────────────────────────────────────────────────────────────────────────────*/ -#include "libc/intrin/strace.internal.h" #include "libc/calls/syscall-nt.internal.h" #include "libc/calls/syscall-sysv.internal.h" #include "libc/dce.h" #include "libc/intrin/asan.internal.h" +#include "libc/intrin/strace.internal.h" +#include "libc/runtime/runtime.h" #include "libc/sysv/errfuns.h" /** @@ -34,6 +35,7 @@ */ int chdir(const char *path) { int rc; + GetProgramExecutableName(); // XXX: ugly workaround if (!path || (IsAsan() && !__asan_is_valid(path, 1))) { rc = efault(); } else if (!IsWindows()) { diff --git a/libc/calls/struct/timespec.h b/libc/calls/struct/timespec.h index 3bc0cb042..75be831a2 100644 --- a/libc/calls/struct/timespec.h +++ b/libc/calls/struct/timespec.h @@ -19,6 +19,7 @@ int timespec_getres(struct timespec *, int); int _timespec_cmp(struct timespec, struct timespec) pureconst; bool _timespec_eq(struct timespec, struct timespec) pureconst; +bool _timespec_gt(struct timespec, struct timespec) pureconst; bool _timespec_gte(struct timespec, struct timespec) pureconst; int64_t _timespec_tomicros(struct timespec) pureconst; int64_t _timespec_tomillis(struct timespec) pureconst; diff --git a/libc/integral/normalize.inc b/libc/integral/normalize.inc index 30be7c9b9..a8ccdf124 100644 --- a/libc/integral/normalize.inc +++ b/libc/integral/normalize.inc @@ -68,7 +68,7 @@ /* TODO(jart): Remove this in favor of GetStackSize() */ #if defined(COSMO) && (defined(MODE_DBG) || defined(__SANITIZE_ADDRESS__)) -#define STACKSIZE 262144 /* 256kb stack */ +#define STACKSIZE 524288 /* 512kb stack */ #elif defined(COSMO) #define STACKSIZE 65536 /* 64kb stack */ #else diff --git a/libc/zipos/get.c b/libc/zipos/get.c index a6d618c9c..c08c3d18f 100644 --- a/libc/zipos/get.c +++ b/libc/zipos/get.c @@ -17,15 +17,15 @@ │ PERFORMANCE OF THIS SOFTWARE. │ ╚─────────────────────────────────────────────────────────────────────────────*/ #include "libc/calls/calls.h" -#include "libc/intrin/strace.internal.h" #include "libc/intrin/cmpxchg.h" #include "libc/intrin/promises.internal.h" -#include "libc/thread/thread.h" +#include "libc/intrin/strace.internal.h" #include "libc/macros.internal.h" #include "libc/runtime/runtime.h" #include "libc/sysv/consts/map.h" #include "libc/sysv/consts/o.h" #include "libc/sysv/consts/prot.h" +#include "libc/thread/thread.h" #include "libc/zip.h" #include "libc/zipos/zipos.internal.h" diff --git a/net/net.mk b/net/net.mk index cd1e17196..58294aed9 100644 --- a/net/net.mk +++ b/net/net.mk @@ -4,4 +4,5 @@ .PHONY: o/$(MODE)/net o/$(MODE)/net: o/$(MODE)/net/finger \ o/$(MODE)/net/http \ - o/$(MODE)/net/https + o/$(MODE)/net/https \ + o/$(MODE)/net/turfwar diff --git a/net/turfwar/turfwar.c b/net/turfwar/turfwar.c new file mode 100644 index 000000000..68496157c --- /dev/null +++ b/net/turfwar/turfwar.c @@ -0,0 +1,932 @@ +/*-*- mode:c;indent-tabs-mode:nil;c-basic-offset:2;tab-width:8;coding:utf-8 -*-│ +│vi: set net ft=c ts=2 sts=2 sw=2 fenc=utf-8 :vi│ +╞══════════════════════════════════════════════════════════════════════════════╡ +│ Copyright 2022 Justine Alexandra Roberts Tunney │ +│ │ +│ Permission to use, copy, modify, and/or distribute this software for │ +│ any purpose with or without fee is hereby granted, provided that the │ +│ above copyright notice and this permission notice appear in all copies. │ +│ │ +│ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL │ +│ WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED │ +│ WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE │ +│ AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL │ +│ DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR │ +│ PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER │ +│ TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR │ +│ PERFORMANCE OF THIS SOFTWARE. │ +╚─────────────────────────────────────────────────────────────────────────────*/ +#include "libc/calls/calls.h" +#include "libc/calls/pledge.h" +#include "libc/calls/struct/iovec.h" +#include "libc/calls/struct/sigaction.h" +#include "libc/calls/struct/stat.h" +#include "libc/calls/struct/timespec.h" +#include "libc/calls/struct/timeval.h" +#include "libc/errno.h" +#include "libc/fmt/conv.h" +#include "libc/fmt/itoa.h" +#include "libc/intrin/bits.h" +#include "libc/intrin/kprintf.h" +#include "libc/intrin/strace.internal.h" +#include "libc/log/check.h" +#include "libc/log/log.h" +#include "libc/macros.internal.h" +#include "libc/mem/gc.h" +#include "libc/mem/mem.h" +#include "libc/nexgen32e/crc32.h" +#include "libc/runtime/internal.h" +#include "libc/runtime/runtime.h" +#include "libc/sock/sock.h" +#include "libc/sock/struct/pollfd.h" +#include "libc/sock/struct/sockaddr.h" +#include "libc/stdio/append.h" +#include "libc/stdio/stdio.h" +#include "libc/str/slice.h" +#include "libc/str/str.h" +#include "libc/sysv/consts/af.h" +#include "libc/sysv/consts/clock.h" +#include "libc/sysv/consts/o.h" +#include "libc/sysv/consts/poll.h" +#include "libc/sysv/consts/sig.h" +#include "libc/sysv/consts/so.h" +#include "libc/sysv/consts/sock.h" +#include "libc/sysv/consts/sol.h" +#include "libc/sysv/consts/tcp.h" +#include "libc/thread/thread.h" +#include "libc/time/struct/tm.h" +#include "libc/x/x.h" +#include "libc/x/xasprintf.h" +#include "libc/zip.h" +#include "net/http/escape.h" +#include "net/http/http.h" +#include "net/http/url.h" +#include "third_party/getopt/getopt.h" +#include "third_party/nsync/cv.h" +#include "third_party/nsync/mu.h" +#include "third_party/nsync/note.h" +#include "third_party/sqlite3/sqlite3.h" +#include "third_party/zlib/zconf.h" +#include "third_party/zlib/zlib.h" +#include "tool/net/lfuncs.h" + +/** + * @fileoverview production webserver for turfwar online game + */ + +#define PORT 8080 +#define WORKERS 2000 +#define HEARTBEAT 2000 +#define KEEPALIVE_MS 2000 +#define DATE_UPDATE_MS 500 +#define POLL_ASSETS_MS 250 +#define BOARD_GENERATE_MS 10000 +#define CLAIM_DEADLINE_MS 1000 +#define CLAIM_MAX 200 +#define NICK_MAX 40 +#define MSG_MAX 10 + +#define GETOPTS "dvp:w:k:" +#define USAGE \ + "\ +Usage: turfwar.com [-dv] ARGS...\n\ + -d daemonize\n\ + -v verbosity\n\ + -p INT port\n\ + -w INT workers\n\ + -k INT keepalive\n\ +" + +#define STANDARD_RESPONSE_HEADERS \ + "Server: turfwar\r\n" \ + "Referrer-Policy: origin\r\n" \ + "Access-Control-Allow-Origin: *\r\n" + +#define HasHeader(H) (!!msg->headers[H].a) +#define HeaderData(H) (inbuf + msg->headers[H].a) +#define HeaderLength(H) (msg->headers[H].b - msg->headers[H].a) +#define HeaderEqual(H, S) \ + SlicesEqual(S, strlen(S), HeaderData(H), HeaderLength(H)) +#define HeaderEqualCase(H, S) \ + SlicesEqualCase(S, strlen(S), HeaderData(H), HeaderLength(H)) +#define UrlEqual(S) \ + SlicesEqual(inbuf + msg->uri.a, msg->uri.b - msg->uri.a, S, strlen(S)) + +#if 1 +#define LOG(...) kprintf(__VA_ARGS__) +#else +#define LOG(...) (void)0 +#endif + +#if 0 +#define DEBUG(...) kprintf(__VA_ARGS__) +#else +#define DEBUG(...) (void)0 +#endif + +#define CHECK_SYS(x) \ + do { \ + if (!CheckSys(__FILE__, __LINE__, x)) { \ + goto OnError; \ + } \ + } while (0) +#define CHECK_SQL(x) \ + do { \ + int e = errno; \ + if (!CheckSql(__FILE__, __LINE__, x)) { \ + goto OnError; \ + } \ + errno = e; \ + } while (0) +#define CHECK_DB(x) \ + do { \ + int e = errno; \ + if (!CheckDb(__FILE__, __LINE__, x, db)) { \ + goto OnError; \ + } \ + errno = e; \ + } while (0) + +static const uint8_t kGzipHeader[] = { + 0x1F, // MAGNUM + 0x8B, // MAGNUM + 0x08, // CM: DEFLATE + 0x00, // FLG: NONE + 0x00, // MTIME: NONE + 0x00, // + 0x00, // + 0x00, // + 0x00, // XFL + kZipOsUnix, // OS +}; + +struct Data { + char *p; + size_t n; +}; + +struct Asset { + char *path; + nsync_mu lock; + const char *type; + struct Data data; + struct Data gzip; + struct timespec mtim; + char lastmod[32]; +}; + +bool g_daemonize; +int g_port = PORT; +int g_workers = WORKERS; +int g_keepalive = KEEPALIVE_MS; + +struct tm g_nowish; +nsync_note g_shutdown; +nsync_mu g_nowish_lock; + +struct Board { + nsync_mu mu; + nsync_cv cv; +} g_board; + +struct Assets { + struct Asset index; + struct Asset about; + struct Asset user; + struct Asset board; +} g_asset; + +struct Claims { + int pos; + int count; + nsync_mu mu; + nsync_cv non_full; + nsync_cv non_empty; + struct Claim { + uint32_t ip; + char name[NICK_MAX + 1]; + } data[CLAIM_MAX]; +} g_claims; + +bool CheckSys(const char *file, int line, long rc) { + if (rc != -1) return true; + kprintf("%s:%d: %s\n", file, line, strerror(errno)); + return false; +} + +bool CheckSql(const char *file, int line, int rc) { + if (rc == SQLITE_OK) return true; + kprintf("%s:%d: %s\n", file, line, sqlite3_errstr(rc)); + return false; +} + +bool CheckDb(const char *file, int line, int rc, sqlite3 *db) { + if (rc == SQLITE_OK) return true; + kprintf("%s:%d: %s: %s\n", file, line, sqlite3_errstr(rc), + sqlite3_errmsg(db)); + return false; +} + +bool IsValidNick(const char *s, size_t n) { + size_t i; + if (n > NICK_MAX) return false; + for (i = 0; i < n; ++i) { + if (!(isalnum(s[i]) || // + s[i] == '@' || // + s[i] == '/' || // + s[i] == ':' || // + s[i] == '.' || // + s[i] == '+' || // + s[i] == '_' || // + s[i] == '*')) { + return false; + } + } + return true; +} + +char *FormatUnixHttpDateTime(char *s, int64_t t) { + struct tm tm; + gmtime_r(&t, &tm); + FormatHttpDateTime(s, &tm); + return s; +} + +void UpdateNow(void) { + int64_t secs; + struct tm tm; + struct timespec ts; + clock_gettime(CLOCK_REALTIME, &ts); + secs = ts.tv_sec; + gmtime_r(&secs, &tm); + //!//!//!//!//!//!//!//!//!//!//!//!//!/ + nsync_mu_lock(&g_nowish_lock); + g_nowish = tm; + nsync_mu_unlock(&g_nowish_lock); + //!//!//!//!//!//!//!//!//!//!//!//!//!/ +} + +char *FormatDate(char *p) { + //////////////////////////////////////// + nsync_mu_rlock(&g_nowish_lock); + p = FormatHttpDateTime(p, &g_nowish); + nsync_mu_runlock(&g_nowish_lock); + //////////////////////////////////////// + return p; +} + +bool AddClaim(struct Claims *q, const struct Claim *v, nsync_time dead) { + bool wake = false; + bool added = false; + nsync_mu_lock(&q->mu); + while (q->count == ARRAYLEN(q->data)) { + if (nsync_cv_wait_with_deadline(&q->non_full, &q->mu, dead, g_shutdown)) { + break; // must be ETIMEDOUT or ECANCELED + } + } + if (q->count != ARRAYLEN(q->data)) { + int i = q->pos + q->count; + if (ARRAYLEN(q->data) <= i) i -= ARRAYLEN(q->data); + memcpy(q->data + i, v, sizeof(*v)); + if (!q->count) wake = true; + q->count++; + added = true; + } + nsync_mu_unlock(&q->mu); + if (wake) { + nsync_cv_broadcast(&q->non_empty); + } + return added; +} + +int GetClaims(struct Claims *q, struct Claim *out, int len, nsync_time dead) { + int got = 0; + nsync_mu_lock(&q->mu); + while (!q->count) { + if (nsync_cv_wait_with_deadline(&q->non_empty, &q->mu, dead, g_shutdown)) { + break; // must be ETIMEDOUT or ECANCELED + } + } + while (got < len && q->count) { + memcpy(out + got, q->data + q->pos, sizeof(*out)); + if (q->count == ARRAYLEN(q->data)) { + nsync_cv_broadcast(&q->non_full); + } + ++got; + q->pos++; + q->count--; + if (q->pos == ARRAYLEN(q->data)) { + q->pos = 0; + } + } + nsync_mu_unlock(&q->mu); + return got; +} + +static bool GetNick(char *inbuf, struct HttpMessage *msg, struct Claim *v) { + size_t i, n; + struct Url url; + void *f[2] = {0}; + bool found = false; + f[0] = ParseUrl(inbuf + msg->uri.a, msg->uri.b - msg->uri.a, &url, + kUrlPlus | kUrlLatin1); + f[1] = url.params.p; + for (i = 0; i < url.params.n; ++i) { + if (SlicesEqual("name", 4, url.params.p[i].key.p, url.params.p[i].key.n) && + url.params.p[i].val.p && + IsValidNick(url.params.p[i].val.p, url.params.p[i].val.n)) { + memcpy(v->name, url.params.p[i].val.p, url.params.p[i].val.n); + found = true; + break; + } + } + free(f[1]); + free(f[0]); + return found; +} + +// thousands of threads for handling client connections +void *HttpWorker(void *arg) { + int server; + int yes = 1; + int id = (intptr_t)arg; + struct HttpMessage *msg; + STRACE("HttpWorker #%d started", id); + + // load balance incoming connections for port 8080 across all threads + // hangup on any browser clients that lag for more than a few seconds + struct timeval timeo = {g_keepalive / 1000, g_keepalive % 1000}; + struct sockaddr_in addr = {.sin_family = AF_INET, .sin_port = htons(g_port)}; + + CHECK_NE(-1, (server = socket(AF_INET, SOCK_STREAM, 0))); + setsockopt(server, SOL_SOCKET, SO_RCVTIMEO, &timeo, sizeof(timeo)); + setsockopt(server, SOL_SOCKET, SO_SNDTIMEO, &timeo, sizeof(timeo)); + setsockopt(server, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)); + setsockopt(server, SOL_SOCKET, SO_REUSEPORT, &yes, sizeof(yes)); + setsockopt(server, SOL_TCP, TCP_FASTOPEN, &yes, sizeof(yes)); + setsockopt(server, SOL_TCP, TCP_QUICKACK, &yes, sizeof(yes)); + errno = 0; + + CHECK_NE(-1, bind(server, &addr, sizeof(addr))); + CHECK_NE(-1, listen(server, 1)); + msg = _gc(xmalloc(sizeof(struct HttpMessage))); + + // connection loop + while (!nsync_note_is_notified(g_shutdown)) { + int msgcount; + struct Data d; + struct Url url; + bool comp, ipv6; + struct Asset *a; + ssize_t got, sent; + struct iovec iov[2]; + uint32_t ip, clientip; + uint32_t clientaddrsize; + struct sockaddr_in clientaddr; + int client, inmsglen, outmsglen; + char inbuf[1500], outbuf[512], ipbuf[32], *p, *q; + + // this slows the server down a lot but is needed on non-Linux to + // react to keyboard ctrl-c + if (!IsLinux() && + poll(&(struct pollfd){server, POLLIN}, 1, HEARTBEAT) < 1) { + continue; + } + + // wait for client connection + clientaddrsize = sizeof(clientaddr); + client = accept(server, (struct sockaddr *)&clientaddr, &clientaddrsize); + if (client == -1) continue; + ip = clientip = ntohl(clientaddr.sin_addr.s_addr); + + // strict message loop w/o pipelining + msgcount = 0; + do { + InitHttpMessage(msg, kHttpRequest); + if ((got = read(client, inbuf, sizeof(inbuf))) <= 0) break; + if ((inmsglen = ParseHttpMessage(msg, inbuf, got)) <= 0) break; + if (msg->version != 11) break; // cloudflare won't send 0.9 or 1.0 + + // get the ip address again + // we assume a firewall only lets the frontend talk to this server + ipv6 = false; + if (HasHeader(kHttpXForwardedFor) && + ParseForwarded(HeaderData(kHttpXForwardedFor), + HeaderLength(kHttpXForwardedFor), &ip, 0) == -1) { + ipv6 = true; + } + ksnprintf(ipbuf, sizeof(ipbuf), "%hhu.%hhu.%hhu.%hhu", ip >> 24, ip >> 16, + ip >> 8, ip); + + if (UrlEqual("/") || UrlEqual("/index.html")) { + a = &g_asset.index; + } else if (UrlEqual("/about.html")) { + a = &g_asset.about; + } else if (UrlEqual("/user.html")) { + a = &g_asset.user; + } else if (UrlEqual("/board")) { + a = &g_asset.board; + } else { + a = 0; + } + + if (a) { + comp = HeaderHas(msg, inbuf, kHttpAcceptEncoding, "gzip", 4); + p = stpcpy(outbuf, "HTTP/1.1 200 OK\r\n" STANDARD_RESPONSE_HEADERS + "Cache-Control: max-age=60, must-revalidate\r\n" + "Vary: Accept-Encoding\r\n" + "Date: "); + p = FormatDate(p); + //////////////////////////////////////// + nsync_mu_rlock(&a->lock); + p = stpcpy(p, "\r\nLast-Modified: "); + p = stpcpy(p, a->lastmod); + p = stpcpy(p, "\r\nContent-Type: "); + p = stpcpy(p, a->type); + if (comp) p = stpcpy(p, "\r\nContent-Encoding: gzip"); + p = stpcpy(p, "\r\nContent-Length: "); + d = comp ? a->gzip : a->data; + p = FormatInt32(p, d.n); + p = stpcpy(p, "\r\n\r\n"); + iov[0].iov_base = outbuf; + iov[0].iov_len = p - outbuf; + iov[1].iov_base = d.p; + iov[1].iov_len = d.n; + sent = writev(client, iov, 2); + outmsglen = iov[0].iov_len + iov[1].iov_len; + nsync_mu_runlock(&a->lock); + //////////////////////////////////////// + + } else if (UrlEqual("/ip")) { + if (!ipv6) { + p = stpcpy(outbuf, "HTTP/1.1 200 OK\r\n" STANDARD_RESPONSE_HEADERS + "Content-Type: text/plain\r\n" + "Cache-Control: private\r\n" + "Date: "); + p = FormatDate(p); + p = stpcpy(p, "\r\nContent-Length: "); + p = FormatInt32(p, strlen(ipbuf)); + p = stpcpy(p, "\r\n\r\n"); + p = stpcpy(p, ipbuf); + outmsglen = p - outbuf; + sent = write(client, outbuf, outmsglen); + } else { + Ipv6Warning: + DEBUG("%.*s via %s: 400 Need IPv4\n", + HeaderLength(kHttpXForwardedFor), + HeaderData(kHttpXForwardedFor), ipbuf); + q = "IPv4 Games only supports IPv4 right now"; + p = stpcpy(outbuf, + "HTTP/1.1 400 Need IPv4\r\n" STANDARD_RESPONSE_HEADERS + "Content-Type: text/plain\r\n" + "Cache-Control: private\r\n" + "Connection: close\r\n" + "Date: "); + p = FormatDate(p); + p = stpcpy(p, "\r\nContent-Length: "); + p = FormatInt32(p, strlen(q)); + p = stpcpy(p, "\r\n\r\n"); + p = stpcpy(p, q); + outmsglen = p - outbuf; + sent = write(client, outbuf, p - outbuf); + break; + } + + } else if (msg->uri.b - msg->uri.a > 6 && + !memcmp(inbuf + msg->uri.a, "/claim", 6)) { + if (ipv6) goto Ipv6Warning; + struct Claim v = {.ip = ip}; + if (GetNick(inbuf, msg, &v)) { + if (AddClaim( + &g_claims, &v, + _timespec_add(_timespec_real(), + _timespec_frommillis(CLAIM_DEADLINE_MS)))) { + LOG("%s claimed by %s\n", ipbuf, v.name); + q = xasprintf("\n" + "
\nBack to homepage\n", + ipbuf, v.name, ipbuf, v.name, v.name); + p = stpcpy(outbuf, "HTTP/1.1 200 OK\r\n" STANDARD_RESPONSE_HEADERS + "Content-Type: text/html\r\n" + "Cache-Control: private\r\n" + "Date: "); + p = FormatDate(p); + p = stpcpy(p, "\r\nContent-Length: "); + p = FormatInt32(p, strlen(q)); + p = stpcpy(p, "\r\n\r\n"); + p = stpcpy(p, q); + outmsglen = p - outbuf; + sent = write(client, outbuf, p - outbuf); + free(q); + } else { + LOG("%s: 502 Claims Queue Full\n", ipbuf); + q = "Claims Queue Full"; + p = stpcpy( + outbuf, + "HTTP/1.1 502 Claims Queue Full\r\n" STANDARD_RESPONSE_HEADERS + "Content-Type: text/plain\r\n" + "Cache-Control: private\r\n" + "Connection: close\r\n" + "Date: "); + p = FormatDate(p); + p = stpcpy(p, "\r\nContent-Length: "); + p = FormatInt32(p, strlen(q)); + p = stpcpy(p, "\r\n\r\n"); + p = stpcpy(p, q); + outmsglen = p - outbuf; + sent = write(client, outbuf, p - outbuf); + break; + } + } else { + LOG("%s: 400 invalid name\n", ipbuf); + q = "invalid name"; + p = stpcpy(outbuf, + "HTTP/1.1 400 Invalid Name\r\n" STANDARD_RESPONSE_HEADERS + "Content-Type: text/plain\r\n" + "Cache-Control: private\r\n" + "Connection: close\r\n" + "Date: "); + p = FormatDate(p); + p = stpcpy(p, "\r\nContent-Length: "); + p = FormatInt32(p, strlen(q)); + p = stpcpy(p, "\r\n\r\n"); + p = stpcpy(p, q); + outmsglen = p - outbuf; + sent = write(client, outbuf, p - outbuf); + break; + } + + } else { + LOG("%s: 400 not found\n", ipbuf); + q = "\r\n" + "